]> kaliko git repositories - sid.git/commitdiff
Initial import
authorkaliko <kaliko@azylum.org>
Fri, 14 Nov 2014 13:22:50 +0000 (14:22 +0100)
committerkaliko <kaliko@azylum.org>
Fri, 14 Nov 2014 13:22:50 +0000 (14:22 +0100)
bot.py [new file with mode: 0644]
sid/__init__.py [new file with mode: 0644]
sid/echo.py [new file with mode: 0644]
sid/feeds.py [new file with mode: 0644]
sid/plugin.py [new file with mode: 0644]
sid/sid.py [new file with mode: 0644]

diff --git a/bot.py b/bot.py
new file mode 100644 (file)
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 (file)
index 0000000..e69de29
diff --git a/sid/echo.py b/sid/echo.py
new file mode 100644 (file)
index 0000000..eae5b56
--- /dev/null
@@ -0,0 +1,82 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2007-2012 Thomas Perl <thp.io/about>
+# Copyright (C) 2014 kaliko <kaliko@azylum.org>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+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 (file)
index 0000000..7291af2
--- /dev/null
@@ -0,0 +1,167 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2011, 2014 kaliko <kaliko@azylum.org>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+import datetime
+import threading
+import time
+
+from feedparser import parse as feed_parse
+
+from .plugin import Plugin, botcmd
+
+
+html_escape_table = {
+        "&": "&amp;",
+        '"': "&quot;",
+        "'": "&apos;",
+        ">": "&gt;",
+        "<": "&lt;",
+        }
+
+
+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'<strong>%s</strong>:' % 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'<a href="%(link)s">%(title)s</a>' % xpost
+                xhtml.append(xbody)
+
+        if len(text) > 1:
+            self.send(('<br/>'.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://bugs.debian.org/612274>
+        # 'http://www.debian.org/security/dsa',
+
+        # not working <http://bugs.debian.org/612274>
+        # '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 (file)
index 0000000..671df85
--- /dev/null
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2010, 2011 Anaël Verrier <elghinn@free.fr>
+# Copyright (C) 2014 kaliko <kaliko@azylum.org>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+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 (file)
index 0000000..24f543f
--- /dev/null
@@ -0,0 +1,187 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (C) 2007-2012 Thomas Perl <thp.io/about>
+# Copyright (C) 2010, 2011 Anaël Verrier <elghinn@free.fr>
+# Copyright (C) 2014 kaliko <kaliko@azylum.org>
+
+# 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 <http://www.gnu.org/licenses/>.
+
+
+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 <command name>'.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