From: kaliko Date: Fri, 14 Nov 2014 13:22:50 +0000 (+0100) Subject: Initial import X-Git-Tag: 0.1.0~53 X-Git-Url: https://git.kaliko.me/?a=commitdiff_plain;h=8050b8698ff1f6294abceb8b022a4aecdbe8375e;p=sid.git Initial import --- 8050b8698ff1f6294abceb8b022a4aecdbe8375e diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..35ac4a5 --- /dev/null +++ b/bot.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from sid.sid import MUCBot +from sid.echo import Echo +from sid.feeds import Feeds + +JID = 'bot@example.org' +NICK = 'sid' +PASS = '53cr34' +ROOM = 'room@conf.example.org' + +def main(): + Feeds.TEMPO = 60 + xmpp = MUCBot(JID, PASS, ROOM, NICK) + xmpp.register_bot_plugin(Feeds) + # Connect to the XMPP server and start processing XMPP stanzas. + if xmpp.connect(): + # If you do not have the dnspython library installed, you will need + # to manually specify the name of the server if it does not match + # the one in the JID. For example, to use Google Talk you would + # need to use: + # + # if xmpp.connect(('talk.google.com', 5222)): + # ... + xmpp.process(block=True) + xmpp.shutdown_plugins() + xmpp.log.info('Done') + else: + xmpp.log.info('Unable to connect.') + +# Script starts here +if __name__ == '__main__': + main() + +# VIM MODLINE +# vim: ai ts=4 sw=4 sts=4 expandtab diff --git a/sid/__init__.py b/sid/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sid/echo.py b/sid/echo.py new file mode 100644 index 0000000..eae5b56 --- /dev/null +++ b/sid/echo.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2007-2012 Thomas Perl +# Copyright (C) 2014 kaliko + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from .plugin import Plugin, botcmd + +class Echo(Plugin): + + def __init__(self, bot): + Plugin.__init__(self, bot) + bot.add_event_handler("groupchat_message", self.muc_message) + # The groupchat_presence event is triggered whenever a + # presence stanza is received from any chat room, including + # any presences you send yourself. To limit event handling + # to a single room, use the events muc::room@server::presence, + # muc::room@server::got_online, or muc::room@server::got_offline. + #bot.add_event_handler("muc::%s::got_online" % self.bot.room, self.muc_online) + + def muc_message(self, msg): + """ + Process incoming message stanzas from any chat room. Be aware + that if you also have any handlers for the 'message' event, + message stanzas may be processed by both handlers, so check + the 'type' attribute when using a 'message' event handler. + + Whenever the bot's nickname is mentioned, respond to + the message. + + IMPORTANT: Always check that a message is not from yourself, + otherwise you will create an infinite loop responding + to your own messages. + + This handler will reply to messages that mention + the bot's nickname. + + Arguments: + msg -- The received message stanza. See the documentation + for stanza objects and the Message stanza to see + how it may be used. + """ + if msg['mucnick'] != self.bot.nick and self.bot.nick in msg['body']: + self.bot.send_message(mto=msg['from'].bare, + mbody="I heard that, %s." % msg['mucnick'], + mtype='groupchat') + + def muc_online(self, presence): + """ + Process a presence stanza from a chat room. In this case, + presences from users that have just come online are + handled by sending a welcome message that includes + the user's nickname and role in the room. + + Arguments: + presence -- The received presence stanza. See the + documentation for the Presence stanza + to see how else it may be used. + """ + if presence['muc']['nick'] != self.bot.nick: + self.bot.send_message(mto=presence['from'].bare, + mbody="Hello, %s %s" % (presence['muc']['role'], + presence['muc']['nick']), + mtype='groupchat') + + @botcmd + def tell(self, message, args): + pass + +# VIM MODLINE +# vim: ai ts=4 sw=4 sts=4 expandtab diff --git a/sid/feeds.py b/sid/feeds.py new file mode 100644 index 0000000..7291af2 --- /dev/null +++ b/sid/feeds.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2011, 2014 kaliko + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime +import threading +import time + +from feedparser import parse as feed_parse + +from .plugin import Plugin, botcmd + + +html_escape_table = { + "&": "&", + '"': """, + "'": "'", + ">": ">", + "<": "<", + } + + +def html_escape(text): + """Produce entities within text.""" + return "".join(html_escape_table.get(c, c) for c in text) + + +def strtm_to_dtm(struc_time): + return datetime.datetime(*struc_time[:6]) + + +class FeedMonitor(threading.Thread): + def __init__(self, plugin): + threading.Thread.__init__(self) + self.feeds_list = plugin.FEEDS + self.tempo = plugin.TEMPO + self.bot = plugin.bot + self.last_check = datetime.datetime.utcnow() + self.seen = dict() + self.thread_killed = False + + def send(self, message): + """simple wrapper around JabberBot().send()""" + self.bot.log.debug(self.bot.room) + self.bot.send_message(mto=self.bot.room, + mbody=message[1], + mhtml=message[0], + mtype='groupchat') + + def new_posts(self, feed): + """Send new posts in feed""" + parsed_feed = feed_parse(feed) + + # Cannot resolve address + if 'status' not in parsed_feed: + self.bot.log.error(u'Error from "%s": %s.' % + (feed, parsed_feed.bozo_exception.__repr__())) + return + + # unusual return http code + if parsed_feed.status != 200: + self.bot.log.error( + u'Got code %(status)d from "%(href)s" (please update).' % + parsed_feed) + return + + feed_updated = parsed_feed.feed.get('updated_parsed', None) + + # Avoid looping over all posts if possible + if feed_updated and strtm_to_dtm(feed_updated) < self.last_check: + self.bot.log.debug('updated : %s' % strtm_to_dtm(feed_updated)) + self.bot.log.debug('last check: %s' % self.last_check) + return + + title = u'"%s":' % parsed_feed.feed.get('title', 'n/a') + xtitle = u'%s:' % html_escape( + parsed_feed.feed.get('title', 'n/a')) + text = [title] + xhtml = [xtitle] + feed_id = parsed_feed.feed.get('id', feed) + if not self.seen.setdefault(feed_id): + # Fills with post id when first started (prevent from posting all + # entries at startup) + self.seen[feed_id] = [post.id for post in parsed_feed.entries] + return + + for post in parsed_feed.entries: + if post.id not in self.seen.get(feed_id): + self.seen[feed_id].append(post.id) + self.bot.log.info(post.title) + + body = u'%(title)s %(link)s' % post + text.append(body) + + xpost = dict(**post) + xpost['title'] = html_escape(xpost.get('title', 'n/a')) + xbody = u'%(title)s' % xpost + xhtml.append(xbody) + + if len(text) > 1: + self.send(('
'.join(xhtml), '\n'.join(text))) + + def run(self): + while not self.thread_killed: + self.bot.log.info(u'feeds check') + for feed in self.feeds_list: + try: + self.new_posts(feed) + except Exception as err: + self.bot.log.error(u'feeds thread crashed') + self.bot.log.error(err) + self.thread_killed = True + self.last_check = datetime.datetime.utcnow() + for _ in list(range(self.tempo)): + time.sleep(1) + if self.thread_killed: + return + + +class Feeds(Plugin): + TEMPO = 60 + FEEDS = [ + # not working + # 'http://www.debian.org/security/dsa', + + # not working + # 'http://www.debian.org/News/news', + + # DPN in french + 'http://www.debian.org/News/weekly/dwn.fr.rdf', + + # Misc + 'http://rss.gmane.org/topics/excerpts/gmane.linux.debian.devel.announce', + 'http://rss.gmane.org/gmane.linux.debian.user.security.announce', + 'http://planet-fr.debian.net/users/rss20.xml', + 'http://planet.debian.org/atom.xml', + ] + + def __init__(self, bot): + Plugin.__init__(self, bot) + self.last_check = None + self.th_mon = FeedMonitor(self) + self.th_mon.start() + + def shutdown(self): + self.th_mon.thread_killed = True + + @botcmd + def feeds(self, message, args): + """feeds monitors debian project related feeds. + !feeds : registred feeds list + !feeds last : last check time""" + if 'last' in args: + return u'Last feeds check: %s' % self.th_mon.last_check + return u'\n'.join(Feeds.FEEDS) diff --git a/sid/plugin.py b/sid/plugin.py new file mode 100644 index 0000000..671df85 --- /dev/null +++ b/sid/plugin.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2010, 2011 Anaël Verrier +# Copyright (C) 2014 kaliko + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from .sid import botcmd + +class Plugin(object): + + def __init__(self, bot): + self.bot = bot + + def shutdown(self): + pass diff --git a/sid/sid.py b/sid/sid.py new file mode 100644 index 0000000..24f543f --- /dev/null +++ b/sid/sid.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- + +# Copyright (C) 2007-2012 Thomas Perl +# Copyright (C) 2010, 2011 Anaël Verrier +# Copyright (C) 2014 kaliko + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import inspect +import logging +import traceback + +import sleekxmpp + + +def botcmd(*args, **kwargs): + """Decorator for bot command functions""" + + def decorate(func, hidden=False, name=None): + setattr(func, '_bot_command', True) + setattr(func, '_bot_command_hidden', hidden) + setattr(func, '_bot_command_name', name or func.__name__) + if func.__doc__ is None: + func.__doc__ = '' + return func + + if len(args): + return decorate(args[0], **kwargs) + else: + return lambda func: decorate(func, **kwargs) + + +class MUCBot(sleekxmpp.ClientXMPP): + + prefix = '!' + + def __init__(self, jid, password, room, nick, log_file=None, + log_level=logging.INFO): + super(MUCBot, self).__init__(jid, password) + + self.log = logging.getLogger(__name__) + self.plugins = list() + self.commands = dict() + self.room = room + self.nick = nick + self.__set_logger(log_file, log_level) + self.register_plugin('xep_0030') # Service Discovery + self.register_plugin('xep_0045') # Multi-User Chat + self.register_plugin('xep_0199') # self Ping + + # The session_start event will be triggered when + # the bot establishes its connection with the server + # and the XML streams are ready for use. We want to + # listen for this event so that we we can initialize + # our roster. + self.add_event_handler("session_start", self.start) + + # Handles MUC message and dispatch + self.add_event_handler("groupchat_message", self.muc_message) + + # Discover bot internal command (ie. help) + for name, value in inspect.getmembers(self): + if inspect.ismethod(value) and getattr(value, '_bot_command', False): + name = getattr(value, '_bot_command_name') + self.log.debug('Registered command: %s' % name) + self.commands[name] = value + + def __set_logger(self, log_file=None, log_level=logging.INFO): + """Create console/file handler""" + log_fd = open(log_file, 'w') if log_file else None + chandler = logging.StreamHandler(log_fd) + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s') + chandler.setFormatter(formatter) + self.log.addHandler(chandler) + self.log.setLevel(log_level) + self.log.debug('set logger, log level : %s' % log_level) + + def muc_message(self, msg): + # ignore message from self + body = msg['body'] + mucfrom = msg['mucnic'] + if msg['mucnick'] == self.nick: + self.log.debug('ignoring message from me') + return + if not body.startswith(MUCBot.prefix): + return + args = body[1:].split() + cmd= args.pop(0) + if cmd not in self.commands: + return + self.log.debug('cmd: {0}'.format(cmd)) + if args: + self.log.debug('arg: {0}'.format(args)) + try: + reply = self.commands[cmd](msg, args) + except Exception as err: + reply = traceback.format_exc(err) + self.log.exception('An error occurred processing: {0}: {1}'.format(body, reply)) + self.log.debug(reply) + self.send_message(mto=msg['from'].bare, mbody=reply, mtype='groupchat') + + def start(self, event): + """ + Process the session_start event. + + Typical actions for the session_start event are + requesting the roster and broadcasting an initial + presence stanza. + + Arguments: + event -- An empty dictionary. The session_start + event does not provide any additional + data. + """ + self.get_roster() + self.send_presence() + self.plugin['xep_0045'].joinMUC(self.room, + self.nick, + # If a room password is needed, use: + # password=the_room_password, + wait=True) + + def register_bot_plugin(self, plugin_class): + self.plugins.append(plugin_class(self)) + for name, value in inspect.getmembers(self.plugins[-1]): + if inspect.ismethod(value) and getattr(value, '_bot_command', + False): + name = getattr(value, '_bot_command_name') + self.log.debug('Registered command: %s' % name) + self.commands[name] = value + + def foreach_plugin(self, method, *args, **kwds): + for plugin in self.plugins: + self.log.debug('shuting down %s' % plugin.__str__) + getattr(plugin, method)(*args, **kwds) + + def shutdown_plugins(self): + # TODO: why can't use event session_end|disconnected? + self.log.info('shuting down') + for plugin in self.plugins: + self.log.debug('shuting down %s' % plugin) + getattr(plugin, 'shutdown')() + + @botcmd + def help(self, message, args): + """Returns a help string listing available options. + + Automatically assigned to the "help" command.""" + self.log.info(args) + help_cmd = ('Type {}help '.format(self.prefix) + + ' to get more info about that specific command.') + if not args: + if self.__doc__: + description = self.__doc__.strip() + else: + description = 'Available commands:' + + cmd_list = list() + for name, cmd in self.commands.items(): + if name == 'help' or cmd._bot_command_hidden: + continue + doc = (cmd.__doc__.strip() or 'undocumented').split('\n', 1)[0] + cmd_list.append('{0}: {1}'.format(name, doc)) + + usage = '\n'.join(cmd_list) + usage = usage + '\n\n' + help_cmd + text = '{}\n\n{}'.format(description, usage) + else: + self.log.debug(args[0]) + self.log.debug(args[0] in self.commands.keys()) + if args[0] in self.commands.keys(): + text = self.commands[args[0]].__doc__.strip() or 'undocumented' + else: + text = 'That command is not defined.' + return text