diff -Nru endroid-1.1.2/README endroid-1.2~68~ubuntu12.10.1/README --- endroid-1.1.2/README 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/README 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,18 @@ +___________ ________ .__ .___ +\_ _____/ ____ \______ \_______ ____ |__| __| _/ + | __)_ / \ | | \_ __ \/ _ \| |/ __ | + | \ | \| ` \ | \( <_> ) / /_/ | +/_______ /___| /_______ /__| \____/|__\____ | + \/ \/ \/ \/ +* EnDroid XMPP Bot + + +* Introduction +** Endroid is an extensible XMPP bot, built with a plugin architecture +** The endroid.sh script in this directory may be used to start EnDroid easily, e.g. for testing + +* Example confiuration +** You should register a Jabber ID for your EnDroid, e.g. at https://register.jabber.org/ +** Put config in ~/.endroid/endroid.conf e.g. + +See etc/endroid.conf for an example config file. diff -Nru endroid-1.1.2/bin/endroid endroid-1.2~68~ubuntu12.10.1/bin/endroid --- endroid-1.1.2/bin/endroid 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/bin/endroid 2013-09-16 14:30:23.000000000 +0000 @@ -1,2 +1,5 @@ #!/bin/sh -PYTHONPATH="/usr/lib/endroid/dependencies/wokkel-0.7.0-py2.7.egg":"/usr/lib/endroid/plugins":"${PYTHONPATH}" python -c "import endroid; endroid.main()" $@ +# Use exec here to ensure that upstart (or other init daemon) can easily follow +# the true daemon process. +export PYTHONPATH="~/.endroid/plugins/":"/usr/lib/endroid/dependencies/wokkel-0.7.1-py2.7.egg":"/usr/lib/endroid/plugins":"${PYTHONPATH}" +exec python -m endroid $@ diff -Nru endroid-1.1.2/bin/endroid_remote endroid-1.2~68~ubuntu12.10.1/bin/endroid_remote --- endroid-1.1.2/bin/endroid_remote 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/bin/endroid_remote 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,152 @@ +#!/usr/bin/env python + +import os +import re +import sys +import urllib + +KEY_ENV_VAR = 'ENDROID_REMOTE_KEY' # eg XACKJAASKJDAHD +JID_ENV_VAR = 'ENDROID_REMOTE_USER' # eg frodo@shire.org +URL_ENV_VAR = 'ENDROID_REMOTE_URL' # eg http://127.0.0.1:8880/remote/ + +class UserError(Exception): + pass + +class EndroidError(Exception): + pass + +class UsageError(UserError): + def __init__(self, err_msg=None): + usage_lines = [] + + if err_msg is not None: + usage_lines.append("Error: %s" % err_msg) + usage_lines.append("") + + usage_lines.append("Usage:") + + for cls in mode_classes.values(): + usage_lines.append(" {bin} {cmd}{usage}".format( + bin=sys.argv[0], cmd=cls.cmd, usage=cls.usage)) + usage_lines.append(" " + cls.help) + + super(UsageError, self).__init__('\n'.join(usage_lines)) + +def endroid_send(msg): + params = {'user': os.environ[JID_ENV_VAR], + 'key': os.environ[KEY_ENV_VAR], + 'message': msg} + + f = urllib.urlopen(os.environ[URL_ENV_VAR], urllib.urlencode(params)) + for line in f.readlines(): + line = line.strip() + if "Error:" in line: + raise EndroidError(line) + +class Watch(object): + cmd = 'watch' + usage = ' ' + help = ('Read lines from stdin, print them to stdout, and send lines ' + 'matching the Python regex to EnDroid.') + + def __init__(self, args): + if len(args) < 1: + raise UsageError("Missing regex argument.") + if len(args) > 1: + raise UsageError("Too many arguments.") + + self._re = re.compile(args[0]) + + def event_loop(self): + line = sys.stdin.readline() + while line: + sys.stdout.write(line) + sys.stdout.flush() + + if self._re.search(line): + endroid_send(line.rstrip()) + + line = sys.stdin.readline() + +class Tee(object): + cmd = 'tee' + usage = '' + help = ('Read lines from stdin, print them to stdout and send them to ' + 'EnDroid.') + + def __init__(self, args): + if len(args) != 0: + raise UsageError("Too many arguments.") + + def event_loop(self): + msg = "" + + line = sys.stdin.readline() + while line: + sys.stdout.write(line) + sys.stdout.flush() + msg += line + + line = sys.stdin.readline() + + endroid_send(msg) + +class Echo(object): + cmd = 'echo' + usage = ' ' + help = 'Send to EnDroid.' + + def __init__(self, args): + if len(args) < 1: + raise UsageError("Message argument required.") + self.msg = ' '.join(args) + + def event_loop(self): + endroid_send(self.msg) + +class Cat(object): + cmd = 'cat' + usage = '' + help = 'Read lines from stdin and send them to EnDroid.' + + def __init__(self, args): + if len(args) != 0: + raise UsageError("Too many arguments.") + + def event_loop(self): + endroid_send(sys.stdin.read()) + +mode_classes = dict((cls.cmd, cls) for cls in + (Watch, Tee, Echo, Cat)) + +try: + if len(sys.argv) < 2: + raise UsageError("Command required.") + + try: + mode_class = mode_classes[sys.argv[1]] + except KeyError: + raise UsageError("Invalid command '%s'" % sys.argv[1]) + + mode = mode_class(sys.argv[2:]) + + if JID_ENV_VAR not in os.environ: + raise UserError("Target user env var (%s) not set" % JID_ENV_VAR) + + if KEY_ENV_VAR not in os.environ: + raise UserError("Message key env var (%s) not set" % KEY_ENV_VAR) + + if URL_ENV_VAR not in os.environ: + raise UserError("Endroid URL env var (%s) not set" % URL_ENV_VAR) + + mode.event_loop() + +except UserError as e: + print e + +except EndroidError as e: + print "EnDroid error: %s" % e + +except KeyboardInterrupt: + pass + diff -Nru endroid-1.1.2/bin/spelunk_hi5s endroid-1.2~68~ubuntu12.10.1/bin/spelunk_hi5s --- endroid-1.1.2/bin/spelunk_hi5s 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/bin/spelunk_hi5s 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,53 @@ +#!/usr/bin/python +# +# Decrypt hi5s +# +# This is all a last-resort mechanism (the whole point of hi5s is that they're +# anonymous). The EnDroid database (eg /var/lib/endroid/endroid.db) is the only +# required command-line parameter. +# +# Copyright (C) Ensoft 2013 +# Created by SimonC + +import argparse, sqlite3 +from getpass import getpass +from subprocess import Popen, PIPE + +HI5_DB = 'hi5_hi5s' + +def set_params(): + """ + Set all parameters + """ + parser = argparse.ArgumentParser(description= + 'Spelunk encrypted high five messages') + parser.add_argument('--keyring', help='gpg secret keyring') + parser.add_argument('db_file', help='path to endroid.db') + args = parser.parse_args() + args.passphrase = getpass('Enter passphrase: ') + return args + +def main(): + args = set_params() + conn = sqlite3.connect(args.db_file) + cur = conn.cursor() + cur.execute('select * from ' + HI5_DB) + for row in cur.fetchall(): + gpg_args = ['gpg', '--decrypt', '--armor', '--passphrase-fd', '0', + '--batch', '--quiet'] + if args.keyring: + gpg_args.extend(('--secret-keyring', args.keyring)) + p = Popen(gpg_args, stdin=PIPE, stdout=PIPE) + p.stdin.write(args.passphrase + '\n') + out, err = p.communicate(row[-1]) + p.wait() + if err: + print '!! ERROR:', err + elif p.returncode != 0: + # No encrypted data, so display what we have + print '{}: {}'.format(row[2], row[1]) + else: + print out + +if __name__ == '__main__': + main() diff -Nru endroid-1.1.2/debian/bzr-builder.manifest endroid-1.2~68~ubuntu12.10.1/debian/bzr-builder.manifest --- endroid-1.1.2/debian/bzr-builder.manifest 2012-09-04 12:13:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/debian/bzr-builder.manifest 2013-09-16 14:30:23.000000000 +0000 @@ -1,2 +1,2 @@ -# bzr-builder format 0.3 deb-version {debupstream}-0~27 -lp:endroid revid:mm@ensoft.co.uk-20120904120424-0a7k5lsk2h02bgdp +# bzr-builder format 0.3 deb-version {debupstream}~68 +lp:endroid revid:tarmac-20130913130152-mealoyvr6xisceu0 diff -Nru endroid-1.1.2/debian/changelog endroid-1.2~68~ubuntu12.10.1/debian/changelog --- endroid-1.1.2/debian/changelog 2012-09-04 12:13:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/debian/changelog 2013-09-16 14:30:23.000000000 +0000 @@ -1,8 +1,19 @@ -endroid (1.1.2-0~27~quantal1) quantal; urgency=low +endroid (1.2~68~ubuntu12.10.1) quantal; urgency=low * Auto build. - -- Martin Morrison Tue, 04 Sep 2012 12:13:00 +0000 + -- Martin Morrison Mon, 16 Sep 2013 14:30:23 +0000 + +endroid (1.2) lucid; urgency=low + + * Stability improvements + * Added roomowner plugin allowing EnDroid to own and admin rooms + * New wiki Docs + * Expanded config syntax (note this won't work with old config files) + * Few new plugins: invite, compute and remote + * Many other minor fixes + + -- Martin Morrison Wed, 14 Aug 2013 13:00:00 +0100 endroid (1.1.2) lucid; urgency=low diff -Nru endroid-1.1.2/debian/conffiles endroid-1.2~68~ubuntu12.10.1/debian/conffiles --- endroid-1.1.2/debian/conffiles 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/debian/conffiles 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1 @@ +var/lib/endroid/db/endroid.db diff -Nru endroid-1.1.2/debian/control endroid-1.2~68~ubuntu12.10.1/debian/control --- endroid-1.1.2/debian/control 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/debian/control 2013-09-16 14:30:23.000000000 +0000 @@ -8,6 +8,7 @@ Package: endroid Architecture: all Depends: python,python-tz,python-twisted,python-dateutil,${misc:Depends} +Suggests: fortune Description: XMPP bot framework EnDroid is a framework for building bots in XMPP. Its architecture is based around plugins, in order to make it as extensibile as possible. diff -Nru endroid-1.1.2/debian/endroid.install endroid-1.2~68~ubuntu12.10.1/debian/endroid.install --- endroid-1.1.2/debian/endroid.install 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/debian/endroid.install 2013-09-16 14:30:23.000000000 +0000 @@ -1,4 +1,9 @@ etc/endroid.conf etc/endroid/ +etc/endroid.conf usr/share/doc/endroid/examples/ etc/init/endroid.conf etc/init/ bin/endroid usr/bin/ -lib/wokkel-0.7.0-py2.7.egg usr/lib/endroid/dependencies +bin/endroid_remote usr/bin/ +bin/spelunk_hi5s usr/sbin/ +lib/wokkel-0.7.1-py2.7.egg usr/lib/endroid/dependencies +var/endroid.db var/lib/endroid/db +doc/EnDroid.png usr/share/endroid/media/ diff -Nru endroid-1.1.2/doc/Configuration endroid-1.2~68~ubuntu12.10.1/doc/Configuration --- endroid-1.1.2/doc/Configuration 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/doc/Configuration 1970-01-01 00:00:00.000000000 +0000 @@ -1,39 +0,0 @@ -#acl EnsoftLander:read,write,delete,revert,admin All:read - -= Configuration = - -The config file is formatted as a standard "ini" config file (using Python's standard !ConfigParser library). The sections/options are as follows (bold = required): - -== Basic Configuration == - - * '''[Setup]''' - * '''jid''' - the JID from which to sign in, including a resource (`username@host.tld/resource`) - * '''secret''' - the password for this JID - * nick - the default nick to use in rooms. If this is not provided then it defaults to the JID. - * plugins - comma-or-newline-separated default list of plugins to use. If this is not provided then it defaults to none. (example plugin: endroid.plugins.echobot) - * Without plugins, your EnDroid isn't going to do much. Several features that might be expected to be core are actually implemented as plugins, so you should consider at a minimum running help, unhandled, command, patternmatcher - * users - list of users to allow. EnDroid will add all of these to its contacts, and will unfriend anyone who it sees who is not in this list. - * rooms - list of rooms to join. EnDroid will only join rooms in this list, regardless of whether they have configuration information further down the file. - - * [Database] - * dbfile - the location of the database file. In a default installation this will be at `/var/lib/endroid/db/endroid.db` - * [!UserGroup:*] - * plugins - if this is specified, then it replaces the plugins directive in the Setup section for all single user chats. Otherwise, defaults to equal it. - * [Room:*] - * plugins - ...same, but for all rooms. - * nick - if specified, overrides the nick directive from the Setup section, otherwise defaults to that. - * [UserGroup:Group Name] - * users - list of all users' JIDs in the group. - * plugins - if this is specified, then it replaces the current default for users (i.e. that in !UserGroup:*, which equally could be taken from Setup...) - * [Room:Room JID] - * plugins - if this is specified, then it replaces the current default for rooms (i.e. that in Room:*, which equally could be taken from Setup...) - * nick - you get the idea - * [Plugin:Plugin Package] - * whatever options the plugin supports - -== Further Config == - -If more detailed configuration is required, plugins can be configured on a per-room basis. - -In the same folder as the standard configuration file, the plugins can have a filename plugin-!PluginName.cfg file. Sections are [Room:RoomName], then the same directives go in here as under the [Plugin:...] sections. - Binary files /tmp/O6nprNwWho/endroid-1.1.2/doc/EnDroid.png and /tmp/c6D8U1aHWi/endroid-1.2~68~ubuntu12.10.1/doc/EnDroid.png differ diff -Nru endroid-1.1.2/doc/Index endroid-1.2~68~ubuntu12.10.1/doc/Index --- endroid-1.1.2/doc/Index 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/doc/Index 1970-01-01 00:00:00.000000000 +0000 @@ -1,13 +0,0 @@ -#acl EnsoftLander:read,write,delete,revert,admin All:read - -= EnDroid = - -EnDroid is a modular XMPP bot platform written in Python and built upon the Twisted framework. - -== Documentation == - - * [[EnDroid/Installation|Installation]] - * [[EnDroid/Configuration|Configuration]] - * [[EnDroid/WritingPlugins|WritingPlugins]] - -<> diff -Nru endroid-1.1.2/doc/Installation endroid-1.2~68~ubuntu12.10.1/doc/Installation --- endroid-1.1.2/doc/Installation 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/doc/Installation 1970-01-01 00:00:00.000000000 +0000 @@ -1,16 +0,0 @@ -#acl EnsoftLander:read,write,delete,admin,revert All:read - -= Installation = - -Installing EnDroid is straightforward. Installation packages are provided (currently only for Ubuntu) [[https://launchpad.net/endroid/+download|on Launchpad]]. It is also possible to run it directly by checking out the code: - - * `bzr branch lp:endroid` - to get the latest code. - * Create yourself a configuration file; an example is provided in the source in `etc/endroid.conf` - * You'll need an XMPP account to use for the bot - * '''Note''': EnDroid will "unfriend" any contact not directly configured. You have been warned! - * cd into the `src` directory and run the `endroid.sh` script there, passing your configuration file path as an argument - * By default, EnDroid looks in `~/.endroid/endroid.conf`, then `/etc/endroid/endroid.conf` - * on platforms without `/bin/sh`, `python -m endroid` (or, for Python 2.6 and earlier, the slightly long-winded `python -c 'import endroid; endroid.main()'` can be used instead - -Note that EnDroid requires python 2.6 or later to run, and depends on Twisted, Wokkel, PyTZ and python-dateutil. - diff -Nru endroid-1.1.2/doc/WritingPlugins endroid-1.2~68~ubuntu12.10.1/doc/WritingPlugins --- endroid-1.1.2/doc/WritingPlugins 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/doc/WritingPlugins 1970-01-01 00:00:00.000000000 +0000 @@ -1,5 +0,0 @@ -#acl EnsoftLander:read,write,delete,admin,revert All:read - -= Writing Plugins = - -TBD. diff -Nru endroid-1.1.2/doc/module-diagram.graphml endroid-1.2~68~ubuntu12.10.1/doc/module-diagram.graphml --- endroid-1.1.2/doc/module-diagram.graphml 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/doc/module-diagram.graphml 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,1841 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + utilities + + + + + + + + + + Folder 8 + + + + + + + + + + + + + + + + + Database + + + + + + + + + + + + + + + + + Cron + + + + + + + + + + + + + + + + + + Parser + + + + + + + + + + + + + + + + + Manhole + + + + + + + + + + + + + + + + + + + + + + Endroid Core + + + + + + + + + + Folder 9 + + + + + + + + + + + + + + + + + + + usermanagement.py + + + + + + + + + + Folder 1 + + + + + + + + + + + + + + + + + UserManagement + + + + + + + + + + + + + + + + + Room + + + + + + + + + + + + + + + + + Roster + + + + + + + + + + + + + + + + + + + + + + messagehandler.py + + + + + + + + + + Folder 2 + + + + + + + + + + + + + + + + + MessageHandler + + + + + + + + + + + + + + + + + Message + + + + + + + + + + + + + + + + + + + + + + pluginmanager.py + + + + + + + + + + Folder 3 + + + + + + + + + + + + + + + + PluginManager + + + + + + + + + + + + + + + + + Plugin + + + + + + + + + + + + + + + + + PluginMeta + + + + + + + + + + + + + + + + + PluginProxy + + + + + + + + + + + + + + + + + + + + Rosterhandler + + + + + + + + + + + + + + + + + + WokkelHandler + + + + + + + + + + + + + + + + + + + + + + Wokkel/Twisted + + + + + + + + + + Folder 10 + + + + + + + + + + + + + + + + MUCClientProtocol + + + + + + + + + + + + + + + + + MessageProtocol + + + + + + + + + + + + + + + + + RosterClientProtocol + + + + + + + + + + + + + + + + + PresenceClientProtocol + + + + + + + + + + + + + + + + + + + + + + utilities + + + + + + + + + + Folder 8 + + + + + + + + + + + + + + + + Cron + + + + + + + + + + + + + + + + + + + Database + + + + + + + + + + + + + + + + + + + + + + plugins + + + + + + + + + + Folder 9 + + + + + + + + + + + + + + + + + + + Helper Plugins + + + + + + + + + + Folder 11 + + + + + + + + + + + + + + + + + + + command.py + + + + + + + + + + Folder 4 + + + + + + + + + + + + + + + + CommandPlugin + + + + + + + + + + + + + + + + + CommandPluginMeta + + + + + + + + + + + + + + + + + Command + + + + + + + + + + + + + + + + + + + PatternMatcher + + + + + + + + + + + + + + + + + httpInterface + + + + + + + + + + + + + + + + + + + + + + command plugins + + + + + + + + + + Folder 6 + + + + + + + + + + + + + + + + Chuck + + + + + + + + + + + + + + + + + Coolit + + + + + + + + + + + + + + + + + Help + + + + + + + + + + + + + + + + + Invite + + + + + + + + + + + + + + + + + Theyfightcrime + + + + + + + + + + + + + + + + + + + Blacklist + + + + + + + + + + + + + + + + + Correct + + + + + + + + + + + + + + + + + Echobot + + + + + + + + + + + + + + + + + Pubpicker + + + + + + + + + + + + + + + + + Ratelimit + + + + + + + + + + + + + + + + + Roomowner + + + + + + + + + + + + + + + + + + Spell + + + + + + + + + + + + + + + + + Unhandled + + + + + + + + + + + + + + + + + Memo + + + + + + + + + + + + + + + + + Remote + + + + + + + + + + + + + + + + + Speak + + + + + + + + + + + + + + + + + Whosonline + + + + + + + + + + + + + + + + + + + + + + Boxes + + + + + + + + + + Folder 15 + + + + + + + + + + + + + + + + Endroid module + + + + + + + + + + + + + + + + + Owned by Endroid class + + + + + + + + + + + + + + + + + External library + + + + + + + + + + + + + + + + + + + + + + Working? + + + + + + + + + + Folder 12 + + + + + + + + + + + + + + + + Fully + + + + + + + + + + + + + + + + + Partially + + + + + + + + + + + + + + + + + Not + + + + + + + + + + + + + + + + + Untested + + + + + + + + + + + + + + + + + + + + + + Arrows + + + + + + + + + + Folder 13 + + + + + + + + + + + + + + + + Has a + + + + + + + + + + + + + + + + + Inherits + + + + + + + + + + + + + + + + + MetaClass + + + + + + + + + + + + + + + + + + Config + + + + + + + + + + + + + + + + + Unimportant + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff -Nru endroid-1.1.2/doc/wiki/Configuration endroid-1.2~68~ubuntu12.10.1/doc/wiki/Configuration --- endroid-1.1.2/doc/wiki/Configuration 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/doc/wiki/Configuration 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,69 @@ +#acl EnsoftLander:read,write,delete,revert,admin All:read + +<> + +== Basic Configuration == + +!EnDroid reads all of its config from a single configuration file, searched for at (in order): + * conffile as specified on the command file e.g. `endroid.sh `. + * `ENDROID_CONF` environment variable. + * `~/.endroid/endroid.conf` (recommended) + * `/etc/endroid/endroid.conf` + +An example config file can be found at `/usr/share/doc/endroid/examples/endroid.conf` if !EnDroid has been installed or in the source repository at `etc/endroid.conf`. +The basic sections of the config file are described below: + +{{{#!python +# Comments are denoted by a hash character in the first column + +[Setup] +# EnDroid's jabber login details. +jid = marvin@hhgg.tld/planet +password = secret + +# EnDroid's default nickname. If not specified, set to the part of jid before the @ +# nick = Marvin + +# EnDroid's full contact list. Users on this list will be added as friends, +# users not on this list will be removed from contacts and will be unable +# to communicate with EnDroid. +users = + +# What rooms EnDroid will attempt to create and join. Defaults to [] +# rooms = room1@ser.ver, + +# What usergroup EnDroid will register plugins with. Defaults to ['all'] +# groups = all, admins + +logfile = ~/.endroid/endroid.log + +[Database] +dbfile = ~/.endroid/endroid.db + +# a section matching groups and rooms with any name +[ group | room : *] +plugins= +# endroid.plugins., +# endroid.plugins., +# ... +}}} + +== Some Notes on Syntax == + + * !EnDroid will try to interpret values in the config file as Python objects, so `my_var = 1`. + * Bools will only be converted if they are `True` or `False` (i.e. capitalised). + * Lists are detected by commas or newlines:{{{#!python +my_list = multiple, entries, present +my_list2 = newlines + also + mean + lists +}}} + * For a list with a single item, a comma must be present at the end of the list:{{{#!python +expects_a_list1 = foo +# Will result in a string foo not a list + +expects_a_list2 = foo, +# Will result in a list of items with only one entry - foo +}}} + diff -Nru endroid-1.1.2/doc/wiki/Debugging endroid-1.2~68~ubuntu12.10.1/doc/wiki/Debugging --- endroid-1.1.2/doc/wiki/Debugging 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/doc/wiki/Debugging 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,127 @@ +#acl EnsoftLander:read,write,delete,revert,admin All:read + +<> + += Logs = + +!EnDroid currently logs to two places (this is a bug - !EnDroid currently uses `twisted.log` ''and'' `python.logging` to generate log messages - these should be combined!) + +== The Console == + + * This is where basic log output is displayed (generated from the `logging.[info|debug|...]` calls in the code). + * The verbosity of this log can be altered with the `-l ` flag, integer defaults to `1`. Lower numbers result in more logging. + * If something strange happens to !EnDroid and nothing is shown here, consult the log file. + +== The Logfile == + + * The log file as specified in `endroid.conf` (defaults to `~/.endroid/endroid.log`): + * This is where `twisted` does all its logging. + * If a `Deferred` has thrown an Exception - the traceback will be in this file. + * If something is going wrong and there are still no apparent errors here, use the `-t` flag to start !EnDroid and look for `bad-request` or `error` in the xml. + * Any `print` statements in the code will have their output redirected here. + * The location of the log file may be specified with the `-L` or `--logfile` + `` flag. + += Debugging = + +== Manhole == + +Manhole provides a way of controlling !EnDroid via ssh. It gives the user a python console through which all of !EnDroid's internals may be accessed as it is running. + +To enable Manhole, pass the flag: `-m ` to !EnDroid on startup. Ssh access is then achieved with `ssh @localhost -p ` and entering the password. + +Once inside Manhole, the user has access to the active instance of !EnDroid via `droid`. For example: + * `droid.usermanagement._pms` - will return the dictionary of `{ : }` + * `droid.usermanagement._pms['all'].get('endroid.plugins.')` will return the instance of `` active in the `'all'` usergroup. + +A user may also define functions, import modules and generally lark around as they would in a regular python prompt. (It is almost certainly worth, for example, writing a short module with some helper +functions to reduce the amount of typing required in Manhole). + +=== A Usage Example === + +Start up !EnDroid with manhole enabled: +{{{#!highlight python +me@localhost:~/endroid/src$ bash endroid.sh -t -m + +11:06:46 INFO EnDroid starting up with conffile +/home//.endroid/endroid.conf +11:06:46 INFO Found JID: +11:06:46 INFO Found Secret: ********** +... +11:06:46 INFO Starting manhole +... +}}} + +And in a separate console: +{{{#!highlight python +me@localhost:~/endroid/src$ ssh @localhost -p +admin@localhost's password: +>>> # I'm now in a python prompt +>>> 1+1 +2 +>>> droid + +>>> # I fancy sending myself a message +>>> droid.messagehandler.send_chat("me@myho.st", "Hi me, +all is well in manhole-land!") +>>> # I received the chat message. +>>> logging.info("Hi console log!") +>>> # console log: 11:04:28 INFO Hi console log! +>>> # Ctrl-D to exit +Connection to localhost closed. +me@localhost:~$ +}}} + +=== An Import Example === + +A useful helper module for debugging plugins. + +{{{#!highlight python +# src/my_helper.py + +def get_loaded(droid, room_group): + """ + Return the _loaded dict of {plugin_name : PluginProxy object} + for room_group. + + Note that a PluginProxy object is just a wrapper round a Plugin + object, and all of the plugin's attributes/functions may be accessed + through it. + + """ + return droid.usermanagement._pms[room_group]._loaded + +def get_instance(droid, room_group, plugin_name): + """Get the instance of 'plugin_name' active in 'room_group'. + + 'plugin_name' may be the full plugin_name e.g. 'endroid.plugins.chuck' + or just the last part e.g. 'chuck' + + """ + dct = get_loaded(droid, room_group) + if plugin_name in dct: + return dct[plugin_name] + else: + for key, item in dct.items(): + if plugin_name == key.split('.')[-1]: + return item + fmt = "Plugin '{}' not active in '{}'" + raise KeyError(fmt.format(plugin_name, room_group)) +}}} + +Then checking to see if our plugin's config has been properly loaded from in Manhole is easy: + +{{{#!highlight python +>>> import my_helper +>>> my_helper.get_loaded(droid, 'all').keys() +['endroid.plugins.unhandled', 'endroid.plugins.patternmatcher', + 'endroid.plugins.httpinterface', 'endroid.plugins.roomowner', + 'endroid.plugins.chuck', 'endroid.plugins.command', + 'endroid.plugins.passthebomb', 'endroid.plugins.invite', + 'endroid.plugins.help', 'endroid.plugins.broadcast'] +>>> my_helper.get_instance(droid, 'all', 'chuck').vars +{'my_list': ['list', 'with', 'numbers', 1, 2, 3], + 'my_list2': ['newlines', 'also', 'mean', 'lists'], + 'my_int': 123, 'my_string': 'this is a string'} +>>> +}}} + diff -Nru endroid-1.1.2/doc/wiki/GettingStarted endroid-1.2~68~ubuntu12.10.1/doc/wiki/GettingStarted --- endroid-1.1.2/doc/wiki/GettingStarted 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/doc/wiki/GettingStarted 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,40 @@ +#acl EnsoftLander:read,write,delete,revert,admin All:read + +<> + += Installation = + +Installing EnDroid is straightforward. Installation packages are provided (currently only for Ubuntu) [[https://launchpad.net/endroid/+download|on Launchpad]]. It is also possible to run it directly +by checking out the code using `bzr branch lp:endroid`. + + Note: !EnDroid requires python 2.7 or later to run, and depends on Twisted, Wokkel, PyTZ and python-dateutil. + +In order to run EnDroid needs to load certain settings from its config file. An example one is at `/usr/share/doc/endroid/examples/endroid.conf` if !EnDroid has been installed or in the +source repository at `etc/endroid.conf`. The settings that must be added to the config file are details of an XMPP account for !EnDroid to use (free accounts can be created easily e.g. from +[[http://comm.unicate.me/|comm.unicate.me]]). + + Note: '''Do not use your own account''' as !EnDroid will cut down its contact list to users listed in the config file. + +Other settings that should be set are `users` - the list of users !EnDroid will communicate with - remember to add yourself to this and `rooms` the list of rooms !EnDroid will join. For more details +on configuration see [[../Configuration|configuration]]. + += Running EnDroid = + + * If you installed !EnDroid, you should be able to run it by typing `endroid` into the console. + * If you pulled the source, run !EnDroid from within the `src` directory with `./endroid.sh` + * See `-h` for full list of command line arguments, some key ones are: + * `` - specify an alternative config file. + * `[-l|--level] ` - specify the verbosity of the console log (lower is more verbose) + * `[-L|--logfile] ` - redirect the console logging to a file. + * `[-t|--logtraffic]` - if `-t` is present, `twisted` will log all the raw message xml to the file log. + +See the [[../Debugging|debugging page]] for more details on logging and `Manhole`. + +If you encounter any bugs please report them at [[https://bugs.launchpad.net/endroid|EnDroid launchpad]]. + +Happy Droiding! + += What Next? = + +Write some plugins to add some new functionality! See the [[../PluginTutorial|plugin tutorial]] to find out how easy it is. + diff -Nru endroid-1.1.2/doc/wiki/Index endroid-1.2~68~ubuntu12.10.1/doc/wiki/Index --- endroid-1.1.2/doc/wiki/Index 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/doc/wiki/Index 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,16 @@ +#acl EnsoftLander:read,write,delete,revert,admin All:read + += EnDroid = + +!EnDroid is a modular XMPP bot platform written in Python and built upon the Twisted framework. + +== Documentation == + + * [[/GettingStarted|Getting Started]] + * [[/Configuration|Configuration]] + * [[/PluginTutorial|Plugin Tutorial]] + * [[/Reference|API Reference]] + * [[/Debugging|Debugging]] + +<> + diff -Nru endroid-1.1.2/doc/wiki/PluginTutorial endroid-1.2~68~ubuntu12.10.1/doc/wiki/PluginTutorial --- endroid-1.1.2/doc/wiki/PluginTutorial 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/doc/wiki/PluginTutorial 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,790 @@ +#acl EnsoftLander:read,write,delete,revert,admin All:read + +<> + += Introduction = + +Over the course of this tutorial we will write a plugin for !EnDroid which plays a simple pass-the-bomb game over chat. + +A user should be able to create a bomb with a specified timer, then throw it to another user. The recipient should then be able to continue the process. + +When the timer expires the bomb should 'kill' its holder and award a kill to whoever lit the fuse. + +This plugin is command orientated - that is we will be using it in chat by typing commands to !EnDroid e.g. `bomb 15` to create a bomb with a 15 second timer. This suggests we use the Command plugin +which simplifies this type of interaction. + +Links to the relevant sections of the !Reference page are at the bottom of each section of this tutorial. + +== Preface: Laying the ground work == + +=== Getting EnDroid === + +See: [[../GettingStarted#Installation|EnDroid installation]] for instructions on getting and setting up !EnDroid. + +We will be creating a plugin at `~/.endroid/plugins/passthebomb.py`. To get !EnDroid to load the plugin, make sure you have: + +{{{ +[room | group : * ] +plugins = passthebomb, + endroid.plugins.command, +# all the other plugins you want +}}} + +See [[../Configuration|EnDroid configuration]] for more details on the config file. + +=== Debugging === + +It is fairly probably that at some stage something will go wrong. For information about debugging problems, see: [[../Debugging|EnDroid debugging]]. + +== A Plot: CommandPlugin == + +Firstly we will get things set up so that our plugin responds to the keywords 'bomb', 'throw' and 'kills'. + +{{{#!highlight python +from endroid.plugins.command import CommandPlugin + +class PassTheBomb(CommandPlugin): + # The help attribute will be displayed when 'help passthebomb' is + # run provided that the help plugin is enabled. + help = "Pass the bomb game for EnDroid" + + def cmd_bomb(self, msg, arg): + # Create a new bomb. + msg.reply("Made a new bomb with arguments '{}'".format(arg)) + + def cmd_throw(self, msg, arg): + # Throw the bomb to another person (or ourself if we fancy). + msg.reply("Throwing a bomb with arguments '{}'".format(arg)) + + def cmd_kills(self, msg, arg): + # How many kills do we have? + msg.reply("Getting killcount with arguments '{}'".format(arg)) +}}} + +The inheritance from `CommandPlugin` means that all methods beginning with `cmd_` are automagically registered e.g. `cmd_bomb` will be called when someone types `"bomb and then whatever else they +want"`. + +Every `cmd_` function take two arguments: + * msg - the Message object that triggered the call + * arg - msg.body minus the first word (the name part of the cmd_ function) + +As it stands the plugin will do little except for reply to messages starting with the word 'bomb', 'throw' or 'kills' - but demonstrates simple response to commands. + +=== Aside: Plugin and CommandPlugin === + +The general class that plugins inherit from is, unsurprisingly, `endroid.pluginmanager.Plugin` (either directly or via a subclass such as `CommandPlugin`). The `Plugin` class has access to +`MessageHandler` and `UserManagement` objects via the `messagehandler` and `usermanagement` attributes. These objects provide access to functionality concerned with X'''MP'''P's '''m'''essaging and +'''p'''resence facilities respectively. + +Additionally the `Plugin` class has methods by which a plugin can register a function with `messagehandler` such that the function will be called every time a specified type message is received, with +the message as its argument. The primary such functions are: + * `register_muc_callback()` + * `register_chat_callback()` + +A simple example plugin: +{{{#!highlight python +from endroid.pluginmanager import Plugin + +class JFSullivan(Plugin): + def endroid_init(self): + self.register_chat_callback(self.do_harass) + self.register_muc_callback(self.do_harass) + + def do_harass(self, msg): + msg.reply("Purchase one of my fine umbrellas!") +}}} +This plugin registers for both muc and chat callbacks, so it's function is called whenever !EnDroid receives a muc or a chat message. + +`CommandPlugin` (`endroid.plugins.command.CommandPlugin`) inherits from `Plugin` and provides some helper features. It will automatically register class methods beginning with `cmd_`. It also provides +extra options such as synonyms, registering for muc or chat only and providing help messages (via the `help` plugin). For these extras it looks for attributes on methods, e.g.: + +{{{#!highlight python +from endroid.plugins.command import CommandPlugin + +class Umbrella(CommandPlugin): + def cmd_buy(self, msg, arg): + msg.reply("It will cost one thousand guineas") + cmd_buy.helphint = "When buying an umbrella, I recommend sheet steel" + cmd_buy.muc_only = True # Can only be called from a group chat. + # cmd_buy.chat_only = True # Can only be called from a single-user chat. + # messages starting with buy, purchase or acquire will call this function. + cmd_buy.synonyms = ("purchase", "acquire") +}}} + + '''Note:''' A function registered with the Plugin class's `register` methods will be called with a __single__ argument - the message that caused it to be called. A `cmd_` function in a +`CommandPlugin` derived class will be called with __two__ arguments: the message that caused it to be called, and the message's body stripped of the first word (which, as mentioned above, will have +been the name of the function minus the `cmd_`. + + +Full reference for `Plugin`: + * [[../Reference#Plugin|EnDroid reference: plugin]] + +== Lighting the Fuse: Cron, Messagehandler == + +Back to the Bomb plugin. We have got the plugin responding to commands but now we want to actually arm the bomb. + +`Cron` is !EnDroid's event scheduler. It provides methods to schedule future events, and allows persistency - i.e. events that will be remembered should !EnDroid undergo a restart. We will use it +explode our bombs at suitable (or unsuitable, depending on who is holding the bomb) times. The `Cron` singleton object is accessible via `self.cron`. + +The scheduling of functions with `Cron` is a two stage process. Firstly we register a callable with `Cron` against a registration string: `register(callable, registration_name)`. Once this is done, we +can use either `setTimeout(time, registration_name, parameters)` or `doAtTime(time, locality, registration_name, parameters)` to tell `Cron` to call the function at some point in the future +(localities are as used by `pytz.timezone`). + + '''Note:''' Registration names must be __globally__ unique (across all plugins), this is the purpose of the `get_id` function in the code below. + +{{{#!highlight python +from endroid.cron import Cron + +class Bomb(object): + ID = 0 + def __init__(self, source, fuse, plugin): + self.source = source # The user who lit the fuse. + self.user = source # Our current bearer. + self.plugin = plugin # Plugin instance we belong to. + self.history = set() # All our bearers. + + idstring = self.get_id() # Get a unique registration_name. + plugin.cron.register(self.explode, idstring) + # Schedule detonation with parameters = None. + plugin.cron.setTimeout(fuse, idstring, None) + + # This function is called by Cron and given an argument. We don't need an + # argument so just ignore it. + def explode(self, _): + # Last argument of send_chat is the new message's source which defaults + # to None and is non-essential (it is used by the Message.reply methods + # and is visible to filters but neither of these are important to us). + self.plugin.messagehandler.send_chat(self.user, "BOOM!", None) + + @classmethod + def get_id(cls): + # Generate a unique id string to register our explode method against. + result = Bomb.ID + cls.ID += 1 + return "bomb" + str(result) + + +class PassTheBomb(CommandPlugin): + ... + def cmd_bomb(self, msg, arg): + holder = msg.sender + try: + # Read a time from the first word of arg. + time = float(arg.split(' ', 1)[0]) + # A new Bomb with the message sender as the source. + Bomb(msg.sender, time, self) + msg.reply("Sniggering evilly, you light the fuse...") + # Provision for a failure to read a time float. + except ValueError: + msg.reply("You struggle with the matches") +}}} + +Now we can type `'bomb 5'` into chat and 5 seconds later (or so) it will explode. + +We have used the `messagehandler.send_chat` function here as the `Bomb` object has no access to a `Message` object to reply to. We will see the `messagehandler.send_muc` function shortly. The +`MessageHandler` object is an abstraction of X'''M'''PPs '''messaging''' protocols as well as providing methods to register message callbacks (in our example these are hidden behind `CommandPlugin`) +and to send messages in single and multi user chats. + +Full reference for `Cron`: + * [[../Reference#Cron|EnDroid reference: Cron]] +Full reference for `MessageHandler`: + * [[../Reference#MessageHandler.2C_Message|EnDroid reference: MessageHandler]] + +== Throwing the Bomb: Usermanagement == + +Now we will add the (important) ability to throw the bomb to another user. We will use the `UserManagement` object to check if our target is online. + +{{{#!highlight python +from collections import defaultdict + +class Bomb(object): + ... + def __init__(self, source, fuse, plugin): + ... # As before. + # Updating the user will now be controlled by our throw method + self.user = None + ... + + def explode(self, _): + self.plugin.messagehandler.send_chat(self.user, "BOOM!", self.plugin) + self.plugin.bombs[self.user].discard(self) # This bomb is done + + def throw(self, next_user): + # Remove this bomb from our current user. + self.plugin.bombs[self.user].discard(self) + + self.history.add(next_user) + self.user = next_user + + # Add it to the new user. + self.plugin.bombs[self.user].add(self) + + +class PassTheBomb(CommandPlugin): + help = ... + bombs = defaultdict(set) # A dictionary of which users have bombs + + ... + + def cmd_bomb(self, msg, arg): + holder = msg.sender + try: + # Read a time from the first word of arg. + time = float(arg.split(' ', 1)[0]) + # A new Bomb with the message sender as the source, passed to + # msg.sender. + Bomb(msg.sender, time, self).throw(msg.sender) + ... + + def cmd_throw(self, msg, arg): + target = arg.split(' ')[0] + # Check if the target is online. + if not target in self.usermanagement.get_available_users(): + msg.reply("You look around but can't spot your target") + # Check if we actually have a bomb. + elif not self.bombs[msg.sender]: + msg.reply("You idly throw your hat, wishing you had something " + "more 'splodey") + else: + self.bombs[msg.sender].pop().throw(target) + msg.reply("You throw the bomb!") + # Let the target know we've thrown the bomb at him! + self.messagehandler.send_chat(target, "A bomb lands by your feet!") +}}} + + +`Usermanagement` is an abstraction of the XM'''P'''Ps '''presence''' protocols. It provides a number of user management functions including: + * `get_[registered|available]_users(name=None)` - returns an iterable of users registered with/available in the room/group name, or in !EnDroid's contact list if name is None (in the code above we + * have used the `get_available_users(None)` to find the users online in our contact list) + * `get_[?available]_[rooms|groups](user)` - returns an iterable of rooms/groups the specified user is available/registered in. + +Now let us add some more interesting explosion reporting, utilising the second of the two above methods. + +{{{#!highlight python +class Bomb(object): + ... + def explode(self, _): + # Some shorthands. + get_rooms = self.plugin.usermanagement.get_available_rooms + send_muc = self.plugin.messagehandler.send_muc + send_chat = self.plugin.messagehandler.send_chat + + msg_explode = "!!!BOOM!!!" + msg_farexplode = "You hear a distant boom" + msg_kill = "{} was got by the bomb" + + rooms = get_rooms(self.user) + for room in rooms: + # Let everyone in a room with self.user hear the explosion. + send_muc(room, msg_explode, self.plugin) + send_muc(room, msg_kill.format(self.user), self.plugin) + + # Alert those who passed the bomb that it has exploded. + for user in self.history: + if user == self.user: + send_chat(self.user, msg_explode, self.plugin) + send_chat(self.user, msg_kill.format("You"), self.plugin) + else: + send_chat(user, msg_farexplode, self.plugin) + send_chat(user, msg_kill.format(self.user), self.plugin) + + self.plugin.bombs[self.user].discard(self) +}}} + +We broadcast the explode message in any room the victim is present in (`get_rooms(self.user)`) using `messagehandler`'s send_muc method and alert other users in the bomb's history. We also send a kill +notice to all users in the history (note that any grammatical imperfections are features, not bugs). + +Full reference for `UserManagement`: + * [[../Reference#UserManagement|EnDroid reference: UserManagement]] + +=== Aside: Plugin Scoping === + +Note that we have used a class variable to store the dictionary of bombs. + +In !EnDroid, a seperate instance of each plugin is instantiated in each room/for each usergroup. This means that if plugins in seperate environments want to share information, they must use class +variables. + +== A Criminal Record: Database == + +`Database` is the second main utility class !EnDroid provides. It wraps an SQL database. We will use it to store kill-counts. + +{{{#!highlight python +from endroid.database import Database + +DB_NAME = "PTB" +DB_TABLE = "PTB" + +class Bomb(object): + ... + def explode(self, _): + ... + self.plugin.register_kill(self.source) # Register the kill in the database. + self.plugin.bombs[self.user].discard(self) + +class PassTheBomb(CommandPlugin): + ... + def endroid_init(self): + # Note that this will either create a new database if none with name + # DB_NAME exists, or open an existing one. + self.db = Database(DB_NAME) + # If we haven't already setup the database then do so. + if not self.db.table_exists(DB_TABLE): + # Create a table with fields 'user' and 'kills'. + self.db.create_table(DB_TABLE, ("user", "kills")) + + def cmd_kills(self, msg, arg): + # Retrieve the users kill count. + kills = self.get_kills(msg.sender) + # self.place_name is the address of the room or name of the group this + # plugin is active in. + nick = self.usermanagement.get_nickname(msg.sender, self.place_name) + level = self.get_level(kills) + + text = "{} the {} has {} kill".format(nick, level, kills) + text += ("" if kills == 1 else "s") + msg.reply(text) + + def register_kill(self, user): + kills = self.get_kills(user) + if kills: + # Change the value of 'kills' to kills+1 in table rows where the + # field 'user' has value user. + r = self.db.update(DB_TABLE, {'kills': kills+1}, {'user': user}) + assert r == 1 # Exactly 1 row should be updated + else: + # The user is not registered in the database - so create a new + # registration for them with their first kill stored. + self.db.insert(DB_TABLE, {'user': user, 'kills': 1}) + + def get_kills(self, user): + # Look in DB_TABLE for the 'kills' field in entries whose 'user' field + # is user. + # Returns a list of dictionaries, each with keys: _endroid_unique_id + # (always included but which we can ignore) and 'kills' - which we want. + results = self.db.fetch(DB_TABLE, ['kills'], {'user': user}) + if len(results) == 0: # The user is not registered in the database. + return 0 + else: + # Get the first dictionary in results and extract the value of + # 'kills' from it. + return results[0]['kills'] + + @staticmethod + def get_level(kills): + if kills < 5: + level = 'novice' + elif kills < 15: + level = 'apprentice' + elif kills < 35: + level = 'journeyman' + elif kills < 65: + level = 'expert' + elif kills < 100: + level = 'master' + else: + level = 'grand-master' + return level +}}} + +We have used the `endroid_init` method, which is called by !EnDroid's plugin management system when a plugin is loaded. Here we use it to set up a database. + +The use of the database is fairly straightforward, in general methods take parameters table_name, options_list_or_dict and conditions_dictionary. Their parameters are internally converted into strings +and passed to an SQL query which does the gruntwork. + +So far, we have a fully functional plugin which does everything we set out to do. However there are still a couple of things to be done. + +Full reference for `Database`: + * [[../Reference#Database|EnDroid reference: Database]] + +== Umbrellas: For when it rains (bombs) == + +There is a problem with the plugin as it stands. If a user does not want to take part in the game (however inconceivable this may seem...) he/she has no options. + +We will remedy these problems by giving all users patented JF Sullivan umbrellas, which they can unfurl to protect themselves and furl to join in the game. By default a user's umbrella is unfurled +(i.e. we operate on an opt-in policy). + +When an umbrella is unfurled, the user should not be able to light a new bomb, nor throw an existing bomb, nor have a bomb thrown at them. + +Most of the changes made to the code to introduce this feature are just Python, so will not be explained in any detail. + +{{{#!highlight python +from collections import namedtuple + +class User(object): + # A class to represent a player of the game. + __slots__ = ('name', 'kills', 'shield') + def __init__(self, name, kills=0, shield=True): + self.name = name + self.kills = kills + self.shield = shield + + def __repr__(self): + return "User(name={}, kills={}, shield={})".format(self.name, + self.kills, + self.shield) + +... + +class PassTheBomb(CommandPlugin): + ... + users = dict() # A dictionary of registered game players. + + def endroid_init(self): + ... + self.db.create_table(DB_TABLE, ('user', 'kills')) + else: + # Make a local copy of the registration database. + data = self.db.fetch(DB_TABLE, ['user', 'kills']) + # Data is a list of dictionaries, each one representing a row in + # the database. + for dct in data: + self.users[dct['user']] = User(dct['user'], dct['kills']) + + def cmd_furl_umbrella(self, msg, arg): + """ + This is how a user enters the game - allows them to be targeted + and to create and throw bombs. + + """ + user = msg.sender + if not self.get_shielded(user): + msg.reply("Your umbrella is already furled!") + else: + if self.get_registered(user): + self.users[user].shield = False + else: # They are not - register them. + self.db.insert(DB_TABLE, {'user': user, 'kills': 0}) + self.users[user] = User(user, kills=0, shield=False) + msg.reply("You furl your umbrella!") + + def cmd_unfurl_umbrella(self, msg, arg): + """A user with an unfurled umbrella cannot create or receive bombs""" + user = msg.sender + if self.get_shielded(user): + msg.reply("Your umbrella is already unfurled!") + else: + # To get user must not have been shielded ie they must have furled + # so they will be in the database. + self.users[user].shield = True + msg.reply("You unfurl your umbrella! No bomb can reach you now!") + + + def cmd_bomb(self, msg, arg): + """ + Create a bomb with a specified timer, eg: 'bomb 1.5' for a 1.5 second + fuse. + + """ + + holder = msg.sender + if self.get_shielded(holder): + return msg.reply("Your sense of honour insists that you furl your " + "umbrella before lighting the fuse") + # Otherwise get a time from the first word of arg. + ... + + def cmd_throw(self, msg, arg): + """Throw a bomb to a user, eg: 'throw benh@ensoft.co.uk'.""" + target = arg.split(' ')[0] + if not self.bombs[msg.sender]: # Do we even have a bomb? + msg.reply("You idly throw your hat, wishing you had something more" + "'splodey") + elif self.get_shielded(msg.sender): # Must be vulnerable while throwing. + msg.reply("You notice that your unfurled umbrella would hinder " + "your throw.") + elif not target in self.usermanagement. ... + elif self.get_shielded(target): # Target registered/vulnerable? + msg.reply("You see your target hunkered down under their umbrella. " + "No doubt a bomb would have no effect on that " + "monstrosity.") + else: ... + + ... + + def register_kill(self, user): + ... + self.users[user].kills += 1 # update our local copy + + # Use our local copy of database information to minimise database access. + def get_kills(self, user): + return self.users[user].kills if user in self.users else 0 + + def get_shielded(self, user): + return self.users[user].shield if user in self.users else True + + def get_registered(self, user): + return user in self.users + + @staticmethod + def get_level(kills): + ... +}}} + +There are a few things here to note. + +First and foremost: database calls are '''synchronous''' i.e. !EnDroid will grind to a halt (briefly) each time the database is accessed. In the above code, we have kept only 'kills' recorded in the +database (we could have kept 'shield' there too but that would mean many more read/writes). As a result things will be faster but if !EnDroid restarts for any reason, all users umbrellas will become +unfurled (but this is not a particular problem). + +Secondly - it is generally a good idea to design plugins to be at least somewhat '''spam-resistent'''. In this case we have used an opt-in policy (an !EnDroid user will know nothing of the PTB plugin +until he/she furls his/her umbrella) and have provided an option to silence the plugin (by unfurling it again). + +Thirdly we see some more of the functionality of !CommandPlugin. The functions `cmd_[?un]furl_umbrella` will respond to messages: "[?un]furl umbrella" i.e. require two words to activate. This idea +extends indefinitely, it would be completely possible to create the command: +`cmd_go_and_buy_a_new_umbrella_mine_is_somewhat_battlescarred` +which would respond to the message "go and buy ...". + += The Full Code = + +A few minor changes have been made below - mostly to do with commenting and report strings. + +{{{#!highlight python +from endroid.plugins.command import CommandPlugin +from endroid.cron import Cron +from collections import defaultdict, namedtuple +from endroid.database import Database + +DB_NAME = "PTB" +DB_TABLE = "PTB" + +class User(object): + __slots__ = ('name', 'kills', 'shield') + def __init__(self, name, kills=0, shield=True): + self.name = name + self.kills = kills + self.shield = shield + + def __repr__(self): + return "User(name={}, kills={}, shield={})".format(self.name, self.kills, self.shield) + +class Bomb(object): + ID = 0 + def __init__(self, source, fuse, plugin): + self.source = source # Who lit the bomb? + self.user = None # Our current holder. + self.plugin = plugin # Plugin instance we belong to. + self.history = set() # Who has held us? + + idstring = self.get_id() # Get a unique registration_name. + plugin.cron.register(self.explode, idstring) + plugin.cron.setTimeout(fuse, idstring, None) # Schedule detonation. + + # This function is called by Cron and given an argument. We don't need an + # argument so just ignore it. + def explode(self, _): + # Some shorthands. + get_rooms = self.plugin.usermanagement.get_available_rooms + send_muc = self.plugin.messagehandler.send_muc + send_chat = self.plugin.messagehandler.send_chat + + msg_explode = "!!!BOOM!!!" + msg_farexplode = "You hear a distant boom" + msg_kill = "{} was got by the bomb" + + rooms = get_rooms(self.user) + for room in rooms: + # Let everyone in a room with self.user here the explosion. + send_muc(room, msg_explode) + send_muc(room, msg_kill.format(self.user)) + + # Alert those who passed the bomb that it has exploded. + for user in self.history: + if user == self.user: + send_chat(self.user, msg_explode) + send_chat(self.user, msg_kill.format("You")) + else: + send_chat(user, msg_farexplode) + send_chat(user, msg_kill.format(self.user)) + + self.plugin.register_kill(self.source) + self.plugin.bombs[self.user].discard(self) + + + def throw(self, user): + # Remove this bomb from our current user. + self.plugin.bombs[self.user].discard(self) + + self.history.add(user) + self.user = user + + # Add it to the new user. + self.plugin.bombs[self.user].add(self) + + @classmethod + def get_id(cls): + # Generate a unique id string to register our explode method against. + result = Bomb.ID + cls.ID += 1 + return "bomb" + str(result) + + +class PassTheBomb(CommandPlugin): + help = "Pass the bomb game for EnDroid" + bombs = defaultdict(set) # Users : set of bombs. + users = dict() # User strings : User objects. + + def endroid_init(self): + self.db = Database(DB_NAME) + if not self.db.table_exists(DB_TABLE): + self.db.create_table(DB_TABLE, ('user', 'kills')) + else: + # Make a local copy of the registration database. + data = self.db.fetch(DB_TABLE, ['user', 'kills']) + for dct in data: + self.users[dct['user']] = User(dct['user'], dct['kills']) + + def cmd_furl_umbrella(self, msg, arg): + """ + This is how a user enters the game - allows them to be targeted + and to create and throw bombs. + + """ + user = msg.sender + if not self.get_shielded(user): + msg.reply("Your umbrella is already furled!") + else: + if self.get_registered(user): + self.users[user].shield = False + else: # They are not - register them. + self.db.insert(DB_TABLE, {'user': user, 'kills': 0}) + self.users[user] = User(user, kills=0, shield=False) + msg.reply("You furl your umbrella!") + cmd_furl_umbrella.helphint = ("Furl your umbrella to participate in the " + "noble game of pass the bomb!") + + def cmd_unfurl_umbrella(self, msg, arg): + """A user with an unfurled umbrella cannot create or receive bombs.""" + user = msg.sender + if self.get_shielded(user): + msg.reply("Your umbrella is already unfurled!") + else: + # To get user must not have been shielded ie they must have furled + # so they will be in the database. + self.users[user].shield = True + msg.reply("You unfurl your umbrella! No bomb can reach you now!") + cmd_unfurl_umbrella.helphint = ("Unfurl your umbrella to cower from the " + "rain of boms!") + + def cmd_bomb(self, msg, arg): + """Create a bomb with a specified timer.""" + + holder = msg.sender + if self.get_shielded(holder): + return msg.reply("Your sense of honour insists that you furl your " + "umbrella before lighting the fuse") + # Otherwise get a time from the first word of arg. + try: + time = float(arg.split(' ', 1)[0]) + # Make a new bomb and throw it to its creator. + Bomb(msg.sender, time, self).throw(msg.sender) + msg.reply("Sniggering evilly, you light the fuse...") + # Provision for a failure to read a time float... + except ValueError: + msg.reply("You struggle with the matches") + cmd_bomb.helphint = ("Light the fuse!") + + def cmd_throw(self, msg, arg): + """Throw a bomb to a user, eg: 'throw benh@ensoft.co.uk'""" + target = arg.split(' ')[0] + # We need a bomb to throw. + if not self.bombs[msg.sender]: + msg.reply("You idly throw your hat, wishing you had something " + "rounder, heavier and with more smoking fuses.") + # Need our umbrella to be furled. + elif self.get_shielded(msg.sender): + msg.reply("You notice that your unfurled umbrella would hinder " + "your throw.") + # Check that target is online. + elif not target in self.usermanagement.get_available_users(): + msg.reply("You look around but cannot spot your target") + elif self.get_shielded(target): # Target registered/vulnerable? + msg.reply("You see your target hunkered down under their umbrella. " + "No doubt a bomb would have little effect on that " + "monstrosity.") + else: + self.bombs[msg.sender].pop().throw(target) + msg.reply("You throw the bomb!") + self.messagehandler.send_chat(target, "A bomb lands by your feet!") + cmd_throw.helphint = ("Throw a bomb!") + + def cmd_kills(self, msg, arg): + kills = self.get_kills(msg.sender) + nick = self.usermanagement.get_nickname(msg.sender, + self.place_name) + level = self.get_level(kills) + + text = "{} the {} has {} kill".format(nick, level, kills) + text += ("" if kills == 1 else "s") + msg.reply(text) + cmd_kills.helphint = ("Receive and gloat over you score!") + + def register_kill(self, user): + kills = self.get_kills(user) + # Change the value of 'kills' to kills+1 in the row where 'user' = user. + self.users[user].kills += 1 + self.db.update(DB_TABLE, {'kills': kills+1}, {'user': user}) + + def get_kills(self, user): + return self.users[user].kills if user in self.users else 0 + + def get_shielded(self, user): + return self.users[user].shield if user in self.users else True + + def get_registered(self, user): + return user in self.users + + @staticmethod + def get_level(kills): + if kills < 5: + level = 'novice' + elif kills < 15: + level = 'apprentice' + elif kills < 35: + level = 'journeyman' + elif kills < 65: + level = 'expert' + elif kills < 100: + level = 'master' + else: + level = 'grand-master' + return level + +}}} + += Extras = + +== Adding Configuration == + +We can add configuration to our plugin by adding a section in `endroid.conf`: + +{{{ +[room | group : * : pconfig : endroid.plugins.passthebomb] +my_string = this is a string +my_int = 123 +my_list = this, has, commas, so, is, a, list +my_list2 = 1 + 2 + 3 +# This will be ignored as long as # is in the first column. + 4 + 5 +}}} + +These variables will now be available in the `self.vars` dictionary, which will look like this: + +{{{#!highlight python +self.vars = { + 'my_string' : 'this is a string', + 'my_int' : 123, + 'my_list': ['this', 'has', 'commas', 'so', 'is', 'a', 'list'] + 'my_list2': [1,2,3,4,5] +} +}}} + +See [[../Configuration|EnDroid configuration]] for more details on the format of the config file. + += Further Reading = + + * [[../Reference|EnDroid reference]] + * [[http://www.bartitsu.org/index.php/2009/05/the-umbrella-a-misunderstood-weapon/|JFSullivan]] + diff -Nru endroid-1.1.2/doc/wiki/Reference endroid-1.2~68~ubuntu12.10.1/doc/wiki/Reference --- endroid-1.1.2/doc/wiki/Reference 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/doc/wiki/Reference 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,567 @@ +#acl EnsoftLander:read,write,delete,revert,admin All:read + +<> + +This API reference will be split into three main parts. + Endroid core:: APIs for the Plugin class and classes directly accessible from that class. All plugins will be able to access all of this API with no extra work. + Utilities:: APIs provided by the Endroid modules Database (for persistent data storage) and Cron (for task scheduling) which plugins may import. + Helper Plugins:: !EnDroid plugins which are designed to be inherited from (e.g. {{{CommandPlugin}}} or 'imported' via {{{Plugin.get("endroid.plugins.")}}} and provide useful extra +functionality. + += Glossary = + muc/chat:: A muc (Multi-User Chat) message is one sent in or to a room (group chat). A chat message is one sent to a single user. + jid:: The address of a user or room. This is a different format depending on the entity being addressed and plugins shouldn't need to know the format being used. However in case its useful it will +be: {{{user@ho.s.t}}} for a single user, {{{room_name@serv.er}}} for a room and {{{room_name@serv.er/user_nickname}}} for a user in a room. + += Core Endroid = + +API reference for the Plugin class and classes directly available to it. All plugins are expected to inherit from either {{{endroid.pluginmanager.Plugin}}} or a subclass of this class e.g. +{{{endroid.plugins.CommandPlugin}}}. + +== Plugin == + +{{{#!highlight python +class Plugin(object): + """ + All plugins are expected to subclass plugin in one way or another. + + Attributes: + - usermanagment + - messagehandler + References to Endroid's usermanagment and messagehandler objects + through which further APIs may be called. + - cron + A reference to the Cron singleton through which task scheduling is available. + + - dependencies + Iterable of plugins this plugin requires to be loaded before it can be + activated by EnDroid. + + - preferences + Iterable of plugins this plugin will use if they are available but does + not require. + + - place + The environment the plugin is active in - either 'room' (muc) or 'group' + (single user chat - active in a user group). + - place_name + The name of the environment the plugin is active in - either a room's + address (room@serv.er) or the name of a user group. + + - vars + A dictionary of config options read from the section in EnDroid's + config file: + [ self.place_name : self.place : pconfig : ] + + """ + + def register_muc_callback(self, callback, inc_self=False, priority=PRIORITY_NORMAL): + def register_chat_callback(self, callback, inc_self=False, priority=PRIORITY_NORMAL): + """ + Register a callback to be called when a muc or chat message is received. + + - inc_self tells EnDroid whether it should do the callback on messages + that EnDroid has sent itself. + - priority is unused by Endroid but accessible to plugins and measures the + importance of a message (lower numbers = more important). + + """ + + def register_unhandled_muc_callback(self, callback, inc_self=False, + priority=PRIORITY_NORMAL): + def register_unhandled_chat_callback(self, callback, inc_self=False, + priority=PRIORITY_NORMAL): + """ + Unhandled callbacks are called by EnDroid when a message has not been + processed by any callbacks. + + They called by a plugin via Message.unhandled(*args) (usually when the + plugin has tried to run its own callback but failed) and so the message has + not been handled. + + """ + + # The following four methods register filter functions which take Message + # objects and return bools. + # Callable should take a message and return a bool. + # If a filter returns False, the message will be dropped. A filter + # may modify a Message object's attributes. + # Callable may alter the message by changing its attributes. + + def register_muc_filter(self, callback, inc_self=False, priority=PRIORITY_NORMAL): + def register_chat_filter(self, callback, inc_self=False, priority=PRIORITY_NORMAL): + """Receive filters (can cause EnDroid to ignore the message).""" + + def register_muc_send_filter(self, callback, inc_self=False, priority=PRIORITY_NORMAL): + def register_chat_send_filter(self, callback, inc_self=False, priority=PRIORITY_NORMAL): + """Send filters (can cause EnDroid to not send a message).""" + + def get(self, plugin_name): + """ + Load plugin called plugin_name (eg endroid.plugins.my_plugin). + + This acts very much like import; it returns an object through which + plugin_name's functions can be accessed. + Note that if get is used, plugin_name should be added to the plugin + class's dependencies tuple. + + """ + + def get_dependencies(self): + def get_preferences(self): + """ + Return a full list of the plugin's dependencies/preferences. + + This includes those of the plugins it depends on/prefers and so on + down the chain. + + """ + + def list_plugins(self): + """ + Get an iterable of all the plugins active in this plugin's environment + (a specific room or usergroup - not globally).""" + +}}} + +=== Plugin Scope === + +Plugins are separately instantiated in each room they are configured in and for each user group. Bear in mind that if a plugin wishes to store data globally (across all rooms and user groups), a class +variable should be used. + +Plugins can find out information about their environment via {{{self.place}}} and {{{self.place_name}}} which return the type of environment ({{{"room"}}} or {{{"group"}}}) and the name of the +environment (e.g. {{{"room1@serv.er"}}} or {{{"admins"}}}) respectively. + +== MessageHandler, Message == + +APIs for !EnDroid's message functionality. + +{{{#!highlight python +class MessageHandler(object): + """A class abstracting XMPP's message protocols.""" + + def send_muc(self, room, body, source=None, priority=PRIORITY_NORMAL): + """ + Send a multi-user-chat message to the room. + + Source is optional. It is unused by EnDroid but visible to plugins + and filters (eg a filter may block all messages with a specified source) + Priority is not used internally but is available to plugins. + PRIORITY_NORMAL = 0, the lower the number, the higher the priority. + + """ + + def send_chat(self, user, body, source=None, priority=PRIORITY_NORMAL): + """ + Send a single-user-chat message to the user with address user. + + Other arguments are the same as send_muc. + + """ + +class Message(object): + """ + Object representing a single message (muc or chat). + + A Message object is passed by MessageHandler to any callbacks registered + via the Plugin.register_ commands. + + Attributes: + - sender - a string representing the sender's userhost. + - sender_full - a string representing the sender's full jid. + Note: sender_full is used to reply to messages so that if a user is + online on more than one resource the reply will go to the right one. + + - body - the text of the message. + - recipient - a string representing the address to send the message to + - priority - a number, lower = more important (unused by EnDroid, can + be accessed by plugins). + + """ + + def reply(self, body): + """ + Reply with a single or multi-user-chat message to self.sender. + + The reply will have the same type as the received message, so replying + to a message in a room will reply to the whole room. + + """ + + def reply_to_sender(self, body): + """ + Reply with a single user chat to self.sender. + + Note the difference from reply - calling reply on a group message will + send a group message, calling reply_to_sender on the same message will + send a single user chat message to its sender. + + """ + + def send(self): + """Send the Message to it's recipient.""" + + def unhandled(self, *args): + """ + Notify the message that the caller hasn't handled it. This should only + be called by plugins that have registered as a handler (and thus have + incremented the handler count for this message). + + This method takes arbitrary arguments so it can be used as deferred + callback or errback. + + """ +}}} + +== UserManagement == + +Note: all the get_ methods take a name string which is one of: + * The jid of a room + * The name of a usergroup + * None (in which case results will be looked for in !EnDroid's contact list) + +{{{#!highlight python +class UserManagement(object): + """ + A class abstracting XMPP's presence protocols. + + In the get_ member functions, name (a userhost string e.g. user@ho.st or + room@serv.er) specifies in which room or group to run a search. If it is + None, the search will be run on EnDroid's contact list (of rooms or users + depending on the function). + + """ + + def get_users(self, name=None): + """Return an iterable of users registered with room/group name.""" + + def get_available_users(self, name=None): + """Return an iterable of users available in room/group name.""" + + def get_groups(self, user=None): + """Return an iterable of groups the user is registered with.""" + + def get_available_groups(self, user=None): + """Return an iterable of groups the user is present in.""" + + def get_rooms(self, user=None): + """Return an iterable of rooms the user is registered with.""" + + def get_available_rooms(self, user=None): + """Return an iterable of rooms the user is present in.""" + + def get_nickname(self, user, place=None): + """ + Given a user jid (user@ho.s.t) return the user's nickname in place, + or if place is None (default), the user part of the jid. + + """ + + def invite(self, user, room, reason=None): + """ + Invite a user to a room. + + Will only send invitation if the user is in our contact list and online, + and if the user is registered in the room but not currently in it. + + Returns a tuple (success, report-message). + + Report message may be: + "User not registered" + "User not available" + "Room not registered" + "User not registered in room" + "User already in room" + "Invitation sent" + """ +}}} + += Utilities = + +API reference for !EnDroid's utility classes. + +== Database == + +A wrapper around an sqlite3 database. + +{{{#!highlight python +class Database(object): + """ + This is wrapper around an sqlite3 database. Note that all accesses + are _synchronous_, so should be minimised. (It is likely that in + the future all accesses will be made using twisted.enterprise.adbapi + which is asynchronous). + + """ + def __init__(self, modName): + """Create a new database with name=modName in the Database singleton.""" + + def create_table(self, name, fields): + """ + Create a new table in the database called 'name' and containing fields + 'fields' (an iterable of strings giving field titles). + + """ + + def table_exists(self, name): + """Check to see if a table called 'name' exists in the database.""" + + def insert(self, name, fields): + """ + Insert a row into table 'name'. + + Fields is a dictionary mapping field names (as defined in + create_table) to values. + + """ + + def fetch(self, name, fields, conditions={}): + """ + Get data from the table 'name'. + + Returns a list of dictionaries mapping 'fields' to their values, one + dictionary for each row which satisfies a condition in conditions. + + Conditions is a dictionary mapping field names to values. A result + will only be returned from a row if its values match those in conditions. + + E.g.: conditions = {'user' : JoeBloggs} + will match only fields in rows which have JoeBloggs in the 'user' field. + + """ + + def count(self, name, conditions): + """Return the number of rows in table 'name' which satisfy conditions.""" + + def delete(self, name, conditions): + """Delete rows from table 'name' which satisfy conditions.""" + + def update(self, name, fields, conditions): + """ + Update rows in table 'name' which satisfy conditions. + + Fields is a dictionary mapping the field names to their new values. + + """ + + def empty_table(self, name): + def delete_table(self, name): + """Remove all rows from/delete table 'name'.""" +}}} + +== Cron == + +!EnDroid's task scheduling service. + +{{{#!highlight python +class Cron(object): + def get(): + """ + Return a reference to the Cron singleton. All below functions can be + accessed via Cron.get().(args). + + """ + +class CronSing(object): + """The singleton returned by Cron.get().""" + + def register(self, function, reg_name, persistent=True): + """ + Register the callable 'function' against 'reg_name'. Note that 'reg_name' + must be globally unique. Allows function to be scheduled with doAtTime or + setTimeout. + + If persistent is False, remoteTask(reg_name) will be called before the + the function is registered (so across a restart of EnDroid, the stored + tasks will be forgotten). + + """ + + def doAtTime(self, time, locality, reg_name, params): + """ + Schedule the callable registered against 'reg_name' to be called + with 'params' (which must be picklable) at locality time 'time'. + (Localities are from pytz.timezone). + + """ + + def setTimeout(self, timedelta, reg_name, params): + """ + Schedule the callable to be called in after 'timedelta'. + + Timedelta may be a datetime.timedelta object or a real number representing + the number of seconds to wait. Negative or zero values will trigger almost + immediately. + + """ + + def removeTask(self, reg_name): + """Remove any scheduled tasks registered with reg_name.""" + + def getAtTimes(self): + """ + Return a string showing the registration names of functions scheduled + with doAtTime and the amount of time they will be called in. + + """ + + def getTimeouts(self): + """ + Return a string showing the registration names of functions scheduled + with setTimeout and the amount of time they will be called in. + + """ +}}} + + += Helper Plugins = + +API reference for plugins designed to be used by other plugins. + +All of these plugins are located at {{{endroid.plugins.}}} + +== Command == + +Plugins should inherit from {{{CommandPlugin}}} or use {{{self.comm = self.get("endroid.plugins.command")}}} to use {{{Command}}}'s methods. + +{{{#!highlight python +""" +Helper plugin to handle command registrations by other plugins. This is +the main avenue by which plugins are expected to handle incoming messages +and it is expected most plugins will depend on this. + +""" + +class CommandPlugin(Plugin): + """ + Parent class for simple command-driven plugins. + + Such plugins don't need to explicitly register their commands. Instead, they + can just define methods prefixed with "cmd_" and they will automatically be + registered. Any additional underscores in the method name will be converted + to spaces in the registration (so cmd_foo_bar is registered as ('foo', + 'bar')). + + In addition, certain options can be passed by adding fields to the methods: + - hidden: don't show the command in help if set to True. + - synonyms: an iterable of alternative keyword sequences to register the + method against. All synonyms are hidden. + - helphint: a hint to print after the keywords in help output. + - muc_only or chat_only: register for only chat or muc messages (default is + both). + + """ + +class Command(Plugin): + """ + The instance of this class active in a plugin's room/group is + returned by a Plugin.get("endroid.plugins.command") call. + + This class makes it possible to access the functionality of CommandPlugin + without the inheritance. + + """ + + def register_muc(self, callback, command, helphint="", hidden=False, + synonyms=()): + """Register a new handler for MUC messages.""" + + def register_chat(self, callback, command, helphint="", hidden=False, + synonyms=()): + """Register a new handler for chat messages.""" + + def register_both(self, callback, command, helphint="", hidden=False, + synonyms=()): + """Register a handler for both MUC and chat messages.""" +}}} + +For an example usecase look at the {{{invite}}} plugins. + +== HTTPInterface == + +Start a webserver, allow callback registrations on URL paths, and route requests to callbacks. + +{{{#!highlight python +class HTTPInterface(Plugin): + """ + The actual plugin class. This may be instantiated multiple times, but is + just a wrapper around a HTTPInterfaceSingleton object. + + """ + + def register_regex_path(self, plugin, callback, path_regex): + """ + Register a callback to be called for requests whose URI matches: + http:////[?] + + Callback arguments: + request: A twisted.web.http.Request object. + + """ + + def register_path(self, plugin, callback, path_prefix): + """ + Register a callback to be called for requests whose URI matches: + http:////[?] + + Or: + http://///[?] + + Or if the prefix is the empty string: + http:////[?] + + """ +}}} + +=== Remote Plugin, endroid_remote script === + +For an example usecase look at the remote plugin, this calls: {{{register_path(self, self.http_request_handler, '')}}}. This listens on {{{http://:8880/remote/}}} by default, where a form +can be filled in to send an !EnDroid user a chat message. You can change the port number with the following configuration file option: + +{{{ +# HTTP port for remote messaging +[ group | room : * : pconfig : endroid.plugins.httpinterface ] +http_port = 8881 +}}} + +You also need the {{{httpinterface}}} and {{{remote}}} plugins to be loaded. + +The `endroid_remote` script (located at `bin/endroid_remote` provides methods for sending a message to an !EnDroid user from the command line via the `remote` plugin. The `endroid_remote` user must +specify the environment variables {{{ENDROID_REMOTE_KEY}}}, {{{ENDROID_REMOTE_USER}}} and {{{ENDROID_REMOTE_URL}}}. When the script runs (e.g. via {{{endroid_remote echo message here}}}) it will send +a message or messages (e.g. "message here") to the user with JID {{{ENDROID_REMOTE_USER}}} (henceforth referred to as the recipient). + +To prevent anonymous spamming of users via !EnDroid, the recipient must have enabled the remote plugin by typing {{{allow remote}}} to !EnDroid. In response !EnDroid will return a key which the +recipient will give to `endroid_remote` users who must then set the {{{ENDROID_REMOTE_KEY}}} environment variable to this key. The recipient should only distribute this key to users from whom the +recipient is happy to receive remote notifications from. + +{{{ENDROID_REMOTE_URL}}} must be set to the URL of the !EnDroid instance's remote page (e.g. {{{http://127.0.0.1:8880/remote/}}}). + +{{{endroid_remote}}} supports the following commands: + * `endroid_remote echo `: Send `` to the !EnDroid user. + * `endroid_remote cat`: Send {{{stdin}}} (in a single message) to the !EnDroid user. + * `endroid_remote tee`: Like the cat, but also copy input to {{{stdout}}} + * `endroid_remote watch `: Send each line from {{{stdin}}} that matches `` to !EnDroid (one message per line), and forward {{{stdin}}} to {{{stdout}}}. + +== Patternmatcher == + +Message regex matching. + +{{{#!highlight python +class PatternMatcher(Plugin): + """Plugin to simplify matching messages based on a regexp.""" + + def register_muc(self, callback, pattern): + def register_chat(self, callback, pattern): + """ + Register a callback to be called when a message's body matches + the pattern (a regex string). + + Callback will be called with the Message object as the argument. + + """ + + def register_both(self, callback, pattern): + """Equivalent to register_muc(...); register_chat(...).""" +}}} +For a usecase, look at the {{{spell}}} plugin, which looks for a {{{(sp?)}}} in a message. + diff -Nru endroid-1.1.2/etc/endroid.conf endroid-1.2~68~ubuntu12.10.1/etc/endroid.conf --- endroid-1.1.2/etc/endroid.conf 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/etc/endroid.conf 2013-09-16 14:30:23.000000000 +0000 @@ -1,33 +1,141 @@ +# EnDroid's config file +# +# Note: Comments will be ignored provided the hash is in the *first* column + [Setup] +# EnDroid's XMPP credentitals these must be specific so that EnDroid +# can connect to XMPP jid = marvin@hhgg.tld/planet -secret = secret -nick = Marvin +password = brainthesizeofaplanet + +# EnDroid's nickname defaults to the part of jid before the @ +# If that's not desired it can be specified here +# nick = + +# EnDroid's full contact list. Users on this list will be added as friends, +# users not on this list will be removed from contacts and will be unable +# to communicate with EnDroid. Defaults to empty list. +users = + +# The rooms EnDroid will attempt to create and join. Defaults to empty list. +rooms = +# Detailed logging will be kept here - if something goes wrong and does not +# display an error in the console, look here +logfile = ~/.endroid/endroid.log + +[Database] +dbfile = ~/.endroid/endroid.db + +[room: *] +# Plugins that will be active for all rooms plugins = - endroid.plugins.blacklist - endroid.plugins.chuck +# Helper plugins endroid.plugins.command + endroid.plugins.httpinterface + endroid.plugins.patternmatcher +# Management plugins + endroid.plugins.blacklist + endroid.plugins.help + endroid.plugins.invite + endroid.plugins.ratelimit + endroid.plugins.roomowner +# The rest endroid.plugins.compute endroid.plugins.coolit endroid.plugins.correct - endroid.plugins.echobot - endroid.plugins.help - endroid.plugins.memo + endroid.plugins.exec + endroid.plugins.hi5 + endroid.plugins.remote + endroid.plugins.theyfightcrime + endroid.plugins.whosonline + +[group: *] +# Plugins that will be active for all user groups +plugins = +# Helper plugins + endroid.plugins.command + endroid.plugins.httpinterface endroid.plugins.patternmatcher - endroid.plugins.pubpicker +# Management plugins + endroid.plugins.blacklist + endroid.plugins.help + endroid.plugins.invite endroid.plugins.ratelimit - endroid.plugins.speak - endroid.plugins.spell +# The rest + endroid.plugins.chuck + endroid.plugins.compute + endroid.plugins.coolit + endroid.plugins.exec + endroid.plugins.hi5 + endroid.plugins.memo + endroid.plugins.remote endroid.plugins.theyfightcrime endroid.plugins.unhandled endroid.plugins.whosonline -users= -rooms= +[room | group : * : plugin : endroid.plugins.blacklist] +# Add the ability to blacklist users (causing endroid to ignore them) +# Specify the set of users that should have this power +#admins = zaphod@beeblebrox.com, -[Database] -dbfile=/var/lib/endroid/db/endroid.db +# Add per room config +#[room : magrathea@planets.hhgg.tld : plugin : endroid.plugins.roomowner] +#name = "RoomName" +#description = "Description" +#persistent = True + +[room : room_name] +# Users allowed in this room +#users = + +[ group | room : * : plugin : endroid.plugins.httpinterface ] +# HTTP remote messaging feature (via 'httpinterface' and 'remote' plugins) +# +# port is the port (defaults to 8880) +# interface is the interface (defaults to 127.0.0.1, i.e. accessible only +# from the local host. Set to 0.0.0.0 to enable access from anywhere). +#port = 8880 +#interface = 127.0.0.1 + +[ group | room : * : plugin : endroid.plugins.compute ] +# The compute plugin uses Wolfram Alpha API to answer questions +# Requires an API key, to get one visit: +# https://developer.wolframalpha.com/portal/api +# and then put it below - good for 2000 free queries/month. +#api_key = 123ABC-DE45FGJIJK + +[ group | room : * : plugin : endroid.plugins.hi5 ] +# Configure one or more chatrooms to broadcast anonymous High Fives +#broadcast = room@conference.example.com, +# Configure GPG to asymmetrically encrypt logs if desired, with +# both the keyring containing the public key and the userid to encrypt for +#gpg = /home/admin/.gnupg/pubring.gpg, admin@example.com -[UserGroup:*] +[ group | room : * : plugin : endroid.plugins.exec ] +# Configure commands that just spawn a process and send the output. +# Format: +# +# unique_name = +# process to execute +# help for that command +# One or more Endroid command(s) that invoke the process +# +# The value of the unique_name doesn't matter, it's just a name from which +# all the other information is hung. Endroid will automatically add some options, +# so 'say something' will also match things like 'hey endroid, say something!' or +# 'say something please, endroid'. You can use regexps for the commands. +fortune = + /usr/games/fortune -a -s + Say something witty + fortune + tell my fortune + what's my fortune + say something +date = + date +'The time is %T on %A %e %B' + Tell the time and date + date|time + what's the (date|time) + what (date|time) is it -[Room:*] diff -Nru endroid-1.1.2/etc/init/endroid.conf endroid-1.2~68~ubuntu12.10.1/etc/init/endroid.conf --- endroid-1.1.2/etc/init/endroid.conf 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/etc/init/endroid.conf 2013-09-16 14:30:23.000000000 +0000 @@ -1,7 +1,7 @@ description "EnDroid XMPP bot" author "Ensoft Limited" -exec /usr/bin/endroid -L /var/log/endroid.log /etc/endroid/endroid.conf +exec /usr/bin/endroid -L /var/log/endroid.log --config /etc/endroid/endroid.conf start on net-device-up stop on stopping network stop on starting shutdown Binary files /tmp/O6nprNwWho/endroid-1.1.2/lib/wokkel-0.7.0-py2.7.egg and /tmp/c6D8U1aHWi/endroid-1.2~68~ubuntu12.10.1/lib/wokkel-0.7.0-py2.7.egg differ Binary files /tmp/O6nprNwWho/endroid-1.1.2/lib/wokkel-0.7.1-py2.7.egg and /tmp/c6D8U1aHWi/endroid-1.2~68~ubuntu12.10.1/lib/wokkel-0.7.1-py2.7.egg differ diff -Nru endroid-1.1.2/setup.py endroid-1.2~68~ubuntu12.10.1/setup.py --- endroid-1.1.2/setup.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/setup.py 2013-09-16 14:30:23.000000000 +0000 @@ -6,9 +6,9 @@ from distutils.core import setup setup(name='endroid', - version="1.1", + version="1.2", description='EnDroid: a modular XMPP bot', url='http://open.ensoft.co.uk/EnDroid', - packages=['endroid', 'endroid.plugins'], + packages=['endroid', 'endroid.plugins', 'endroid.plugins.compute'], package_dir={'endroid': 'src/endroid'}, ) diff -Nru endroid-1.1.2/src/README endroid-1.2~68~ubuntu12.10.1/src/README --- endroid-1.1.2/src/README 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/README 1970-01-01 00:00:00.000000000 +0000 @@ -1,37 +0,0 @@ -___________ ________ .__ .___ -\_ _____/ ____ \______ \_______ ____ |__| __| _/ - | __)_ / \ | | \_ __ \/ _ \| |/ __ | - | \ | \| ` \ | \( <_> ) / /_/ | -/_______ /___| /_______ /__| \____/|__\____ | - \/ \/ \/ \/ -* EnDroid XMPP Bot - - -* Introduction -** Endroid is an extensible XMPP bot, built with a plugin architecture -** The endroid.sh script in this directory may be used to start EnDroid easily, e.g. for testing - -* Example confiuration -** You should register a Jabber ID for your EnDroid, e.g. at https://register.jabber.org/ -** Put config in ~/.endroid/endroid.conf e.g. - -Example ~/.endroid/endroid.conf follows: -============================================================ -[Setup] -jid=my_en_droid@jabber.org -secret=PASSWORD -nick=MyEnDroid - -plugins=endroid.plugins.command,endroid.plugins.help,endroid.plugins.patternmatcher,endroid.plugins.speak,endroid.plugins.unhandled,endroid.plugins.whosonline,endroid.plugins.memo - -users=john@domain.tld -rooms=test-room@conference.jabber.org - -[Database] -dbfile=/tmp/endroid.db - -[UserGroup:*] - -[Room:*] - -============================================================ diff -Nru endroid-1.1.2/src/endroid/__init__.py endroid-1.2~68~ubuntu12.10.1/src/endroid/__init__.py --- endroid-1.1.2/src/endroid/__init__.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/__init__.py 2013-09-16 14:30:23.000000000 +0000 @@ -5,65 +5,101 @@ # ----------------------------------------- import os -import os.path import sys +import getpass from argparse import ArgumentParser import logging +import os.path from twisted.application import service from twisted.internet import reactor from twisted.internet.defer import Deferred, DeferredList, inlineCallbacks from twisted.words.protocols.jabber.jid import JID +from twisted.python import log # used for xml logging + from wokkel.client import XMPPClient -from endroid.config import EnConfigParser +# endroid base layer from endroid.rosterhandler import RosterHandler from endroid.wokkelhandler import WokkelHandler -from endroid.messagehandler import MessageHandler +# top layer from endroid.usermanagement import UserManagement +from endroid.messagehandler import MessageHandler +# utilities +from endroid.confparser import Parser from endroid.database import Database -from endroid.pluginmanager import PluginManager +import endroid.manhole + + +__version__ = (1, 2) + +LOGGING_FORMAT = '%(asctime)-8s %(levelname)-8s %(message)s' +LOGGING_DATE_FORMAT = '%Y-%m-%d %H:%M:%S' +# LOGGING_FORMAT = '%(levelname)-5s: %(message)s' -__version__ = (1, 1) class Endroid(object): - def __init__(self, conffile, logtraffic=False): + def __init__(self, conffile, args): self.application = service.Application("EnDroid") - - self.conf = EnConfigParser(conffile) - - jid = self.conf['jid'] - self.jid = jid + + self.conf = Parser(conffile) + + logfile = self.conf.get("setup", "logfile", + default=args.logfile) + observer = log.PythonLoggingObserver() + observer.start() + + if logfile: + logfile = os.path.expanduser(logfile) + logging.basicConfig(filename=logfile, level=args.level, + format=LOGGING_FORMAT, + datefmt=LOGGING_DATE_FORMAT) + console = logging.StreamHandler() + console.setLevel(args.level) + console.setFormatter(logging.Formatter(LOGGING_FORMAT, "%H:%M:%S")) + logging.getLogger().addHandler(console) + else: + logging.basicConfig(level=args.level, format=LOGGING_FORMAT, + datefmt=LOGGING_DATE_FORMAT) + + self.jid = self.conf.get("setup", "jid") logging.info("Found JID: " + self.jid) - - self.secret = self.conf['secret'] + + self.secret = self.conf.get("setup", "password") logging.info("Found Secret: **********") - - self.nick = self.conf['nick'] - logging.info("Found Default Nick: " + self.nick) - - self.rooms = self.conf['rooms'] - for room in self.rooms: + + rooms = self.conf.get("setup", "rooms", default=[]) + for room in rooms: logging.info("Found Room to Join: " + room) - - dbfile = self.conf['database']['dbfile'] + + groups = self.conf.get("setup", "groups", default=['all']) + for group in groups: + logging.info("Found Group: " + group) + + try: + dbfile = self.conf.get("setup", "dbfile") + except KeyError: + # Try the old location in 'database' section, also use a default + dbfile = self.conf.get("database", "dbfile", + default="~/.endroid/endroid.db") logging.info("Using " + dbfile + " as database file") Database.setFile(dbfile) - + self.client = XMPPClient(JID(self.jid), self.secret) - logging.info("Setting traffic logging to " + str(logtraffic)) - self.client.logTraffic = logtraffic + logging.info("Setting traffic logging to " + str(args.logtraffic)) + self.client.logTraffic = args.logtraffic + self.client.setServiceParent(self.application) - - self.roster = RosterHandler() - self.roster.setHandlerParent(self.client) - - self.wh = WokkelHandler(JID(self.jid), self.nick, self.rooms) - self.wh.setHandlerParent(self.client) - - self.messagehandler = MessageHandler(self.wh) - self.usermanagement = UserManagement(self.roster, self.wh) - + + self.rosterhandler = RosterHandler() + self.rosterhandler.setHandlerParent(self.client) + + self.wokkelhandler = WokkelHandler() + self.wokkelhandler.setHandlerParent(self.client) + + self.usermanagement = UserManagement(self.wokkelhandler, self.rosterhandler, self.conf) + self.messagehandler = MessageHandler(self.wokkelhandler, self.usermanagement) + # Fire off our startup flow (once the reactor is running) reactor.callWhenRunning(self.startup_flow) @@ -72,58 +108,69 @@ # Start the client! self.client.startService() - # First, we wait for the Roster and connection to Wokkel - connd = Deferred() - rosterd = Deferred() - self.wh.set_connected_handler(connd) - self.roster.set_loaded_handler(rosterd) - yield DeferredList([connd, rosterd]) - - # Ensure that everyone in the config file is in the contacts list. - # And remove anyone in the roster who isn't in the contacts list - # We only do this after the roster is loaded, so we don't prematurely - # try to add peeps - self.usermanagement.init_contacts(self.conf['users']) - - # Now load plugins for all the configured user groups - for gname, group in self.conf['usergroups'].items(): - logging.info("Initialising User Group: " + gname) - pm = PluginManager(self.messagehandler, self.usermanagement, - userlist=group['users']) - if group['plugins']: - for p in group['plugins']: - pm.load(p, self.conf['plugindata'][p]) - for p in group['plugins']: - pm.init(p) - else: - logging.error("No Plugins Loaded For User Group: {0}".format(gname)) - - # Then join all the rooms we are configured in (and init their plugins) - roomjoinds = [] - for rjid, rconf in self.conf['rooms'].items(): - pm = PluginManager(self.messagehandler, self.usermanagement, rjid) - if rconf['plugins']: - for p in rconf['plugins']: - pm.load(p, rconf['plugindata'][p]) - for p in rconf['plugins']: - pm.init(p) - - rnick = rconf.get('nick', self.nick) - - logging.info("Joining Room " + rjid + " as \"" + rnick + "\"") - - logging.info("We have normality. I repeat, we have normality. " - "Anything you still can't cope with is therefore your " - "own problem.") + # wait for the wokkelhandler and rosterhandler to connect + whd = Deferred() + rhd = Deferred() + self.wokkelhandler.set_connected_handler(whd) + self.rosterhandler.set_connected_handler(rhd) + yield DeferredList([whd, rhd]) def run(self): reactor.run() -def main(): + +def manhole_setup(argument, config, manhole_dict): + """ + Perform all manhole specific argument processing. + + This involves extracting user, host and port from the argument + (if they have been specified). Reading any unspecified arguments + from config and resorting to defaults if necessary (as below). + There is no default password, instead the user is prompted to + enter one. + + Defaults: + user - endroid + password - No default password + host - 127.0.0.1 + port - 42000 + + """ + + if not argument: + # User doesn't want to start a manhole so nothing to do + return + + if isinstance(argument, basestring): + # Extract the 3 parts (if present) + to_decode, _, port = argument.partition(':') + user, _, host = to_decode.partition('@') + else: + user = None + host = None + port = None + + if not user: + user = config.get("setup", "manhole_user", default="endroid") + if not host: + host = config.get("setup", "manhole_host", default="127.0.0.1") + if not port: + port = config.get("setup", "manhole_port", default="42000") + + # Try getting password from config file otherwise prompt for it + try: + password = config.get("setup", "manhole_password") + except KeyError: + password = getpass.getpass("Enter a manhole password for EnDroid: ") + + endroid.manhole.start_manhole(manhole_dict, user, password, host, port) + + +def main(args): parser = ArgumentParser(prog="endroid", epilog="I'm a robot. I'm not a refrigerator.", - description="EnDroid: extensible XMPP Bot") - parser.add_argument("conffile", nargs='?', default="", + description="EnDroid: Extensible XMPP Bot") + parser.add_argument("-c", "--config", default="", help="Configuration file to use.") parser.add_argument("-l", "--level", type=int, default=logging.INFO, help="Logging level. Lower is more verbose.") @@ -131,14 +178,14 @@ help="File for logging output.") parser.add_argument("-t", "--logtraffic", action='store_true', help="Additionally log all traffic.") - args = parser.parse_args() - - if args.logfile: - logging.basicConfig(filename=args.logfile, level=args.level) - else: - logging.basicConfig(level=args.level) + parser.add_argument("-m", "--manhole", const=True, nargs='?', + metavar="user@host:port", + help="Login name, host and port for ssh access. " + "Any (or none) of the 3 parts can be specified " + "as follows: [user][@host][:port]") + args = parser.parse_args(args) - cmd = args.conffile + cmd = args.config env = os.environ.get("ENDROID_CONF", "") usr = os.path.expanduser(os.path.join("~", ".endroid", "endroid.conf")) gbl = "/etc/endroid/endroid.conf" @@ -146,12 +193,16 @@ try: conffile = (p for p in (cmd, env, usr, gbl) if os.path.exists(p)).next() except StopIteration: - logging.error("EnDroid requires a configuration file.") + sys.stderr.write("EnDroid requires a configuration file.\n") sys.exit(1) - logging.info("EnDroid starting up with conffile {0}".format(conffile)) - droid = Endroid(conffile, logtraffic=args.logtraffic) + print("EnDroid starting up with config file {}".format(conffile)) + + droid = Endroid(conffile, args=args) + + manhole_dict = dict([('droid', droid)] + globals().items()) + manhole_setup(args.manhole, droid.conf, manhole_dict) + + # Start the reactor droid.run() -if __name__ == "__main__": - main() diff -Nru endroid-1.1.2/src/endroid/__main__.py endroid-1.2~68~ubuntu12.10.1/src/endroid/__main__.py --- endroid-1.1.2/src/endroid/__main__.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/__main__.py 2013-09-16 14:30:23.000000000 +0000 @@ -4,7 +4,7 @@ # Created by Jonathan Millican # ----------------------------------------- +import sys import endroid -if __name__ == "__main__": - endroid.main() +endroid.main(sys.argv[1:]) diff -Nru endroid-1.1.2/src/endroid/config.py endroid-1.2~68~ubuntu12.10.1/src/endroid/config.py --- endroid-1.1.2/src/endroid/config.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/config.py 1970-01-01 00:00:00.000000000 +0000 @@ -1,159 +0,0 @@ -# ----------------------------------------- -# Endroid - XMPP Bot -# Copyright 2012, Ensoft Ltd. -# Created by Jonathan Millican -# ----------------------------------------- - -from ConfigParser import ConfigParser -import re -import os.path -import logging -import sys - -pluginOptions = {} - -class EnConfigParser(ConfigParser, dict): - def _sections(self): - return ConfigParser._sections(self) - - def __setitem__(self, key, value): - super(EnConfigParser, self).__setitem__(key, value) - - def __delitem__(self): - return super(EnConfigParser,self).__delitem__() - - def __iter__(self): - return super(EnConfigParser,self).__iter__() - - - def __init__(self, filename): - super(EnConfigParser, self).__init__() - - self.optionxform = str - with open(filename) as f: - self.readfp(f) - - self['database'] = {'dbfile': self.get('Database', 'dbfile')} - self['jid'] = self.get('Setup', 'jid') - self['secret'] = self.get('Setup', 'secret') - - self['plugindata'] = {} - - self['nick'] = self.get('Setup', 'nick', self['jid']) - self['plugins'] = self.getlist('Setup', 'plugins') - self.getPluginData(self['plugins']) - - self['users'] = self.getlist('Setup', 'users') - - # Plugins can be found in various dirs - for dir in self.getlist('Setup', 'plugindirs', ['~/.endroid/plugins/']): - sys.path.append(os.path.expanduser(dir)) - - defRoom = {} - defRoom['nick'] = self.get('Room:*', 'nick', self['nick']) - defRoom['plugins'] = self.getlist('Room:*', 'plugins', self['plugins']) - self.getPluginData(defRoom['plugins']) - - roomlist = self.getlist('Setup', 'rooms') - self['rooms'] = {} - self['rooms'] = dict((k, defRoom.copy()) for k in roomlist) - - defUGroup = {'users': self['users'][:]} # Creates a copy of self['users'] - defUGroup['plugins'] = self.getlist('UserGroup:*', 'plugins', self['plugins']) - self.getPluginData(defUGroup['plugins']) - - self['usergroups'] = {'*': defUGroup} - - for item in self.sections(): - if item[0:10] == 'UserGroup:' and item[10:] != '*': - key = item[10:] - logging.info("Found User Group: " + key) - uGroup = {} - uGroup['users'] = self.getlist(item, 'users') - for userjid in uGroup['users']: - if userjid in self['usergroups']['*']['users']: - self['usergroups']['*']['users'].remove(userjid) - logging.info("User " + userjid + " added to " + key + ", and removed from default user group.") - else: - uGroup['users'].remove(userjid) - logging.info("User " + userjid + " not permitted in Setup section. Not being added to " + key) - - uGroup['plugins'] = self.getlist(item, 'plugins', self['usergroups']['*']['plugins']) - self.getPluginData(uGroup['plugins']) - - self['usergroups'][key] = uGroup - - elif item[0:5] == 'Room:' and item[5:] != '*': - key = item[5:] - if self['rooms'].has_key(key): - self['rooms'][key]['nick'] = self.get(item, 'nick', self['nick']) - self['rooms'][key]['plugins'] = self.getlist(item, 'plugins', defRoom['plugins']) - self.getPluginData(self['rooms'][key]['plugins']) - - for roomjid in self['rooms']: - self['rooms'][roomjid]['plugindata'] = {} - for plugin in self['rooms'][roomjid]['plugins']: - self['rooms'][roomjid]['plugindata'][plugin] = self.getSpecificPluginData(plugin, "Room:" + roomjid) - - - def getPluginData(self, pList): - for plugin in pList: - if not self['plugindata'].has_key(plugin): - self['plugindata'][plugin] = plugin_config(plugin, self) - - def getSpecificPluginData(self, pName, section): - self.getPluginData([pName]) - if os.path.exists('config/plugin-' + pName + '.cfg'): - pData = self['plugindata'][pName].copy() - pConf = ConfigParser() - pConf.optionxform = str - with open('config/plugin-' + pName + '.cfg') as f: - pConf.readfp(f) - - if not pConf.has_section(section): - return pData - else: - opts = pConf.options(section) - for option in opts: - pData[option] = pConf.get(section, option) - return pData - else: - return self['plugindata'][pName] - - - def get(self, section, option, default=None): - if default == None: - return ConfigParser.get(self, section, option) - else: - if self.has_option(section, option): - return ConfigParser.get(self, section, option) - else: - return default - - def getlist(self, section, option, default=None): - if self.has_option(section, option) or default == None: - return as_list(self.get(section, option, '')) - else: - return default - -SPLITTER = re.compile("[,\n]") - -def as_list(value): - """ - Given a string containing potentially multiple lines of comma-separated - items, returns a list of those items. Newlines are treated as separators - (even without a trailing comma), and all items are stripped of whitespace - either side. - """ - return [item.strip() for item in SPLITTER.split(value) if item.strip()] - -def plugin_config(plugName, mConf): - if pluginOptions.has_key(plugName): - return pluginOptions[plugName] - confData = {} - if mConf.has_section('Plugin:' + plugName): - opts = mConf.options('Plugin:' + plugName) - for option in opts: - confData[option] = mConf.get('Plugin:' + plugName, option) - pluginOptions[plugName] = confData - return confData diff -Nru endroid-1.1.2/src/endroid/confparser.py endroid-1.2~68~ubuntu12.10.1/src/endroid/confparser.py --- endroid-1.1.2/src/endroid/confparser.py 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/confparser.py 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,182 @@ +from ConfigParser import ConfigParser +from ast import literal_eval +from collections import defaultdict +import copy +import re + +class Parser(object): + """Reads an ini-like configuration file into an internal dictionary. + Extra syntax: + - nested sections: + [root:child:grandchild:...]: stored as nested dictionaries with values + accessible via .get("root", "child", "grandchild", ..., "key") + - or syntax: + [root:child1|child2:...]: .get("root", "child1", ...) and .get("root", "child2"...) + will look in this section. Note that the or syntax is usable at any depth + and with any number of alternatives (so [root1|root2:child1|child2|child2:...] + is fine) + - wildcard syntax: + [root:*:grandchild:...]: .get("root", : "grandchild", ...) will + look in this section. Again the wildcard character may be used at any depth + (so [*:*:*:...] is doable) + - order of search: + the .get method will return the most specified result it can find. The order + of search is: + [foo:bar] - first + [foo:bar|baz] + [foo|far:bar] + [foo|far:bar|baz] + [foo:*] + [foo|far:*] + [*:bar] + [*:bar|baz] + [*:*] - last + - lists: + - Parser will try to identify lists by the presence interior commas and + newlines. The entry: + key = var1, var2, var3 + will be returned as a list + - For a single item list, a comma must be present: + key = var1, + - Multiline lists do not need commas: + key = var1 + var2 + var3 + as internal newlines are present + - booleans: + - literal_eval will convert "True" or "False" to their bool counterparts, + but "true", "false", "yes", "no" etc will remain as strings. + Arguments to .get: + - 'default' may be specified in which case KeyErrors will never be raised + - 'return_all' will cause get to return all possible results rather than + only the most relevant + Notes: + - All section names will have all whitespace removed and will be converted + to lower case ([Foo | BAR ] -> [foo|bar]) + - Values in the config file will be parsed with literal_eval so will return + from .get as Python objects rather than strings (though if literal_eval fails + then a string will be returned) + """ + SPLITTER = re.compile("[,\n]") + + def __init__(self, filename=None): + self.filename = filename + self.dict = {} + self._aliases = defaultdict(list) + + if filename: + self.load(filename) + + def load(self, filename=None): + filename = filename or self.filename + self.read_file(filename) + self.build_dict() + + def read_file(self, filename): + cp = ConfigParser() + cp.optionxform = Parser.sanitise + + with open(filename) as f: + cp.readfp(f) + self.filename = filename + + # transform Parser section labels into lists of sanitise label parts + # eg "foo: bar | Bar2 : BAZ" -> ["foo","bar|bar2","baz"] + sections = cp.sections() + process_tuple = Parser.process_tuple + self._parts = [map(Parser.sanitise, s.split(':')) for s in sections] + self._items = [map(process_tuple, ts) for ts in map(cp.items, sections)] + + def build_dict(self): + new_dict = {} + for part_list, items_list in zip(self._parts, self._items): + d = new_dict + for part in part_list: + d = d.setdefault(part, {}) + # set the value when we get to the last part + d.update(dict(items_list)) + + self.dict = new_dict + + # register aliases (for the or syntax) + for part in [p for parts in self._parts for p in parts if '|' in p]: + for sub_p in part.split('|'): + # register the full arg against its parts if we haven't + # already so for the arg "name1|name2": + # self._aliases[name1] = ["name1|name2"] + # self._aliases[name2] = ["name1|name2"] + if not part in self._aliases[sub_p]: + self._aliases[sub_p].append(part) + + def get(self, *args, **kwargs): + # note that this is quite a slow lookup function to support the wildcard + # '*' and or '|' syntax without complicating self.dict - shouldn't matter + # as it shouldn't be called too often + if not self.filename: + msg = "[{0}] lookup but no file loaded" + raise ValueError(msg.format(':'.join(args))) + + dicts = [self.dict] + for arg in [a.lower() for a in args]: + # currently looking in [dict1, dict2...] - now move our focus to + # [dict1[a], dict2[a]... dict1[a|o], dict2[a|o]... dict1[*], dict2[*]...] + # in order: [arg, aliases (eg arg|other), wildcard] (so most specified + # comes first) + dicts = [d.get(key) for d in dicts + for key in [arg] + self._aliases[arg] + ['*'] if key in d] + + # get the result + if 'return_all' in kwargs: + result = dicts + elif len(dicts): + result = dicts[0] + elif 'default' in kwargs: + result = kwargs['default'] + else: + msg = "[{0}] not defined in {1}" + raise KeyError(msg.format(':'.join(args), self.filename)) + + # if the result is mutable make a copy of it to prevent accidental modification + # of the config dictionary + if isinstance(result, (dict, list)): + return copy.deepcopy(result) + else: # immutable type + return result + + + @staticmethod + def sanitise(string): + # remove _all_ whitespace (matched by \s) and convert to lowercase + return re.sub(r"\s", "", string).lower() + + @staticmethod + def as_list(string): + # transforms strings containing interior commas or newlines into a list + return [s.strip() for s in Parser.SPLITTER.split(string) if s.strip()] + + @staticmethod + def process_tuple(label_val): + # given a label, value tuple (where label and value are both strings) + # attempts to interpret value as a python object using literal_eval + # and/or as a list (in the case that value contains interior newlines + # or commas) + def process_val(value): + # if literal_eval fails (eg if value really is a string) then return + # a cleaned-up resion. Note we do not want to apply any other transformation + # to value as eg case might be important + try: + return literal_eval(value) + except: + # it is a plain string or cannot be parsed so return as string + return value.strip() + + label, val = label_val + # val strings at end of lines will have an \n so must strip them + val = val.strip() + if Parser.SPLITTER.search(val): # val is a list of items + val = [process_val(v) for v in Parser.as_list(val)] + else: # it is single value + val = process_val(val) + + return (label, val) + diff -Nru endroid-1.1.2/src/endroid/cron.py endroid-1.2~68~ubuntu12.10.1/src/endroid/cron.py --- endroid-1.1.2/src/endroid/cron.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/cron.py 2013-09-16 14:30:23.000000000 +0000 @@ -4,53 +4,140 @@ # Created by Jonathan Millican # ----------------------------------------- -from endroid.database import Database as Database -import datetime +from endroid.database import Database + from twisted.internet import reactor -import cPickle from pytz import timezone +import datetime +import cPickle import logging +import functools + +__all__ = ( + 'task', + 'Cron', +) + + +def task(name, persistent=True): + """ + Decorator for method of Plugin classes that are to be used as callbacks for + cron tasks. + + This decorator ensures the function is registered with the plugin's cron + using the given name and persistent arguments. The decorated function can + be used like a Task (returned from Cron.register): + + >>> CRON_NAME = "MyCronName" + >>> class Foo(Plugin): + ... @task(CRON_NAME) + ... def repeat(self, *args): pass + ... def message_handler(self, msg): + ... self.repeat.setTimeout(10, params) + + That is it provides the setTimeout and doAtTime methods. The decorated + function can also be called as normal, should that be needed. + + """ + def decorator(fn): + fn._cron_iscb = True + fn._cron_name = name + fn._cron_persistent = persistent + return fn + return decorator + class Task(object): """ - Wrapper object providing direct access to a specific "task". Obtain an + Wrapper object providing direct access to a specific "task". Obtain an instance using register on the Cron singleton. + + Can also be obtained by wrapping a callback method using the @task + decorator. In either case, the Task object is updated to appear as if it is + the underlying function, and can be called as if it is. + """ - __slot__ = ('name', 'cron') - def __init__(self, name, cron): + def __init__(self, name, cron, fn): self.name = name self.cron = cron + self.fn = fn + # Disguise the Task as the function it is wrapping + functools.update_wrapper(self, fn) + + def __call__(self, *args, **kwargs): + return self.fn(*args, **kwargs) + def doAtTime(self, time, locality, params): return self.cron.doAtTime(time, locality, self.name, params) + def setTimeout(self, timedelta, params): return self.cron.setTimeout(timedelta, self.name, params) + class Cron(object): + # a wrapper around the CronSing singleton cron = None @staticmethod def get(): - if Cron.cron == None: + if Cron.cron is None: Cron.cron = CronSing() return Cron.cron class CronSing(object): + """ + A singleton providing task scheduling facilities. + A function may be registered by calling register(function, name) (returning + a Task object). + A registered function may be scheduled with either: + - setTimeout(time, name, params) / doAtTime(time, locality, name, params) + - or by calling either method on the Task object returned + by register(), omitting the name parameter. + Note that params will be pickled for storage in the database. + + When it comes to be called, the function will be called with an argument + unpickled from params (so even if the function needs no arguments it should + allow for one eg def foo(_) rather than def foo()). + + """ def __init__(self): self.delayedcall = None self.fun_dict = {} self.db = Database('Cron') + # table for tasks which will be called after a certain amount of time if not self.db.table_exists('cron_delay'): - self.db.create_table('cron_delay', ['timestamp', 'fun_name', 'params']) + self.db.create_table('cron_delay', + ['timestamp', 'reg_name', 'params']) + # table for tasks which will be called at a specific time if not self.db.table_exists('cron_datetime'): - self.db.create_table('cron_datetime', ['datetime', 'locality', 'fun_name', 'params']) - + self.db.create_table('cron_datetime', + ['datetime', 'locality', 'reg_name', 'params']) + def seconds_until(self, td): - return float((td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6)) / 10**6 - - def register(self, fun, name): - self.fun_dict.update({name: fun}) - return Task(name, self) + ds, ss, uss = td.days, td.seconds, td.microseconds + return float((uss + (ss + ds * 24 * 3600) * 10**6)) / 10**6 + + def register(self, function, reg_name, persistent=True): + """ + Register the callable fun against reg_name. + + Returns a Task object and allows callable to be scheduled with doAtTime + or setTimeout either on self, or on the Task object returned. + + If persistent is False, any previous reigstrations against reg_name will + be deleted before the new function is registered. + + """ + # reg_name is the key we use to access the function - we can then set the + # function to be called using setTimeout or doAtTime with regname = name + + # remove any prior functions with this reg_name + if not persistent: + self.removeTask(reg_name) + + self.fun_dict.update({reg_name: function}) + return Task(reg_name, self, function) def cancel(self): if self.delayedcall: @@ -61,14 +148,14 @@ if self.delayedcall: self.delayedcall.cancel() self.delayedcall = reactor.callLater(0, self._do_crons) - + def _time_left_delay(self, pickleTime): curtime = datetime.datetime.now(timezone('GMT')) dt = cPickle.loads(str(pickleTime)) dt_gmt = dt.astimezone(timezone('GMT')) time_delta = dt_gmt - curtime return self.seconds_until(time_delta) - + def _time_left_set_time(self, pickleTime, locality): curtime = datetime.datetime.now(timezone('GMT')) dt = cPickle.loads(str(pickleTime)) @@ -76,89 +163,131 @@ dt_gmt = dt_local.astimezone(timezone('GMT')) time_delta = dt_gmt - curtime return self.seconds_until(time_delta) - - + def _do_crons(self): self.delayedcall = None -# curtime = datetime.now(timezone('GMT')) - delays = self.db.fetch('cron_delay', ['timestamp', 'fun_name', 'params', 'rowid']) - set_times = self.db.fetch('cron_datetime', ['datetime', 'fun_name', 'params', 'locality', 'rowid']) - -# crons = [{'table':'cron_delay', 'data':data, 'time_left': self.seconds_until(cPickle.loads(str(data['timestamp'])).astimezone(timezone('GMT')) - curtime)} for data in delays ] -# crons = [] -# for data in delays: -# dt = cPickle.loads(str(data['timestamp'])) -# dt_gmt = dt.astimezone(timezone('GMT')) -# time_delta = dt_gmt - curtime -# time_left = self.seconds_until(time_delta) -# crons.append({'table':'cron_delay', 'data':data, 'time_left':time_left}) -# -# for data in set_times: -# dt = cPickle.loads(str(data['datetime'])) -# dt_local = timezone(data['locality']).localize(dt) -# dt_gmt = dt_local.astimezone(timezone('GMT')) -# time_delta = dt_gmt - curtime -# time_left = self.seconds_until(time_delta) -# crons.append({'table':'cron_datetime', 'data':data, 'time_left':time_left}) - -# crons_s = [{'table':'cron_datetime', 'data':data, 'time_left': self.seconds_until(timezone(data['locality']).localize(cPickle.loads(str(data['datetime']))).astimezone(timezone('GMT')) - curtime)} for data in set_times] + # retrieve the information about our two kinds of scheduled tasks from + # the database + delays = self.db.fetch('cron_delay', + ['timestamp', 'reg_name', 'params', 'rowid']) + set_times = self.db.fetch('cron_datetime', + ['datetime', 'reg_name', 'params', + 'locality', 'rowid']) + + # transform the two types of data into a consistant format and combine + # data is the raw information we retrieved from self.db crons_d = [{ 'table': 'cron_delay', 'data': data, 'time_left': self._time_left_delay(data['timestamp']) - } for data in delays] + } for data in delays] crons_s = [{ 'table': 'cron_datetime', 'data': data, - 'time_left': self._time_left_set_time(data['datetime'], data['locality']) + 'time_left': self._time_left_set_time(data['datetime'], + data['locality']) } for data in set_times] crons = crons_d + crons_s - + shortest = None - + + # run all crons with time_left <= 0, find smallest time_left amongst + # others and reschedule ourself to run again after this time for cron in crons: if cron['time_left'] <= 0: + # the function is ready to be called + # remove the entry from the database and call it self.db.delete(cron['table'], cron['data']) - logging.info("Running Cron: " + cron['data']['fun_name']) - self.fun_dict[cron['data']['fun_name']](cPickle.loads(str(cron['data']['params']))) + logging.info("Running Cron: " + cron['data']['reg_name']) + params = cPickle.loads(str(cron['data']['params'])) + try: + self.fun_dict[cron['data']['reg_name']](params) + except KeyError: + # If there has been a restart we will have lost our fun_dict + # If functions have not been re-registered then we will have a problem. + logging.error("Failed to run Cron: {} not in dictionary".format(cron['data']['reg_name'])) else: - if (shortest == None) or cron['time_left'] < shortest: + # update the shortest time left + if (shortest is None) or cron['time_left'] < shortest: shortest = cron['time_left'] - if not shortest == None: + if not shortest is None: #ie there is another function to be scheduled self.delayedcall = reactor.callLater(shortest, self._do_crons) - def doAtTime(self, time, locality, fun_name, params): + def doAtTime(self, time, locality, reg_name, params): + """ + Start a cron job to trigger the specified function ('reg_name') with the + specified arguments ('params') at time ('time', 'locality'). + + """ lTime = timezone(locality).localize(time) gTime = lTime.astimezone(timezone('GMT')) - - logging.info("Cron task \"" + fun_name + "\" set for " + str(lTime) + " (" + str(gTime) + " in GMT)") - t,p = self._pickleTimeParams(time, params) - - self.db.insert('cron_datetime', {'datetime': t, 'locality': locality, 'fun_name': fun_name, 'params': p}) + + fmt = "Cron task '{}' set for {} ({} GMT)" + logging.info(fmt.format(reg_name, lTime, gTime)) + t, p = self._pickleTimeParams(time, params) + + self.db.insert('cron_datetime', {'datetime': t, 'locality': locality, + 'reg_name': reg_name, 'params': p}) self.do_crons() - - + def setTimeout(self, timedelta, reg_name, params): """ - Start a cron job to trigger the specified registration ('reg_name') with the - specified arguments ('params') after the specified delay ('timedelta'). + Start a cron job to trigger the specified registration ('reg_name') with + specified arguments ('params') after delay ('timedelta'). timedelta may either be a datetime.timedelta object, or a real number - representing a number of seconds to wait. Negative or 0 values will trigger - near immediately. + representing a number of seconds to wait. Negative or 0 values will + trigger near immediately. + """ if not isinstance(timedelta, datetime.timedelta): timedelta = datetime.timedelta(seconds=timedelta) - logging.info('Cron task "{0}" set to run after {1}'.format(reg_name, - str(timedelta))) + + fmt = 'Cron task "{0}" set to run after {1}' + logging.info(fmt.format(reg_name, str(timedelta))) + time = datetime.datetime.now(timezone('GMT')) + timedelta - t, p = self._pickleTimeParams(time, params) - - self.db.insert('cron_delay', {'timestamp': t, 'fun_name': reg_name, - 'params': p}) + + self.db.insert('cron_delay', {'timestamp': t, 'reg_name': reg_name, + 'params': p}) self.do_crons() - - + + def removeTask(self, reg_name): + """Remove any scheduled tasks registered with reg_name.""" + self.db.delete('cron_delay', {'reg_name': reg_name}) + self.db.delete('cron_datetime', {'reg_name': reg_name}) + self.fun_dict.pop('reg_name', None) + + def getAtTimes(self): + """ + Return a string showing the registration names of functions scheduled + with doAtTime and the amount of time they will be called in. + + """ + def get_single_string(data): + fmt = " name '{}' to run in '{}:{}:{}'" + name = data['reg_name'] + delay = int(round(self._time_left_set_time(data['datetime'], data['locality']))) + return fmt.format(name, delay // 3600, (delay % 3600) // 60, delay % 60) + + data = self.db.fetch('cron_datetime', ['reg_name', 'locality', 'datetime']) + return "Datetime registrations:\n" + '\n'.join(map(get_single_string, data)) + + def getTimeouts(self): + """ + Return a string showing the registration names of functions scheduled + with setTimeout and the amount of time they will be called in. + + """ + def get_single_string(data): + fmt = " name '{}' to run in '{}:{}:{}'" + name = data['reg_name'] + delay = int(round(self._time_left_delay(data['timestamp']))) + return fmt.format(name, delay // 3600, (delay % 3600) // 60, delay % 60) + + data = self.db.fetch('cron_delay', ['reg_name', 'timestamp']) + return "Timeout registrations:\n" + '\n'.join(map(get_single_string, data)) + def _pickleTimeParams(self, time, params): return cPickle.dumps(time), cPickle.dumps(params) diff -Nru endroid-1.1.2/src/endroid/database.py endroid-1.2~68~ubuntu12.10.1/src/endroid/database.py --- endroid-1.1.2/src/endroid/database.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/database.py 2013-09-16 14:30:23.000000000 +0000 @@ -12,21 +12,27 @@ class TableRow(dict): - """ - A regular dict, plus a system 'id' attribute. - """ + """A regular dict, plus a system 'id' attribute.""" __slots__ = () def __init__(self, *args, **kwargs): super(TableRow, self).__init__(*args, **kwargs) if not EndroidUniqueID in self: - raise ValueError("Cannot create table row from table with no {0} column!".format(EndroidUniqueID)) + raise ValueError("Cannot create table row from table with no {0} " + "column!".format(EndroidUniqueID)) @property def id(self): return self[EndroidUniqueID] class Database(object): + """ + Wrapper round an sqlite3 Database. + + All accesses are synchronous, TODO use twisted.enterprise.adbapi to + asynchronise them. + + """ connection = None cursor = None file_name = None @@ -71,6 +77,11 @@ return " and ".join(Database._sanitize(c) + "=?" for c in conditions) or "1" def create_table(self, name, fields): + """ + Create a new table in the database called 'name' and containing fields + 'fields' (an iterable of strings giving field titles). + + """ if any(f.startswith('_endroid') for f in fields): raise ValueError("An attempt was made to create a table with system-reserved column-name (prefix '_endroid').") n = Database._sanitize(self._tName(name)) @@ -79,6 +90,7 @@ Database.raw(query) def table_exists(self, name): + """Check to see if a table called 'name' exists in the database.""" n = Database._sanitize(self._tName(name)) query = "SELECT `name` FROM `sqlite_master` WHERE `type`='table' AND `name`={0};".format(n) Database.raw(query) @@ -87,6 +99,13 @@ return count != 0 def insert(self, name, fields): + """ + Insert a row into table 'name'. + + Fields is a dictionary mapping field names (as defined in + create_table) to values. + + """ n = Database._sanitize(self._tName(name)) query = "INSERT INTO {0} ({1}) VALUES ({2});".format(n, Database._stringFromFieldNames(fields), @@ -95,6 +114,19 @@ Database.raw(query, tup) def fetch(self, name, fields, conditions={}): + """ + Get data from the table 'name'. + + Returns a list of dictionaries mapping 'fields' to their values, one + dictionary for each row which satisfies a condition in conditions. + + Conditions is a dictionary mapping field names to values. A result + will only be returned from a row if its values match those in conditions. + + E.g.: conditions = {'user' : JoeBloggs} + will match only fields in rows which have JoeBloggs in the 'user' field. + + """ n = Database._sanitize(self._tName(name)) fields = list(fields) + [EndroidUniqueID] query = "SELECT {0} FROM {1} WHERE ({2});".format( @@ -106,31 +138,41 @@ return rows def count(self, name, conditions): + """Return the number of rows in table 'name' which satisfy conditions.""" n = Database._sanitize(self._tName(name)) query = "SELECT COUNT(*) FROM {0} WHERE ({1});".format(n, Database._buildConditions(conditions)) r = Database.raw(query, Database._tupleFromFieldValues(conditions)).fetchall() return r[0][0] def delete(self, name, conditions): + """Delete rows from table 'name' which satisfy conditions.""" n = Database._sanitize(self._tName(name)) query = "DELETE FROM {0} WHERE ({1});".format(n, Database._buildConditions(conditions)) Database.raw(query, Database._tupleFromFieldValues(conditions)) return Database.cursor.rowcount def update(self, name, fields, conditions): + """ + Update rows in table 'name' which satisfy conditions. + + Fields is a dictionary mapping the field names to their new values. + + """ n = Database._sanitize(self._tName(name)) query = "UPDATE {0} SET {1} WHERE ({2});".format(n, Database._buildConditions(fields), Database._buildConditions(conditions)) tup = Database._tupleFromFieldValues(fields) tup = tup + Database._tupleFromFieldValues(conditions) Database.raw(query, tup) return Database.cursor.rowcount - + def empty_table(self, name): + """Remove all rows from table 'name'.""" n = Database._sanitize(self._tName(name)) query = "DELETE FROM {0} WHERE 1;".format(n) Database.raw(query) def delete_table(self, name): + """Delete table 'name'.""" n = Database._sanitize(self._tName(name)) query = "DROP TABLE {0};".format(n) Database.raw(query) diff -Nru endroid-1.1.2/src/endroid/manhole.py endroid-1.2~68~ubuntu12.10.1/src/endroid/manhole.py --- endroid-1.1.2/src/endroid/manhole.py 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/manhole.py 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,43 @@ +from twisted.cred import portal, checkers +from twisted.conch import manhole, manhole_ssh +from twisted.internet import reactor +import logging + + +def manhole_factory(nmspace, **passwords): + realm = manhole_ssh.TerminalRealm() + + def getManhole(_): + return manhole.Manhole(nmspace) + + realm.chainedProtocolFactory.protocolFactory = getManhole + p = portal.Portal(realm) + p.registerChecker( + checkers.InMemoryUsernamePasswordDatabaseDontUse(**passwords) + ) + f = manhole_ssh.ConchFactory(p) + return f + + +def start_manhole(nmspace, user, password, host, port): + """ + Start EnDroid listening for an ssh connection. + + Logging in via ssh gives access to a python prompt from which EnDroid's + internals can be investigated. Connect to the manhole using e.g. + ssh user@host -p port + + nmspace - The namespace to make available to the manhole + user - Logon username. + password - Password to use for the manhole + host - Host where the manhoe connection will be made. + port - The port to open for the manhole. + + """ + + logging.info("Starting manhole with user: %s, host: %s and port: %s", + user, host, port) + + reactor.listenTCP(int(port), manhole_factory(nmspace, **{user: password}), + interface=host) + diff -Nru endroid-1.1.2/src/endroid/messagehandler.py endroid-1.2~68~ubuntu12.10.1/src/endroid/messagehandler.py --- endroid-1.1.2/src/endroid/messagehandler.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/messagehandler.py 2013-09-16 14:30:23.000000000 +0000 @@ -5,152 +5,282 @@ # ----------------------------------------- import logging +from twisted.words.protocols.jabber.jid import JID -PRIORITY_NORMAL = 0 -PRIORITY_URGENT = -1 -PRIORITY_BULK = 1 + +class Handler(object): + __slots__ = ("name", "priority", "callback") + def __init__(self, priority, callback): + self.name = callback.__name__ + self.priority = priority + self.callback = callback + + def __str__(self): + return "{}: {}".format(self.priority, self.name) + +class Priority(object): + NORMAL = 0 + URGENT = -1 + BULK = 1 class MessageHandler(object): - def __init__(self, wh): - self._handlers = {} + """An abstraction of XMPP's message protocols.""" + + PRIORITY_NORMAL = Priority.NORMAL + PRIORITY_URGENT = Priority.URGENT + PRIORITY_BULK = Priority.BULK + + def __init__(self, wh, um): self.wh = wh + self.um = um + # wh translates messages and gives them to us, needs to know who we are self.wh.set_message_handler(self) + self._handlers = {} + + def _register_callback(self, name, typ, cat, callback, + including_self=False, priority=Priority.NORMAL): + """ + Register a function to be called on receipt of a message of type + 'typ' (muc/chat), category 'cat' (recv, send, unhandled, *_self, *_filter) + sent from user or room 'name'. - def register_callback(self, typ, cat, key, callback, - including_self=False, priority=0): + """ + # self._handlers is a dictionary of form: + # { type : { category : { room/groupname : [Handler objects]}}} typhndlrs = self._handlers.setdefault(typ, {}) cathndlrs = typhndlrs.setdefault(cat, {}) - handlers = cathndlrs.setdefault(key, []) - handlers.append((priority, callback)) - handlers.sort() - if including_self: - self.register_callback(typ, cat + "_self", key, callback, - priority=priority) + handlers = cathndlrs.setdefault(name, []) + handlers.append(Handler(priority, callback)) + handlers.sort(key=lambda h: h.priority) - def register_muc_handler(self, rjid, handler, including_self=False, priority=0): - self.register_callback("muc", "recv", rjid, handler, including_self, priority) + # this callback be called when we get messages sent by ourself + if including_self: + self._register_callback(name, typ, cat + "_self", callback, + priority=priority) - def register_chat_handler(self, users, handler, including_self=False, priority=0): - for user in users: - self.register_callback("chat", "recv", user, handler, including_self, priority) - - def register_unhandled_muc_handler(self, rjid, handler, including_self=False, - priority=0): - self.register_callback("muc", "unhandled", rjid, handler, including_self, priority) - - def register_unhandled_chat_handler(self, users, handler, including_self=False, - priority=0): - for user in users: - self.register_callback("chat", "unhandled", user, handler, including_self, priority) - - def register_muc_recv_filter(self, rjid, handler, priority=0): - self.register_callback("muc", "recv_filter", rjid, handler, priority=priority) - - def register_chat_recv_filter(self, users, handler, priority=0): - for user in users: - self.register_callback("chat", "recv_filter", user, handler, priority=priority) - - def register_muc_send_filter(self, rjid, handler, priority=0): - self.register_callback("muc", "send_filter", rjid, handler, priority=priority) - - def register_chat_send_filter(self, users, handler, priority=0): - for user in users: - self.register_callback("chat", "send_filter", user, handler, priority=priority) - - def do_callback(self, typ, cat, key, msg, failback): - handlers = self._handlers.get(typ, {}).get(cat, {}) - filters = self._handlers.get(typ, {}).get(cat + "_filter", {}) - if key in handlers and all(f(msg) for p, f in filters.get(key, [])): + def _get_handlers(self, typ, cat, name): + dct = self._handlers.get(typ, {}).get(cat, {}) + if typ == 'chat': # we need to lookup name's groups + # we may have either a full jid or just a userhost, + # groups are referenced by userhost + name = self.um.get_userhost(name) + handlers = [] + for name in self.um.get_groups(name): + handlers.extend(dct.get(name, [])) + handlers.sort(key=lambda h: h.priority) + return handlers + else: # we are in a room so only one set of handlers to read + return dct.get(name, []) + + def _get_filters(self, typ, cat, name): + return self._get_handlers(typ, cat + "_filter", name) + + def _do_callback(self, cat, msg, failback=lambda m: None): + if msg.place == "muc": + # get the handlers active in the room - note that these are already + # sorted (sorting is done in the register_callback method) + handlers = self._get_handlers(msg.place, cat, msg.recipient) + filters = self._get_filters(msg.place, cat, msg.recipient) + else: + # combine the handlers from each group the user is registered with + # note that if the same plugin is registered for more than one of + # the user's groups, the plugin's instance in each group will be + # called + handlers = self._get_handlers(msg.place, cat, msg.sender) + filters = self._get_filters(msg.place, cat, msg.sender) + + log_list = [] + if handlers and all(f.callback(msg) for f in filters): msg.set_unhandled_cb(failback) - for i in handlers[key]: + + for i in handlers: msg.inc_handlers() - for pri, cb in handlers[key]: + + log_list.append("Did {} {} handlers (priority: cb):".format(len(handlers), cat)) + for handler in handlers: try: - cb(msg) + handler.callback(msg) + log_list.append(str(handler)) except Exception as e: - logging.error("Exception occurred in callback: {0}".format(str(e))) - msg.unhandled() + log_list.append("Exception in {}:\n{}".format(handler.name, e)) + msg.dec_handlers() raise else: failback(msg) + if log_list: + logging.info("Finished plugin callback: {}".format( + "\n\t".join(log_list))) + else: + logging.info("Finished plugin callback - no plugins called.") - def _unhandled_muc(self, msg): - self.do_callback("muc", "unhandled", msg.rjid.userhost(), msg, lambda m: None) + def _unhandled(self, msg): + self._do_callback("unhandled", msg) - def _unhandled_self_muc(self, msg): - self.do_callback("muc", "unhandled_self", msg.rjid.userhost(), msg, - lambda m: None) + def _unhandled_self(self, msg): + self._do_callback("unhandled_self", msg) + # Do normal (recv) callbacks on msg. If no callbacks handle the message + # then call unhandled callbacks (msg's failback is set self._unhandled_... + # by the last argument to _do_callback). def receive_muc(self, msg): - self.do_callback("muc", "recv", msg.rjid.userhost(), msg, self._unhandled_muc) + self._do_callback("recv", msg, self._unhandled) def receive_self_muc(self, msg): - self.do_callback("muc", "recv_self", msg.rjid.userhost(), msg, - self._unhandled_self_muc) - - def _unhandled_chat(self, msg): - self.do_callback("chat", "unhandled", msg.sender.userhost(), msg, - lambda m: None) - - def _unhandled_self_chat(self, msg): - self.do_callback("chat", "unhandled_self", msg.sender.userhost(), msg, - lambda m: None) + self._do_callback("recv_self", msg, self._unhandled_self) def receive_chat(self, msg): - self.do_callback("chat", "recv", msg.sender.userhost(), msg, - self._unhandled_chat) + self._do_callback("recv", msg, self._unhandled) def receive_self_chat(self, msg): - self.do_callback("chat", "recv_self", msg.sender.userhost(), msg, - self._unhandled_self_chat) + self._do_callback("recv_self", msg, self._unhandled_self) + + def for_plugin(self, pluginmanager, plugin): + return PluginMessageHandler(self, pluginmanager, plugin) + + # API for global plugins + + def register(self, name, callback, priority=Priority.NORMAL, muc_only=False, + chat_only=False, include_self=False, unhandled=False, + send_filter=False, recv_filter=False): + if sum(1 for i in (unhandled, send_filter, recv_filter) if i) > 1: + raise TypeError("Only one of unhandled, send_filter or recv_filter " + "may be specified") + if chat_only and muc_only: + raise TypeError("Only one of chat_only or muc_only may be " + "specified") + + if unhandled: + cat = "unhandled" + elif send_filter: + cat = "send_filter" + elif recv_filter: + cat = "recv_filter" + else: + cat = "recv" + + if not muc_only: + self._register_callback(name, "chat", cat, callback, + include_self, priority) + if not chat_only: + self._register_callback(name, "muc", cat, callback, + include_self, priority) + + def send_muc(self, room, body, source=None, priority=Priority.NORMAL): + """ + Send muc message to room. + + The message will be run through any registered filters before it is + sent. + + """ + # Verify this is a room EnDroid knows about + msg = Message('muc', source, body, self, recipient=room) + # when sending messages we check the filters registered with the + # _recipient_. Cf. when we receive messages we check filters registered + # with the _sender_. + filters = self._get_filters('muc', 'send', msg.recipient) + + if all(f.callback(msg) for f in filters): + logging.info("Sending message to {}".format(room)) + self.wh.groupChat(JID(room), body) + else: + logging.info("Filtered out message to {}".format(room)) + + def send_chat(self, user, body, source=None, priority=Priority.NORMAL): + """ + Send chat message to person with address user. - def send_muc(self, rjid, text, source, priority=PRIORITY_NORMAL): - filters = self._handlers.get("muc", {}).get("send_filter", {}) - msg = Message('muc', source, text, self, recipient=rjid, rjid=rjid) - if all(f(msg) for p, f in filters.get(rjid.userhost(), [])): - self.wh.groupChat(rjid, text) - - def send_chat(self, user, text, source, priority=PRIORITY_NORMAL): - filters = self._handlers.get("chat", {}).get("send_filter", {}) - msg = Message('chat', source, text, self, recipient=user) - if all(f(msg) for p, f in filters.get(user.userhost(), [])): - self.wh.chat(user, text) + The message will be run through any registered filters before it is + sent. + + """ + # Verify user is known to EnDroid + msg = Message('chat', source, body, self, recipient=user) + filters = self._get_filters('chat', 'send', msg.recipient) + + if all(f.callback(msg) for f in filters): + logging.info("Sending message to {}".format(user)) + self.wh.chat(JID(user), body) else: logging.info("Filtered out message to {0}".format(user)) - + +class PluginMessageHandler(object): + """ + One of these exists per plugin, providing the API to handle messsages. + """ + def __init__(self, messagehandler, pluginmanager, plugin): + self._messagehandler = messagehandler + self._pluginmanager = pluginmanager + self._plugin = plugin + + def send_muc(self, body, source=None, priority=Priority.NORMAL): + if self._pluginmanager.place != "room": + raise ValueError("Not in a room") + self._messagehandler.send_muc(self._pluginmanager.name, body, + source=source, priority=priority) + + def send_chat(self, user, body, source=None, priority=Priority.NORMAL): + if self._pluginmanager.place != "group": + raise ValueError("Not in a group") + if user not in self._pluginmanager.usermanagement.users( + self._pluginmanager.name): + raise ValueError("Target user is not in this group") + # Verify user is in the group we are in + self._messagehandler.send_chat(user, body, + source=source, priority=priority) + + def register(self, callback, priority=Priority.NORMAL, muc_only=False, + chat_only=False, include_self=False, unhandled=False, + send_filter=False, recv_filter=False): + if self._pluginmanager.place == "room" and not chat_only: + muc_only = True + if self._pluginmanager.place == "group" and not muc_only: + chat_only = True + self._messagehandler.register(self._pluginmanager.name, callback, + priority=priority, muc_only=muc_only, + chat_only=chat_only, + include_self=include_self, + unhandled=unhandled, + send_filter=send_filter, + recv_filter=recv_filter) + class Message(object): - def __init__(self, mtype, sender, body, messagehandler, - recipient=None, handlers=0, rjid=None, sendernick=None, - priority=PRIORITY_NORMAL): - self.mtype = mtype - self.sender = sender - self.sendernick = sendernick + def __init__(self, place, sender, body, messagehandler, recipient, handlers=0, + priority=Priority.NORMAL): + self.place = place + + # sender_full is a string representing the full jid (including resource) + # of the message's sender. Used in reply methods so that if a user is + # logged in on several resources, the reply will be sent to the right + # one + self.sender_full = sender + # a string representing the userhost of the message's sender. Used to + # lookup resource-independant user properties eg their registered rooms + self.sender = messagehandler.um.get_userhost(sender) self.body = body self.recipient = recipient + + # a count of plugins which will try to process this message self.__handlers = handlers - self.rjid = rjid - self.messagehandler = messagehandler + self._messagehandler = messagehandler self.priority = priority def send(self): - if self.mtype == "chat": - self.messagehandler.send_chat(self.recipient, self.body, self.sender) - elif self.mtype == "muc": - self.messagehandler.send_muc(self.recipient, self.body, self.sender) + if self.place == "chat": + self._messagehandler.send_chat(self.recipient, self.body, self.sender) + elif self.place == "muc": + self._messagehandler.send_muc(self.recipient, self.body, self.sender) def reply(self, body): - if self.mtype == "chat": - self.messagehandler.send_chat(self.sender, body, self) - elif self.mtype == "muc": - self.messagehandler.send_muc(self.rjid, body, self) + if self.place == "chat": + self._messagehandler.send_chat(self.sender_full, body, self.recipient) + elif self.place == "muc": + # we send to the room (the recipient), not the message's sender + self._messagehandler.send_muc(self.recipient, body, self.recipient) def reply_to_sender(self, body): - self.messagehandler.send_chat(self.sender, body, self) - - def prepare_response(self, tojid, message, ismuc=False): - return Message('muc' if ismuc else 'chat', self.recipient, message, - self.messagehandler, recipient=tojid) + self._messagehandler.send_chat(self.sender_full, body, self.recipient) def inc_handlers(self): self.__handlers += 1 @@ -158,7 +288,7 @@ def dec_handlers(self): self.__handlers -= 1 self.do_unhandled() - + def unhandled(self, *args): """ Notify the message that the caller hasn't handled it. This should only @@ -167,12 +297,13 @@ This method takes arbitrary arguments so it can be used as deferred callback or errback. + """ self.dec_handlers() - + def do_unhandled(self): - if self.__handlers == 0 and hasattr(self, 'unHandledCallback'): - self.unHandledCallback(self) - + if self.__handlers == 0 and hasattr(self, '_unhandled_cb'): + self._unhandled_cb(self) + def set_unhandled_cb(self, cb): - self.unHandledCallback = cb + self._unhandled_cb = cb diff -Nru endroid-1.1.2/src/endroid/pluginmanager.py endroid-1.2~68~ubuntu12.10.1/src/endroid/pluginmanager.py --- endroid-1.1.2/src/endroid/pluginmanager.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/pluginmanager.py 2013-09-16 14:30:23.000000000 +0000 @@ -6,8 +6,31 @@ import sys import logging +import functools + +from endroid.cron import Cron +from endroid.database import Database + +def deprecated(fn): + return fn + +msg_filter_doc = ("Register a {0} filter for {1} messages.\n" + "Filter takes endroid.messagehandler.Message and returns bool. If its\n" + "return value evaluates to False, the message is dropped.\n" + "Priority specifies the order of calling - lower numbers = called earlier.\n") + +msg_send_doc = ("Send a {0} message to {1}, with given text and priority.\n" + "rjid is a string representing the JID of the room to send to.\n" + "text is a string (or unicode) of the message contents.\n" + "priority one of the PRIORITY_* constants from endroid.messagehandler.\n" + "(Use with care, especially PRIORITY_URGENT, which will usually bypass any\n" + "ratelimiting or other protections put in place.)") + +msg_cb_doc = ("Register a callback for {0} messages.\n" + "Callback takes endroid.messagehandler.Message and may alter it arbitrarily.\n" + "Inc_self specifies whether to do the callback if EnDroid created the message.\n" + "Priority specifies the order of calling - lower numbers = called earlier.\n") -from endroid.messagehandler import PRIORITY_NORMAL class PluginMeta(type): """ @@ -35,211 +58,197 @@ if 'enInit' in dict: dict['endroid_init'] = dict['enInit'] del dict['enInit'] + + # Support for the Cron @task decorator + crons = {name: fn for name, fn in dict.items() + if getattr(fn, "_cron_iscb", False)} + init = dict.get('endroid_init', lambda _: None) + # Make sure the new init function looks like the old one + @functools.wraps(init) + def endroid_init(self): + for name, fn in crons.items(): + @functools.wraps(fn) + def inner(*args, **kwargs): + # This function is here to ensure the right obj is passed + # as self to the method. + return fn(self, *args, **kwargs) + task = self.cron.register(inner, fn._cron_name, + persistent=fn._cron_persistent) + setattr(self, name, task) + init(self) + dict['endroid_init'] = endroid_init + return type.__new__(meta, name, bases, dict) def __init__(cls, name, bases, dict): type.__init__(cls, name, bases, dict) + cls.modname = cls.__module__ PluginMeta.registry[cls.__module__] = cls + class Plugin(object): """ Parent class of all plugin objects within EnDroid. Plugins must subclass this type and it also represents the entry point for the plugin into the rest of EnDroid. + """ __metaclass__ = PluginMeta - - def suc_users(self): - return self._pm.suc_users() - - def _pluginInit(self, pm, conf): - self._pm = pm - self.vars = conf - - # Quick and nasty integration of usermanagement without abstracting away - # functions. - - def presence(self): - return self._pm.usermanagement.presence - # Registration methods + def _setup(self, pm, conf): + self._pm = pm - def register_muc_callback(self, callback, including_self=False, priority=0): - if self._pm.rjid: - self._pm.register_muc_callback(callback, including_self=False, priority=priority) - - def register_chat_callback(self, callback, including_self=False, priority=0): - if not self._pm.rjid: - self._pm.register_chat_callback(callback, including_self=False, priority=priority) - - def register_unhandled_muc_callback(self, callback, including_self=False, priority=0): - if self._pm.rjid: - self._pm.register_unhandled_muc_callback(callback, including_self=False, priority=priority) - - def register_unhandled_chat_callback(self, callback, including_self=False, priority=0): - if not self._pm.rjid: - self._pm.register_unhandled_chat_callback(callback, including_self=False, priority=priority) + self.messagehandler = pm.messagehandler + self.usermanagement = pm.usermanagement - def register_muc_filter(self, callback, priority=0): - """ - Register a receive filter for MUC messages. The specified callback is - called whenever a MUC message is received, allowing it to perform - either transformations on the message, and even choose to drop the - message completely. - - callback is a callable that takes a single argument, which will be of - type endroid.messagehandler.Message, representing the received - message. If it returns a False value (including None), then the message - is dropped - no further filters will be called, and no handlers are - called. - priority allows a relative prioritisation of different filters. Lower - priority filters are run first. Within a priority level, filters are - run in the order they are registered. - """ - if self._pm.rjid: - self._pm.register_muc_filter(callback, priority) + self.plugins = pm + self.messages = pm.messagehandler.for_plugin(pm, self) + self.rosters = pm.usermanagement.for_plugin(pm, self) - def register_chat_filter(self, callback, priority=0): - """ - Register a receive filter for regular chat messages. The specified - callback is called whenever a chat message is received, allowing it to - perform either transformations on the message, and even choose to drop - the message completely. - - callback is a callable that takes a single argument, which will be of - type endroid.messagehandler.Message, representing the received - message. If it returns a False value (including None), then the message - is dropped - no further filters will be called, and no handlers are - called. - priority allows a relative prioritisation of different filters. Lower - priority filters are run first. Within a priority level, filters are - run in the order they are registered. - """ - if not self._pm.rjid: - self._pm.register_chat_filter(callback, priority) + self._database = None - def register_muc_send_filter(self, callback, priority=0): - """ - Register a send filter for MUC messages. The specified callback is - called whenever a MUC message is about to be sent, allowing it to - perform either transformations on the message, and even choose to drop - the message completely. - - callback is a callable that takes a single argument, which will be of - type endroid.messagehandler.Message, representing the message about to - be sent. If it returns a False value (including None), then the message - is dropped - no further filters will be called, and the message is not - sent on to the WokkelHandler. - priority allows a relative prioritisation of different filters. Lower - priority filters are run first. Within a priority level, filters are - run in the order they are registered. - """ - if self._pm.rjid: - self._pm.register_muc_send_filter(callback, priority) + self.place = pm.place + self.place_name = pm.name + self.vars = conf - def register_chat_send_filter(self, callback, priority=0): - """ - Register a send filter for regular chat messages. The specified - callback is called whenever a chat message is about to be sent, - allowing it to perform either transformations on the message, and even - choose to drop the message completely. - - callback is a callable that takes a single argument, which will be of - type endroid.messagehandler.Message, representing the message about to - be sent. If it returns a False value (including None), then the message - is dropped - no further filters will be called, and the message is not - sent on to the WokkelHandler. - priority allows a relative prioritisation of different filters. Lower - priority filters are run first. Within a priority level, filters are - run in the order they are registered. - """ - if not self._pm.rjid: - self._pm.register_chat_send_filter(callback, priority) + @property + def database(self): + if self._database is None: + self._database = Database(self.name) # Should use place too + return self._database + + def _register(self, *args, **kwargs): + return self.messagehandler._register_callback(self._pm.name, *args, **kwargs) + + # Message Registration methods + @deprecated + def register_muc_callback(self, callback, inc_self=False, priority=0): + if self._pm.place != "room": + return + self._register("muc", "recv", callback, inc_self, priority) + + @deprecated + def register_chat_callback(self, callback, inc_self=False, priority=0): + if self._pm.place != "group": + return + self._register("chat", "recv", callback, inc_self, priority) + + @deprecated + def register_unhandled_muc_callback(self, callback, inc_self=False, priority=0): + if self._pm.place != "room": + return + self._register("muc", "unhandled", callback, inc_self, priority) + + @deprecated + def register_unhandled_chat_callback(self, callback, inc_self=False, priority=0): + if self._pm.place != "group": + return + self._register("chat", "unhandled", callback, inc_self, priority) + + register_muc_callback.__doc__ = msg_cb_doc.format("muc") + register_chat_callback.__doc__ = msg_cb_doc.format("chat") + register_unhandled_muc_callback.__doc__ = msg_cb_doc.format("unhandled muc") + register_unhandled_chat_callback.__doc__ = msg_cb_doc.format("unhandled chat") + + @deprecated + def register_muc_filter(self, callback, inc_self=False, priority=0): + if self._pm.place != "room": + return + self._register("muc", "recv_filter", callback, inc_self, priority) + + @deprecated + def register_chat_filter(self, callback, inc_self=False, priority=0): + if self._pm.place != "group": + return + self._register("chat", "recv_filter", callback, inc_self, priority) + + @deprecated + def register_muc_send_filter(self, callback, inc_self=False, priority=0): + if self._pm.place != "room": + return + self._register("muc", "send_filter", callback, inc_self, priority) + + @deprecated + def register_chat_send_filter(self, callback, inc_self=False, priority=0): + if self._pm.place != "group": + return + self._register("chat", "send_filter", callback, inc_self, priority) + + register_muc_filter.__doc__ = msg_filter_doc.format("receive", "muc") + register_chat_filter.__doc__ = msg_filter_doc.format("receive", "chat") + register_muc_send_filter.__doc__ = msg_filter_doc.format("send", "muc") + register_chat_send_filter.__doc__ = msg_filter_doc.format("send", "chat") - # Message send methods + # Plugin access methods + @deprecated + def get(self, plugin_name): + """Return a plugin-like object from the plugin module plugin_name.""" + return self.plugins.get(plugin_name) - def send_muc(self, rjid, text, priority=PRIORITY_NORMAL): + def get_dependencies(self): """ - Send a MUC message to the specified room ('rjid'), with the given text - and at the specified priority. + Return an iterable of plugins this plugin depends on. - rjid is a string representing the JID of the room to send to. - text is a string (or unicode) with the message contents. - priority specifies the priority of the message, and should be one of - the PRIORITY_* constants defined in endroid.messagehandler. Use with - care, especially PRIORITY_URGENT, which will usually bypass any - ratelimiting or other protections put in place. - """ - self._pm.send_muc(rjid, text, self, priority) + This includes indirect dependencies i.e. the dependencies of plugins this + plugin depends on and so on. - def send_chat(self, user, text, priority=PRIORITY_NORMAL): """ - Send a regular chat message to the specified room ('rjid'), with the - given text and at the specified priority. + return (self.get(dependency) for dependency in self.dependencies) - user is a string representing the JID of the user to send to. - text is a string (or unicode) with the message contents. - priority specifies the priority of the message, and should be one of - the PRIORITY_* constants defined in endroid.messagehandler. Use with - care, especially PRIORITY_URGENT, which will usually bypass any - ratelimiting or other protections put in place. + def get_preferences(self): """ - self._pm.send_chat(user, text, self, priority) + Return an iterable of plugins this plugin prefers. - # Plugin access methods + This includes indirect preferences i.e. the preferences of plugins this + plugin prefers and so on. - def get(self, plugin_name): - return self._pm.get(plugin_name) - - def get_dependencies(self): - return map(self.get, self.dependencies) + """ + return (self.get(preference) for preference in self.preferences) - def get_preferences(self): - return map(self.get, self.preferences) - + @deprecated def list_plugins(self): - return self._pm.list() - + """Return a list of all plugins loaded in the plugin's environment.""" + return self.plugins.all() + + @deprecated def pluginLoaded(self, modname): - return self._pm.hasloaded(modname) - + """Check if modname is loaded in the plugin's environment (bool).""" + return self.plugins.loaded(modname) + + @deprecated def pluginCall(self, modname, func, *args, **kwargs): + """Directly call a method on plugin modname.""" return getattr(self.get(modname), func)(*args, **kwargs) - # Default implementations of overridable methods - + # Overridable values/properties def endroid_init(self): pass + @property + def cron(self): + return Cron().get() + dependencies = () preferences = () + +class GlobalPlugin(Plugin): + def _setup(self, pm, conf): + super(GlobalPlugin, self)._setup(pm, conf) + self.messages = pm.messagehandler + self.rosters = pm.usermanagement + + class PluginProxy(object): def __init__(self, modname): - logging.info(modname) - __import__(modname) - m = sys.modules[modname] - - # In loading a plugin, we first look for a get_plugin() function, - # then a function with the same name as the module, and finally we - # just check the automatic Plugin registry for a Plugin defined in - # that module. - if hasattr(m, 'get_plugin'): - self.module = getattr(m, 'get_plugin')() - elif hasattr(m, modname.split('.')[-1]): - self.module = getattr(m, modname.split('.')[-1])() - else: - self.module = PluginMeta.registry[modname]() + self.name = modname def __getattr__(self, key): - if hasattr(self.module, key): - return getattr(self.module, key) - else: - return self - - def hasattr(self, key): - return hasattr(self.module, key) - + return self.__dict__.get(key, self) + + def __getitem__(self, idx): + return self + def __call__(self, *args, **kwargs): return self @@ -251,113 +260,183 @@ def __init__(self, value): super(ModuleNotLoadedError, self).__init__(value) self.value = value + def __str__(self): return repr(self.value) + class PluginInitError(Exception): pass class PluginManager(object): - def __init__(self, messagehandler, usermanagement, rjid=None, userlist=[]): + def __init__(self, messagehandler, usermanagement, place, name, config): self.messagehandler = messagehandler self.usermanagement = usermanagement - self._plugins = {} - self.initialised = {} - self.rjid = rjid - self.userlist = userlist - - def suc_users(self): - return self.userlist - - def register_muc_callback(self, callback, including_self=False, priority=0): - self.messagehandler.register_muc_handler(self.rjid, callback, - including_self, priority) - - def register_chat_callback(self, callback, including_self=False, priority=0): - self.messagehandler.register_chat_handler(self.userlist, callback, - including_self, priority) - - def register_unhandled_muc_callback(self, callback, including_self=False, priority=0): - self.messagehandler.register_unhandled_muc_handler(self.rjid, callback, - including_self, priority) - - def register_unhandled_chat_callback(self, callback, including_self=False, priority=0): - self.messagehandler.register_unhandled_chat_handler(self.userlist, callback, - including_self, priority) - - def register_muc_filter(self, callback, priority=0): - self.messagehandler.register_muc_recv_filter(self.rjid, callback, priority) - - def register_chat_filter(self, callback, priority=0): - self.messagehandler.register_chat_recv_filter(self.userlist, callback, - priority) - def register_muc_send_filter(self, callback, priority=0): - self.messagehandler.register_muc_send_filter(self.rjid, callback, priority) + self.place = place # For global, needs to be made to work with config + self.name = name or "*" - def register_chat_send_filter(self, callback, priority=0): - self.messagehandler.register_chat_send_filter(self.userlist, callback, - priority) - - def send_muc(self, rjid, text, source, priority=PRIORITY_NORMAL): - self.messagehandler.send_muc(rjid, text, source, priority) - - def send_chat(self, jid, text, source, priority=PRIORITY_NORMAL): - self.messagehandler.send_chat(jid, text, source, priority) - - def list(self): - return self._plugins.keys() - - def load(self, plugin, conf): - append = "" - if self.rjid: - append = " for room " + self.rjid - elif self.userlist: - append = " for users " + str(self.userlist) - logging.info("Loading Plugin: " + plugin + append) + # this is a dictionary of plugin module names to plugin objects + self._loaded = {} + # a dict of modnames : plugin configs (unused?) + self._plugin_cfg = {} + # module name to bool dictionary (use set instead?) + self._initialised = set() + + self._read_config(config) + self._load_plugins() + self._init_plugins() + + def _read_config(self, conf): + def get_data(modname): + # return a tuple of (modname, modname's config) + return modname, conf.get(self.place, self.name, "plugin", modname, default={}) + + plugins = conf.get(self.place, self.name, "plugins") + logging.debug("Found the following plugins in {}/{}: {}".format( + self.place, self.name, ", ".join(plugins))) + self._plugin_cfg = dict(map(get_data, plugins)) + + def _load(self, modname): + # loads the plugin module and adds a key to self._loaded + logging.debug("\tLoading Plugin: " + modname) try: - p = PluginProxy(plugin) - p._pluginInit(self, conf) - - self._plugins[plugin] = p + __import__(modname) except ImportError as i: logging.error(i) - logging.error("**Could Not Import Plugin \"" + plugin + "\". Check That It Exists In Your PYTHONPATH.") + logging.error("**Could Not Import Plugin \"" + modname + + "\". Check That It Exists In Your PYTHONPATH.") + return + except Exception as e: + logging.error(e) + logging.error("**Failed to import plugin {}".format(modname)) + return + else: + # dictionary mapping module names to module objects + m = sys.modules[modname] - def get(self, name): - if not name in self._plugins: - raise ModuleNotLoadedError(name) - return self._plugins[name] - - def hasloaded(self, name): - return self._plugins.has_key(name) - - def init(self, modname): - logging.debug("Initialising Plugin: " + modname) - if self.initialised.has_key(modname): - logging.debug(modname + " Already Initialised") + try: + # In loading a plugin, we first look for a get_plugin() function, + # then check the automatic Plugin registry for a Plugin defined in + # that module. + if hasattr(m, 'get_plugin'): + plugin = getattr(m, 'get_plugin')() + else: + plugin = PluginMeta.registry[modname]() + except Exception as k: + logging.error(k) + logging.error("**Could not import plugin {}. Module doesn't seem to" + "define a Plugin".format(modname)) + return + else: + plugin._setup(self, self._plugin_cfg[modname]) + self._loaded[modname] = plugin + + def _load_plugins(self): + logging.info("Loading Plugins for {0}".format(self.name)) + for p in self._plugin_cfg: + self._load(p) + + def _init_one(self, modname): + if modname in self._initialised: + logging.debug("\t{0} Already Initialised".format(modname)) return True - if not self.hasloaded(modname): - logging.error("**Cannot Initialise Plugin \"" + modname + "\", It Has Not Been Imported") + if not self.loaded(modname): + logging.error("\t**Cannot Initialise Plugin \"" + modname + "\", " + "It Has Not Been Imported") + return False + if modname in self._initialising: + logging.error("\t**Circular dependency detected. Initialising: {}" + .format(", ".join(sorted(self._initialising)))) return False + logging.debug("\tInitialising Plugin: " + modname) + + # deal with dependencies and preferences + # Dependencies are mandatory, so they must be loaded; + # Preferences are optional, so are replaced with a PluginProxy if not + # loaded. In both cases, all mentioned plugins are initialised to + # make sure they are ready before this plugin starts to load them. + + # Circular dependencies cause failures, while circular preferences are + # temporarily replaced with a Proxy to break the cycle, then replaced + # later (which means that during the init phase, they will not have + # been available so might not be correctly used). + self._initialising.add(modname) - plugin = self.get(modname) - for modname2 in plugin.dependencies: - logging.debug(modname + " Depends On " + modname2 + "...") - if not self.init(modname2): - logging.error("**No \"" + modname2 + "\". Unloading " + modname) - self._plugins.pop(modname) - return False - for modname2 in plugin.preferences: - logging.debug(modname + " Prefers To Have " + modname2 + "...") - if not self.init(modname2): - logging.error("**Could Not Load " + modname2) try: - plugin.endroid_init() - self.initialised[modname] = True - logging.info("Initialised Plugin: " + modname) - except PluginInitError as e: - logging.error("**Error initializing \"" + modname + "\". See log for details.") - return False - return True + plugin = self.get(modname) + for mod_dep_name in plugin.dependencies: + logging.debug("\t{} depends on {}".format(modname, + mod_dep_name)) + if not self._init_one(mod_dep_name): + # can't possibly initialise us so remove us from self._loaded + logging.error('\t**No "{}". Unloading {}.' + .format(mod_dep_name, modname)) + self._loaded.pop(modname) + return False + + for mod_pref_name in plugin.preferences: + logging.debug("\t{} Prefers {}".format(modname, mod_pref_name)) + if mod_pref_name in self._initialising: + logging.warning("\tDetected circular preference for {}. " + "Continuing with proxy object in place" + .format(mod_pref_name)) + self._loaded[mod_pref_name] = PluginProxy(mod_pref_name) + + elif not self._init_one(mod_pref_name): + logging.error("\t**Could Not Load {} required by {}".format( + mod_pref_name, modname)) + # Create a proxy object instead + self._loaded[mod_pref_name] = PluginProxy(mod_pref_name) + + # attempt to initialise the plugin + try: + plugin.endroid_init() + self._initialised.add(modname) + logging.info("\tInitialised Plugin: " + modname) + # Re-add this plugin to _loaded, in case it was temporarily + # replaced by a proxy + self._loaded[modname] = plugin + except Exception as e: + logging.error(e) + logging.error('\t**Error initializing "{}". See log for ' + 'details.'.format(modname)) + return False + return True + finally: + self._initialising.discard(modname) + + def _init_plugins(self): + logging.info("Initialising Plugins for {0}".format(self.name)) + # Track what we're doing to detect circular dependencies + self._initialising = set() + for p in self.all(): + self._init_one(p) + del self._initialising + + # ========================================================================= + # Public API for plugins + # + + def all(self): + """ + Return an Iterator of the names of all plugins loaded in this place. + """ + return self._loaded.keys() + get_plugins = all + + def loaded(self, name): + """Returns True if the named plugin is loaded in this place.""" + return name in self._loaded + hasLoaded = loaded + + def get(self, name): + """ + Gets the instance of the named plugin within this place. + + Raises a ModuleNotLoadedError if the plugin is not loaded. + """ + if not name in self._loaded: + raise ModuleNotLoadedError(name) + return self._loaded[name] diff -Nru endroid-1.1.2/src/endroid/plugins/blacklist.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/blacklist.py --- endroid-1.1.2/src/endroid/plugins/blacklist.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/blacklist.py 2013-09-16 14:30:23.000000000 +0000 @@ -7,7 +7,6 @@ from endroid.pluginmanager import Plugin from endroid.database import Database from endroid.cron import Cron -from endroid.config import as_list # DB constants DB_NAME = "Blacklist" @@ -26,39 +25,41 @@ help = "Maintain a blacklist of JIDs who get ignored by EnDroid." hidden = True + _blacklist = set() + def endroid_init(self): """ Initialise the plugin, and recover the blacklist from the DB. """ - self.admins = set(map(str.strip, as_list(self.vars.get("admins", "")))) - self._blacklist = set() + self.admins = set(map(str.strip, self.vars.get("admins", []))) + + self.task = self.cron.register(self.unblacklist, CRON_UNBLACKLIST) - self.cron = Cron.get().register(self.unblacklist, CRON_UNBLACKLIST) + self.messages.register(self.checklist, recv_filter=True) + self.messages.register(self.command, chat_only=True) + self.messages.register(self.checksend, send_filter=True, chat_only=True) - self.register_muc_filter(self.checklist) - self.register_chat_filter(self.checklist) - self.register_chat_callback(self.command) - self.register_chat_send_filter(self.checksend) - - self.db = Database(DB_NAME) - if not self.db.table_exists(DB_TABLE): - self.db.create_table(DB_TABLE, ("userjid",)) - for row in self.db.fetch(DB_TABLE, ("userjid",)): + if not self.database.table_exists(DB_TABLE): + self.database.create_table(DB_TABLE, ("userjid",)) + for row in self.database.fetch(DB_TABLE, ("userjid",)): self.blacklist(row["userjid"]) + + def get_blacklist(self): + return self._blacklist def checklist(self, msg): """ Receive filter callback - checks the message sender against the blacklist """ - return msg.sender.userhost() not in self._blacklist + return msg.sender not in self.get_blacklist() def checksend(self, msg): """ Send filter callback - checks the message recipient against the blacklist """ - return msg.recipient.userhost() not in self._blacklist + return msg.sender not in self.get_blacklist() def command(self, msg): """ @@ -70,13 +71,14 @@ while len(parts) < 3: parts.append(None) bl, cmd, user = parts[:3] - if msg.sender.userhost() in self.admins and bl == "blacklist": - if cmd == "add" and user not in self._blacklist: + + if msg.sender in self.admins and bl == "blacklist": + if cmd == "add" and user not in self.get_blacklist(): self.blacklist(user) - elif cmd == "remove" and user in self._blacklist: + elif cmd == "remove" and user in self.get_blacklist(): self.unblacklist(user) elif cmd == "list": - msg.reply("Blacklist: " + ", ".join(self._blacklist)) + msg.reply("Blacklist: " + ", ".join(self.get_blacklist() or ['None'])) else: msg.unhandled() else: @@ -88,15 +90,15 @@ argument is passed, the user is removed after the specified number of seconds. """ + self.database.delete(DB_TABLE, {"userjid": user}) + self.database.insert(DB_TABLE, {"userjid": user}) self._blacklist.add(user) - self.db.delete(DB_TABLE, {"userjid": user}) - self.db.insert(DB_TABLE, {"userjid": user}) if duration != 0: - self.cron.setTimeout(duration, user) + self.task.setTimeout(duration, user) def unblacklist(self, user): """ Remove the specified user from the blacklist. """ - self._blacklist.discard(user) - self.db.delete(DB_TABLE, {"userjid": user}) + self.database.delete(DB_TABLE, {"userjid": user}) + self._blacklist.remove(user) diff -Nru endroid-1.1.2/src/endroid/plugins/brainyquote.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/brainyquote.py --- endroid-1.1.2/src/endroid/plugins/brainyquote.py 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/brainyquote.py 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,26 @@ +# ----------------------------------------------------------------------------- +# EnDroid - Brainy Quote of the moment plugin +# Copyright 2013, Ensoft Ltd +# ----------------------------------------------------------------------------- + +import re +from HTMLParser import HTMLParser +from twisted.web.client import getPage +from endroid.plugins.command import CommandPlugin, command + +QURE = re.compile(r'
]*>\s*

(.*?)

.*?]*>(.*?)', + re.S) + +class BrainyQuote(CommandPlugin): + help = "Get the Quote of the Moment from brainyquote.com." + + @command(synonyms=("brainy quote", "brainyquote")) + def cmd_quote(self, msg, arg): + def extract_quote(data): + quote, author = map(str.strip, QURE.search(data).groups()) + hp = HTMLParser() + msg.reply("Quote of the moment: {} -- {}".format( + hp.unescape(quote), hp.unescape(author))) + + getPage("http://www.brainyquote.com/").addCallbacks(extract_quote, + msg.unhandled) diff -Nru endroid-1.1.2/src/endroid/plugins/broadcast.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/broadcast.py --- endroid-1.1.2/src/endroid/plugins/broadcast.py 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/broadcast.py 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,171 @@ +from endroid.plugins.command import CommandPlugin +from endroid.database import Database +import logging + + +DB_NAME = "BROADCAST" +DB_TABLE = "BROADCAST" + + +class Levels(object): + NONE = "none" + ALL = "all" + POSITIVE = "positive" + MAX = "max" + + @classmethod + def __contains__(cls, key): + return key in (cls.NONE, cls.ALL, cls.POSITIVE, cls.MAX) + + +class Broadcast(CommandPlugin): + """ + This plugin partially solves the problem with XMPP that if a user is logged + in on multiple devices (resources) then messages sent by EnDroid may not + arrive at all of them. + + When broadcasting is enabled by a user, any messages sent by EnDroid to them + will be intercepted and replaced by several identical messages sent + individually to a selection of their resources. + + Note: + - this plugin only applies to chat messages (room messages do not suffer + the same problem) + - the plugin may be configured to broadcast at levels: + - all: send to all the recipient's available resources + - positive: send to the recipient's available resources with priority >=0 + - max: send to the recipient's maximum priority resource + - none (default): disable broadcasting + + """ + help = "Plugin to enable the broadcasting of messages to all resources." + users = {} + + levels = Levels() + + ID = "broadcast_plugin" # this will be set as the source of sent messages + + def endroid_init(self): + self.register_chat_send_filter(self.do_broadcast) + self.db = Database(DB_NAME) + if not self.db.table_exists(DB_TABLE): + self.db.create_table(DB_TABLE, ('user', 'do_broadcast')) + + # make a local copy of the registration database + data = self.db.fetch(DB_TABLE, ['user', 'do_broadcast']) + for row in data: + self.users[row['user']] = row['do_broadcast'] + + def do_broadcast(self, msg): + sender = msg.sender + if sender == self.ID: # we sent this message + return True + + recipient = msg.recipient + recip_host = self.usermanagement.get_userhost(recipient) + + logging.debug("Broadcast got message {} -> {}".format(sender, recipient)) + # Check the broadcast level for recip_host (if they are not + # registered return levels.NONE). + level = self.users.get(recip_host, self.levels.NONE) + + if level == self.levels.NONE: + # we are not broadcasting to this user, let the original message + # through and do nothing + return True + else: + # we have some broadcasting to do + rs = self._get_resources(recip_host, level) + + sent_num = 0 + for resource in rs: + self.messagehandler.send_chat(resource, msg.body, self.ID) + sent_num += 1 + + fmt = "Broadcast '{}' sent {} messages to {}" + logging.debug(fmt.format(level, sent_num, recip_host)) + # drop the original message + return False + + + def cmd_set_broadcast(self, msg, arg): + """ + When this is called, messages EnDroid sends will be sent to _all_ + the user's available resources. + + """ + level = arg.split()[0] + level = level if level in self.levels else self.levels.ALL + # this will be broadcasted + msg.reply("Setting broadcast level '{}'.".format(level)) + if not msg.sender in self.users: + self._register_user(msg.sender, level=level) + else: + self._update_user(msg.sender, level=level) + # this may not be broadcasted, depending on what level has been set to + msg.reply("Set broadcast level '{}'.".format(level)) + + cmd_set_broadcast.helphint = ("{all|positive|max|none} (all, positive, max " + "process resource priorities.") + + + def cmd_disable_broadcast(self, msg, arg): + """Disable broadcasting.""" + self.cmd_set_broadcast(msg, self.levels.NONE) + # msg.reply("Disabling broadcast.") + # if self.users[msg.sender]: + # self._update_user(msg.sender, level=self.levels.NONE) + # msg.reply("Disabled broadcast.") + + cmd_disable_broadcast.helphint = ("Equivalent to 'set broadcast none'.") + + def cmd_get_broadcast(self, msg, arg): + """Get broadcast level.""" + level = self.users.get(msg.sender, self.levels.NONE) + message = "Broadcast level '{}'".format(level) + if arg and arg.split()[0] in ("ls", "list"): + rs = self._get_resources(msg.sender, level) + message += ':' + msg.reply('\n'.join([message] + (rs or ["none"]))) + else: + message += '.' + msg.reply(message) + + cmd_get_broadcast.helphint = ("{ls|list}?") + + def cmd_get_resources(self, msg, arg): + """Return msg.sender's available resources. + + If arg is "broadcast", return those we are broadcasting to. + + """ + message = ["Available resources:"] + rs = self._get_resources(msg.sender) + msg.reply('\n'.join(message + (rs or ["none"]))) + + cmd_get_resources.helphint = ("Report all available resources.") + + def _register_user(self, user, level=levels.NONE): + self.db.insert(DB_TABLE, {'user' : user, 'do_broadcast' : level}) + self.users[user] = level + + def _update_user(self, user, level=levels.NONE): + self.db.update(DB_TABLE, {'do_broadcast' : level}, {'user' : user}) + self.users[user] = level + + def _get_resources(self, user, level=levels.ALL): + resources = self.usermanagement.resources(user) + addresses = [] + + if level == self.levels.ALL: + addresses = resources.keys() + elif level == self.levels.POSITIVE: + addresses = [j for j, r in resources.items() if r.priority >= 0] + elif level == self.levels.MAX: + # resources.items is a list of tuples of form: (jid : Resource) + # sort on Resource.priority + # this returns the tuple (jid : max_priority_resource) + # get just the jid by indexing with [0] + addresses = [max(resources.items(), key=lambda (j,r): r.priority)[0]] + + return addresses diff -Nru endroid-1.1.2/src/endroid/plugins/command.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/command.py --- endroid-1.1.2/src/endroid/plugins/command.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/command.py 2013-09-16 14:30:23.000000000 +0000 @@ -4,13 +4,13 @@ # Created by Jonathan Millican # ----------------------------------------- -import logging - +import functools from collections import namedtuple from endroid.pluginmanager import Plugin, PluginMeta __all__ = ( 'CommandPlugin', + 'command', ) # A single registration @@ -18,22 +18,113 @@ # - command is the original registered command (a str, or a tuple of strs) # - helphint is any additional help hint provided at registration time # - if hidden is True, then the command is not included in help output +# - plugin is the plugin that made the registration Registration = namedtuple('Registration', ('callback', 'command', 'helphint', - 'hidden')) + 'hidden', 'plugin')) # A set of handlers # - handlers is a list of the Registration objects for this subcommand # - subcommands is a dict of subcommand (simple str) to Handlers object Handlers = namedtuple('Handlers', ('handlers', 'subcommands')) +def command(wrapped=None, synonyms=(), helphint="", hidden=False, + chat_only=False, muc_only=False): + """ + Decorator used to mark command functions in subclasses of CommandPlugin. + + In it's simplest form, can be used to mark a function as a command: + + >>> class Foo(CommandPlugin): + ... @command + ... def foo_bar(self, msg, args): + ... do_stuff_here() + + This will register the command string ("foo", "bar") with the Command plugin + when the Foo plugin is initialised. + + The decorator also takes a series of optional keyword arguments to control + the behaviour of the registration: + + synonyms: sequence of synonyms (either strings, or sequences of strings) + helphint: a helpful hint to display in help for the command + hidden: whether the command is hidden in the help + chat_only: set to True if the command should only be registered in chat + muc_only: set to True if the command should only be registered in muc + + """ + def decorator(fn): + fn.is_command = True + fn.synonyms = synonyms + fn.helphint = helphint + fn.hidden = hidden + fn.chat_only = chat_only + fn.muc_only = muc_only + return fn + if wrapped is None: + return decorator + else: + return decorator(wrapped) + + +class _Topics(object): + """ + Descriptor to handle auto-updating of the help_topics. + + This is required if a plugin doesn't just define its help_topics at the + class level, and instead sets it during its initialisation. This descriptor + will update the help_topics instead of replacing them when set. + """ + def __get__(self, obj, type=None): + """ + As well as fetching the help_topics (from the _help_topics field), this + also injects the 'commands' entry into topics. It is done here, as this + is the first point at which we have an instance of the plugin (needed + to do the filtering later when displaying the help). + + Moral of the story: injecting methods is awkward. + """ + def _commands_help(topic): + com = obj.get('endroid.plugins.command') + return com._help_main(topic, plugin=obj) + if not 'commands' in obj._help_topics: + # This will call the __set__, below + setattr(obj, 'help_topics', {'commands': _commands_help}) + return obj._help_topics + + def __set__(self, obj, value): + """ + Takes a copy of the existing topics, updates the dict with the given + value, then sets it on the instance. + + Note that if someone attempts to set them on the class after class + creation, this will raise an exception. Which seems the sanest thing to + do, as updating the class topics after the fact is an odd thing to do. + """ + # We need to take a copy in case these are the class topics + topics = obj._help_topics.copy() + topics.update(value) + obj._help_topics = topics + +# We only ever need one instance of this class +_Topics = _Topics() + + class CommandPluginMeta(PluginMeta): """ Metaclass to support simple command-driven plugins. This should not be used directly, but rather by subclassing from CommandPlugin rather than Plugin. """ def __new__(meta, name, bases, dct): - cmds = dict((c, f) for c, f in dct.items() if c.startswith("cmd_")) + # We can also always add a default name here, so command can always + # assume there is one. + if 'name' not in dct: + dct['name'] = name.lower() + + cmds = dict((c, f) for c, f in dct.items() + if c.startswith("cmd_") or getattr(f, "is_command", False)) init = dct.get('endroid_init', lambda _: None) + # Make sure the new init function looks like the old one + @functools.wraps(init) def endroid_init(self): com = self.get('endroid.plugins.command') for cmd, fn in cmds.items(): @@ -43,25 +134,42 @@ reg_fn = com.register_muc else: reg_fn = com.register_both - reg_fn(getattr(self, cmd), cmd.split("_")[1:], + words = cmd.split("_") + # Handle a leading underscore (may be necessary in some cases) + if not words[0]: + words = words[1:] + if not getattr(fn, "is_command", False): + words = words[1:] + reg_fn(getattr(self, cmd), words, helphint=getattr(fn, "helphint", ""), hidden=getattr(fn, "hidden", False), - synonyms=getattr(fn, "synonyms", ())) + synonyms=getattr(fn, "synonyms", ()), + plugin=self) init(self) dct['endroid_init'] = endroid_init + + # We replace any class topics with the Descriptor, and always set at + # least an empty dict in the private _help_topics field + topics = dct.get('help_topics', {}) + dct['_help_topics'] = topics + dct['help_topics'] = _Topics + dct['dependencies'] = tuple(dct.get('dependencies', ()) + ('endroid.plugins.command',)) - return PluginMeta.__new__(meta, name, bases, dct) + return super(CommandPluginMeta, meta).__new__(meta, name, bases, dct) class CommandPlugin(Plugin): """ Parent class for simple command-driven plugins. Such plugins don't need to explicitly register their commands. Instead, they can just define methods - prefixed with "cmd_" and they will automatically be registered. Any - additional underscores in the method name will be converted to spaces in - the registration (so cmd_foo_bar is registered as ('foo', 'bar')). + prefixed with "cmd_" or decorated with the 'command' decorator, and they + will automatically be registered. Any additional underscores in the method + name will be converted to spaces in the registration (so cmd_foo_bar is + registered as ('foo', 'bar')). + + In addition, certain options can be passed by adding fields to the methods, + or as keyword arguments to the decorator: - In addition, certain options can be passed by adding fields to the methods: - hidden: don't show the command in help if set to True. - synonyms: an iterable of alternative keyword sequences to register the method against. All synonyms are hidden. @@ -83,52 +191,53 @@ def endroid_init(self): self._muc_handlers = Handlers([], {}) self._chat_handlers = Handlers([], {}) - self.register_muc_callback(self.command_muc) - self.register_chat_callback(self.command_chat) + self.messages.register(self._command_muc, muc_only=True) + self.messages.register(self._command_chat, chat_only=True) self.help_topics = { - '': self.help_main, - 'chat': self.help_chat, - 'muc': self.help_muc, + '': self._help_main, + 'chat': self._help_chat, + 'muc': self._help_muc, } # ------------------------------------------------------------------------- # Help methods - def help_add_regs(self, output, handlers): + def _help_add_regs(self, output, handlers, plugin=None): """ Add lines of help strings to the output list for each handler in the given Handlers object. Then recurses down all subcommands to get their help strings too. """ for reg in handlers.handlers: - if not reg.hidden: + if not reg.hidden and (plugin is None or plugin is reg.plugin): output.append(" %s %s" % (reg.command, reg.helphint)) for _, hdlrs in sorted(handlers.subcommands.items()): - self.help_add_regs(output, hdlrs) + self._help_add_regs(output, hdlrs, plugin) - def help_main(self, topic): + def _help_main(self, topic, plugin=None): assert not topic - out = ["Commands known to me:"] - chat = self.help_chat(topic) + out = ["Commands known to {}:" + .format("me" if plugin is None else plugin.name)] + chat = self._help_chat(topic, plugin=plugin) if chat: out.extend(["", chat]) - muc = self.help_muc(topic) + muc = self._help_muc(topic, plugin=plugin) if muc: out.extend(["", muc]) return "\n".join(out) - def help_chat(self, topic): + def _help_chat(self, topic, plugin=None): parts = [] - self.help_add_regs(parts, self._chat_handlers) + self._help_add_regs(parts, self._chat_handlers, plugin=plugin) if parts: return "\n".join(["Commands in Chat:"] + parts) else: return "No command registered in chat." - def help_muc(self, topic): + def _help_muc(self, topic, plugin=None): parts = [] - self.help_add_regs(parts, self._muc_handlers) + self._help_add_regs(parts, self._muc_handlers, plugin=plugin) if parts: return "\n".join(["Commands in MUC:"] + parts) else: @@ -137,7 +246,7 @@ # ------------------------------------------------------------------------- # Command handling methods - def command(self, handlers, args, msg): + def _command(self, handlers, args, msg): """ Handle an incoming message using the given handlers; args is the current remaining message string; msg is the full Message object. @@ -145,22 +254,22 @@ All handlers for the current command are called after first recursing down to any subcommands that match. """ - com, arg = self.command_split(args) + com, arg = self._command_split(args) if com in handlers.subcommands: msg.inc_handlers() - self.command(handlers.subcommands[com], arg, msg) + self._command(handlers.subcommands[com], arg, msg) for handler in handlers.handlers: msg.inc_handlers() handler.callback(msg, args) msg.dec_handlers() - def command_muc(self, msg): - self.command(self._muc_handlers, msg.body, msg) + def _command_muc(self, msg): + self._command(self._muc_handlers, msg.body, msg) - def command_chat(self, msg): - self.command(self._chat_handlers, msg.body, msg) + def _command_chat(self, msg): + self._command(self._chat_handlers, msg.body, msg) - def command_split(self, text): + def _command_split(self, text): num = text.count(' ') if num == 0: return (text.lower(), '') @@ -171,19 +280,22 @@ # ------------------------------------------------------------------------- # Registration methods - def register_handler(self, callback, cmd, helphint, hidden, handlers, - synonyms=()): + def _register_handler(self, callback, cmd, helphint, hidden, handlers, + synonyms=(), plugin=None): """ Register a new handler. callback is the callback handle to call. command is either a single keyword, or a sequence of keywords to match. - helphint and hidden are argument to the Registration object. + helphint and hidden are arguments to the Registration object. handlers are the top-level handlers to add the registration to. + plugin is the plugin that made the registration, used to provide + automatic 'commands' help for the plugin. """ # Register any synonyms (done before we frig with the handlers) for entry in synonyms: - self.register_handler(callback, entry, helphint, True, handlers) + self._register_handler(callback, entry, helphint, True, handlers, + plugin=plugin) # Allow simple commands to be passed as strings cmd = cmd.split() if isinstance(cmd, (str, unicode)) else cmd @@ -191,22 +303,24 @@ for part in cmd: handlers = handlers.subcommands.setdefault(part, Handlers([], {})) handlers.handlers.append(Registration(callback, " ".join(cmd), - helphint, hidden)) + helphint, hidden, plugin)) def register_muc(self, callback, command, helphint="", hidden=False, - synonyms=()): + synonyms=(), plugin=None): """Register a new handler for MUC messages.""" - self.register_handler(callback, command, helphint, hidden, - self._muc_handlers, synonyms) + self._register_handler(callback, command, helphint, hidden, + self._muc_handlers, synonyms, plugin) def register_chat(self, callback, command, helphint="", hidden=False, - synonyms=()): + synonyms=(), plugin=None): """Register a new handler for chat messages.""" - self.register_handler(callback, command, helphint, hidden, - self._chat_handlers, synonyms) + self._register_handler(callback, command, helphint, hidden, + self._chat_handlers, synonyms, plugin) def register_both(self, callback, command, helphint="", hidden=False, - synonyms=()): + synonyms=(), plugin=None): """Register a handler for both MUC and chat messages.""" - self.register_muc(callback, command, helphint, hidden, synonyms) - self.register_chat(callback, command, helphint, hidden, synonyms) + self.register_muc(callback, command, helphint, hidden, synonyms, + plugin) + self.register_chat(callback, command, helphint, hidden, synonyms, + plugin) diff -Nru endroid-1.1.2/src/endroid/plugins/compute/__init__.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/compute/__init__.py --- endroid-1.1.2/src/endroid/plugins/compute/__init__.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/compute/__init__.py 2013-09-16 14:30:23.000000000 +0000 @@ -14,9 +14,10 @@ from twisted.web.client import getPage import logging import wap +import urllib # Constants for the DB values we use -WOLFRAM_API_SERVER_DEFAULT = "http://api.wolframalpha.com/v1/query.jsp" +WOLFRAM_API_SERVER_DEFAULT = "http://api.wolframalpha.com/v2/query" MESSAGES = { 'help' : """ @@ -44,7 +45,7 @@ self.waeo = wap.WolframAlphaEngine(self.api_key, self.server) except KeyError: logging.error("ERROR: Compute: There is no API key set in config! Set 'api_key' variable" - "in section '[Plugin:endroid.plugins.compute]'. Aborting plugin load.") + " in section '[Plugin:endroid.plugins.compute]'. Aborting plugin load.") raise PluginInitError("Aborted plugin initialization") def help(self): @@ -96,7 +97,7 @@ if not args: msg.reply(MESSAGES['help']) return - query = self.waeo.CreateQuery(' '.join(args)) + query = self.waeo.CreateQuery(urllib.quote_plus(' '.join(args))) # Set up Twisted to asyncronously download page d = getPage(self.waeo.server,method='POST',postdata=str(query), diff -Nru endroid-1.1.2/src/endroid/plugins/coolit.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/coolit.py --- endroid-1.1.2/src/endroid/plugins/coolit.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/coolit.py 2013-09-16 14:30:23.000000000 +0000 @@ -3,13 +3,12 @@ # Copyright 2012, Ensoft Ltd # ----------------------------------------------------------------------------- -from endroid.plugins.command import CommandPlugin +from endroid.plugins.command import CommandPlugin, command class CoolIt(CommandPlugin): help = "I'm a robot. I'm not a refrigerator." hidden = True - def cmd_coolit(self, msg, args): + @command(synonyms=('cool it', 'freeze'), hidden=True) + def coolit(self, msg, args): msg.reply(self.help) - cmd_coolit.hidden = True - cmd_coolit.synonyms = ('cool it',) diff -Nru endroid-1.1.2/src/endroid/plugins/correct.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/correct.py --- endroid-1.1.2/src/endroid/plugins/correct.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/correct.py 2013-09-16 14:30:23.000000000 +0000 @@ -8,9 +8,11 @@ from endroid.pluginmanager import Plugin -REGEX = "^s(?P[^a-zA-Z0-9])(.+?)(?P=sep)(.*?)(?P=sep)([gi]*)(?:\s.*)?$" +REGEX = r"^s(?P[^a-zA-Z0-9\\])((?:[^\\]|\\.)+)(?P=sep)((?:[^\\]|\\.)*)(?P=sep)(g?i?|ig)(?:\s.*)?$" REOBJ = re.compile(REGEX) +ERROR = "It looks like you tried to correct yourself, but I couldn't parse it! " + class Correct(Plugin): """ Correction plugin handles sed-style regular expressions, and corrects the @@ -21,8 +23,7 @@ def endroid_init(self): self.lastmsg = {} - self.register_chat_callback(self.heard) - self.register_muc_callback(self.heard) + self.messages.register(self.heard) def heard(self, msg): """ @@ -49,14 +50,23 @@ flags are supported: i for case-insensitive, and g for global replacements. """ + if r'\0' in match.group(3): + return msg.reply_to_sender(ERROR + r"'\0' not valid group reference." + + " Indices start at one.") + opts = match.group(4) if match.group(4) else "" count = 0 if "g" in opts else 1 flags = "(?i)" if "i" in opts else "" - newstr = re.sub(flags + match.group(2), match.group(3), body, count) + + try: + newstr = re.sub(flags + match.group(2), match.group(3), body, count) + except Exception as e: + return msg.reply_to_sender(ERROR + str(e)) if newstr == body: # This is unexpected. Probably a mistake on the user's part? msg.unhandled() else: - who = msg.sendernick if msg.sendernick else "You" + sendernick = self.rosters.nickname(msg.sender_full) + who = sendernick if self.place == "room" else "You" msg.reply("%s meant: %s" % (who, newstr)) diff -Nru endroid-1.1.2/src/endroid/plugins/echobot.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/echobot.py --- endroid-1.1.2/src/endroid/plugins/echobot.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/echobot.py 2013-09-16 14:30:23.000000000 +0000 @@ -7,7 +7,7 @@ from endroid.pluginmanager import Plugin class EchoBot(Plugin): - def endroid_plugin(self): + def endroid_init(self): self.register_chat_callback(self.do_echo) self.register_muc_callback(self.do_echo) diff -Nru endroid-1.1.2/src/endroid/plugins/exec.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/exec.py --- endroid-1.1.2/src/endroid/plugins/exec.py 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/exec.py 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,64 @@ +# ----------------------------------------------------------------------------- +# EnDroid - Exec process plugin +# Copyright 2013, Ensoft Ltd +# Created by Simon C +# ----------------------------------------------------------------------------- + +import re +from twisted.internet.utils import getProcessOutput +from endroid.pluginmanager import Plugin + +class Exec(Plugin): + name = "exec" + help = "Execute a command and display the output" + + def endroid_init(self): + """ + Go through each command, and compile a regexp for all the possible + ways to invoke it. We want any expensive string processing to be done + by the regexp engine, not Python. + """ + self.regexp_map = [] + for cmd_spec in self.vars.values(): + invocation = cmd_spec[0] + help_str = cmd_spec[1] + cmds = cmd_spec[2:] + regexp_bits = [] + self.help += '\n\n{}:'.format(help_str) + for cmd in cmds: + regexp_bits.append(r'(?:(?:(?:hey\s+)?endroid[,!:\s\?]*)?' + + '(?:' + cmd + ')' + + r'[,\s]*(?:please[,\s]*)?[\?!]*(?:endroid)?[\s\?!]*$)') + self.help += '\n - ' + cmd + regexp = re.compile('|'.join(regexp_bits), re.IGNORECASE) + self.regexp_map.append((regexp, invocation)) + self.messages.register(self.heard) + + def heard(self, msg): + """ + See if any of our commands match, and execute the process if so. + """ + for regexp, invocation in self.regexp_map: + if regexp.match(msg.body): + self.invoke(invocation, msg) + break + else: + msg.unhandled() + + def invoke(self, invocation, msg): + """ + Spawn a process and send the output + """ + def failure(result): + # twisted voodoo to try to guess at the interesting bit of a failure + try: + failure_summary = result.value[0].split('\\n')[-2] + assert len(failure_summary) > 0 + except Exception: + failure_summary = str(result.value) + msg.reply("I have this terrible pain in all the diodes down my " + "left hand side, trying to execute {}: {}". + format(invocation, failure_summary)) + d = getProcessOutput('/bin/sh', ('-c', invocation)) + d.addCallbacks(lambda result: msg.reply(result.rstrip()), failure) + diff -Nru endroid-1.1.2/src/endroid/plugins/help.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/help.py --- endroid-1.1.2/src/endroid/plugins/help.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/help.py 2013-09-16 14:30:23.000000000 +0000 @@ -3,9 +3,12 @@ # Copyright 2012, Ensoft Ltd # ----------------------------------------------------------------------------- -from endroid.plugins.command import CommandPlugin +import functools +from endroid.plugins.command import CommandPlugin, command class Help(CommandPlugin): + name = "help" + def endroid_init(self): self.help_topics = { '': self.show_help_main, @@ -18,13 +21,50 @@ self.load_plugin_list() def load_plugin_list(self): - self.plugins = {} - for fullname in self.list_plugins(): - plugin = self.get(fullname) + """ + Loads all plugins and stores a short name to plugin mapping. It also + updates the plugins to simplify later calls to get the help. + + Currently this means handling a help method or string, and updating + (or creating, as necessary) the help_topics with a '*' entry. This + means it's possible to just handle help_topics later when help is + requested. + """ + + def help_helper(self, name, topic): + out = [] + try: + out.append(self.help(topic)) + except TypeError: + if topic: + out.append("(Plugin {} doesn't support topic-based help)" + .format(name)) + if callable(self.help): + out.append(self.help()) + else: + out.append(str(self.help)) + return "\n".join(out) + + self._plugins = {} + for fullname in self.plugins.all(): + plugin = self.plugins.get(fullname) name = getattr(plugin, "name", fullname) - self.plugins[name] = (fullname, plugin) + self._plugins[name] = (fullname, plugin) - def cmd_help(self, msg, args): + # Do a little jiggery pokery to simplify any requests for help + # Put a "help" method or string in as a '*' topic handler (unless) + # there is already a '*' handler). If there's neither a help method + # nor help_topics dictionary, then it is just left alone. + topics = getattr(plugin, "help_topics", {}) + + if not '*' in topics and hasattr(plugin, "help"): + topics['*'] = functools.partial(help_helper, plugin, name) + + if topics: + plugin.help_topics = topics + + @command + def _help(self, msg, args): msg.reply_to_sender(self.show_help_plugin("help", args)) def show_help_main(self, topic): @@ -43,7 +83,7 @@ else: out = [] out.append("Currently loaded plugins:") - for name, (_, plug) in sorted(self.plugins.items()): + for name, (_, plug) in sorted(self._plugins.items()): if not getattr(plug, "hidden", False): out.append(" {0}".format(name)) return "\n".join(out) @@ -53,21 +93,10 @@ def show_help_plugin(self, name, topic=''): out = [] - fullname, plugin = self.plugins.get(name, (name, None)) - if self.pluginLoaded(fullname): - # First check if it has a simple "help" property or method - if plugin.hasattr("help"): - try: - out.append(plugin.help(topic)) - except TypeError: - if topic: - out.append("(Plugin doesn't support topic help)") - if callable(plugin.help): - out.append(plugin.help()) - else: - out.append(str(plugin.help)) - - elif plugin.hasattr("help_topics"): + fullname, plugin = self._plugins.get(name, (name, None)) + if self.plugins.loaded(fullname): + # First check for help_topics + if hasattr(plugin, "help_topics"): # Check if it is a "help_topics" dictionary, mapping topic # (first keyword) to handler function keywords = topic.strip().split(' ', 1) @@ -78,11 +107,12 @@ elif '*' in plugin.help_topics: out.append(plugin.help_topics['*'](' '.join(keywords))) else: - out.append("Unknown topic '{0}' for plugin {1}".format(topic, - name)) + out.append("Unknown topic '{}' for plugin {}".format(topic, + name)) + else: - out.append("Plugin '{0}' provides no help".format(name)) + out.append("Plugin '{}' provides no help".format(name)) else: - out.append("Plugin '{0}' is not loaded".format(name)) + out.append("Plugin '{}' is not loaded".format(name)) return '\n'.join(out) diff -Nru endroid-1.1.2/src/endroid/plugins/hi5.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/hi5.py --- endroid-1.1.2/src/endroid/plugins/hi5.py 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/hi5.py 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,192 @@ +# Endroid - XMPP Bot +# Copyright 2012, Ensoft Ltd. +# Created by Jonathan Millican, and mangled beyond recognition by SimonC + +import logging, re, time +from twisted.internet import defer, error, protocol, reactor +from endroid.plugins.command import CommandPlugin, command + +HI5_TABLE = 'hi5s' + +class FilterProtocol(protocol.ProcessProtocol): + """ + Send some data to a process via its stdin, close it, and reap the output + from its stdout. Twisted arguably ought to provide a utility function for + this - there's nothing specific to GPG or high-fiving here. + """ + def __init__(self, in_data, deferred, args): + self.in_data = in_data + self.deferred = deferred + self.args = args + self.out_data= '' + + def connectionMade(self): + self.transport.write(self.in_data.encode('utf-8')) + self.transport.closeStdin() + + def outReceived(self, out_data): + self.out_data += out_data.decode('utf-8') + + def outConnectionLost(self): + d, self.deferred = self.deferred, None + d.callback(self.out_data) + + def processEnded(self, reason): + if isinstance(reason.value, error.ProcessTerminated): + logging.error("Problem running filter ({}): {}". + format(self.args, reason.value)) + d, self.deferred = self.deferred, None + d.errback(reason.value) + +class Hi5(CommandPlugin): + """ + The Hi5 plugin lets you send anonymous 'High Five!' messages to other + users known to Endroid. The idea is that it makes it easy to send a + compliment to somebody. The most basic usage is: + + hi5 user@example.com Nice presentation dude! + + which, if user@example.com is known to EnDroid and currently logged in, + sends both a unicast chat to that user with the anonymous compliment, + and also anonymously announces to one or more configured chatrooms that + user@example.com got the High Five 'Nice presentation dude!'. + + Slightly more complicated examples: it's possible to send a compliment + to multiple users at once, by using comma-separation, and also omit + the domain part of the JID if the user is unambiguous given all the + users known to EnDroid. So for example: + + hi5 bilbo@baggins.org, frodo, sam Good work with the Nazgul guys :-) + + There is some basic anonymous logging performed by default, that includes + only the time/date and recipient JID. However, if you configure a GPG + public key, then an asymmetrically encrypted log that also includes the + sender and message is done. That provides a last-resort mechanism should + someone use the mechanism for poisonous purposes, but requires the private + key and passphrase. The 'spelunk_hi5' script can be used for this. + """ + + name = "hi5" + help = ("Send anonymous 'hi5s' to folks! Your message is sent from EnDroid" + " direct to the recipient, as well as being broadcast in any " + "configured public chat rooms.") + + def endroid_init(self): + if 'gpg' in self.vars: + self.gpg = ('/usr/bin/gpg', '--encrypt', '--armor', + '--batch', '--trust-model', 'always', + '--keyring', self.vars['gpg'][0], + '--recipient', self.vars['gpg'][1]) + else: + self.gpg = None + if not self.database.table_exists(HI5_TABLE): + self.database.create_table(HI5_TABLE, ['jids', 'date', 'encrypted']) + + @command(helphint="{user}[,{user}] {message}") + def hi5(self, msg, arg): + # Parse the request + try: + jids, text = self._parse(arg) + assert len(text) > 0 + except Exception: + msg.reply("Sorry, couldn't spot a message to send in there. Use " + "something like:\n" + "hi5 frodo@shire.org, sam, bilbo@rivendell Nice job!") + return + + # Sanity checks, and also expand out 'user' to 'user@host' + # if we can do so unambiguously + fulljids = [] + for jid in jids: + if '/' in jid: + msg.reply("Recipients can't contain '/'s".format(jid)) + return + fulljid = self._get_fulljid(jid) + if not fulljid: + msg.reply("{0} is not a currently online valid receipient. " + "Sorry.".format(jid)) + return + elif fulljid == msg.sender: + msg.reply("You really don't have to resort to complimenting " + "yourself. I already think you're great") + return + fulljids.append(fulljid) + + # Do it + self._do_hi5(jids, fulljids, text, msg) + + def _do_hi5(self, jids, fulljids, text, msg): + """ + Actually send the hi5 + """ + # jidlist is a nice human-readable representation of the lucky + # receipients, like 'Tom, Dick & Harry' + if len(jids) > 1: + jidlist = ', '.join(jids[:-1]) + ' & ' + jids[-1] + else: + jidlist = jids[0] + + msg.reply('A High Five "' + text + '" is being sent to ' + jidlist) + self._log_hi5(','.join(fulljids), msg.sender, text) + + for jid in fulljids: + self.messagehandler.send_chat(jid, "You've been sent an anonymous " + "High Five: " + text) + for group in self.vars.get('broadcast', []): + groupmsg = '{} {} been sent an anonymous High Five: {}'.format( + jidlist, 'have' if len(jids) > 1 else 'has', text) + self.messagehandler.send_muc(group, groupmsg) + + @staticmethod + def _parse(msg): + """ + Parse something like ' bilbo@baggins.org, frodo, sam great job! ' into + (['bilbo@baggsins.org', 'frodo', 'sam'], 'great job!') + """ + jids = [] + msg = msg.strip() + while True: + m = re.match(r'([^ ,]+)( *,? *)(.*)', msg) + jid, sep, rest = m.groups(1) + jids.append(jid) + if sep.strip(): + msg = rest + else: + return jids, rest + + def _get_fulljid(self, jid): + """ + Expand a simple 'user' JID to 'user@host' if we can do so unambiguously + """ + users = self.usermanagement.get_available_users() + if jid in users: + return jid + expansions = [] + for user in users: + if user.startswith(jid + '@'): + expansions.append(user) + if len(expansions) == 1: + return expansions[0] + + def _log_hi5(self, jidlist, sender, text): + """ + Log the hi5. This either means spawning a GPG process to do an + asymmetric encryption of the whole thing, or just writing a basic + summary straight away. + """ + now = time.ctime() + def db_insert(encrypted): + self.database.insert(HI5_TABLE, {'jids':jidlist, + 'date':now, 'encrypted': encrypted}) + def db_insert_err(err): + logging.error("Error with hi5 database entry: {}".format(err)) + db_insert(None) + if self.gpg: + d = defer.Deferred() + d.addCallbacks(db_insert, db_insert_err) + gpg_log = '{}: {} -> {}: {}'.format(now, sender, jidlist, text) + fp = FilterProtocol(gpg_log, d, self.gpg) + reactor.spawnProcess(fp, self.gpg[0], self.gpg, {}) + else: + db_insert(None) + diff -Nru endroid-1.1.2/src/endroid/plugins/httpinterface.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/httpinterface.py --- endroid-1.1.2/src/endroid/plugins/httpinterface.py 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/httpinterface.py 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,203 @@ +# ----------------------------------------------------------------------------- +# EnDroid - HTTP Interface +# Copyright 2012, Ensoft Ltd +# ----------------------------------------------------------------------------- + +import collections +import re +import logging + +from endroid.pluginmanager import Plugin +from twisted.internet import reactor +from twisted.web.resource import Resource +from twisted.web.server import Site +from twisted.web.static import File + +DEFAULT_HTTP_PORT = 8880 +DEFAULT_HTTP_INTERFACE = '127.0.0.1' +DEFAULT_MEDIA_DIR = "/usr/share/endroid/media/" + +NOT_FOUND_TEMPLATE = """ + + Handler not found + +

Handler not found

+ Handler not found: %s + + +""" + +INDEX_TEMPLATE = """ + + EnDroid + +

EnDroid HTTP interface

+ +
    +{} +
+ +""" + +class HandlerNotFoundError(Exception): + pass + +class IndexPage(Resource): + """ + Only registered resource. Route requests to callbacks. + """ + + def __init__(self, interface): + Resource.__init__(self) + self.interface = interface + + def getChild(self, name, request): + if name == "_media": + return self.interface._media + else: + return self + + def _render(self, request): + try: + callback = self.interface.lookup_handler(request.path) + except HandlerNotFoundError as e: + page = NOT_FOUND_TEMPLATE % e + else: + page = callback(request) + + return page + + render_GET = _render + render_POST = _render + + +class HTTPInterfaceSingleton(object): + """ + Start a webserver, allow callback registrations on URL paths, and route + requests to callbacks. + """ + + def __init__(self): + self.registrations = collections.defaultdict(list) + + def _index(self, request): + return INDEX_TEMPLATE.format("\n".join("
  • {0}
  • " + .format(name) + for name in self.registrations)) + + def lookup_handler(self, path): + """" + Search for a callback function registered to handle the given path. + + Raises HandlerNotFoundError if no callback has been registered on the + given path. + """ + + assert path[0] == '/' + path = path[1:] + + spl = path.split('/', 1) + if len(spl) != 2 and not spl[0]: + return self._index + if len(spl) != 2: + raise HandlerNotFoundError("Path only has one component") + plugin_name, sub_path = spl + + if plugin_name not in self.registrations: + raise HandlerNotFoundError("Plugin %s has no registered paths" % + plugin_name) + + for regex, cb in self.registrations[plugin_name]: + if regex.match(sub_path): + callback = cb + break + else: + raise HandlerNotFoundError("Plugin %s is not registered on %s" % + (plugin_name, sub_path)) + + return callback + + def register_regex_path(self, plugin, callback, path_regex): + """ + See HTTPInterface.register_regex_path() + """ + if isinstance(path_regex, str): + path_regex = re.compile(path_regex) + + self.registrations[plugin.name].append((path_regex, callback)) + + def register_path(self, plugin, callback, path_prefix=''): + """ + See HTTPInterface.register_path() + """ + if not path_prefix: + re_src = r".*" + else: + if path_prefix[0] == '/': + raise ValueError("Prefix %s begins with a slash" % path_prefix) + if path_prefix[-1] == '/': + raise ValueError("Prefix %s ends with a slash" % path_prefix) + re_src = re.escape(path_prefix) + r"(/.*)?" + self.register_regex_path(plugin, callback, re_src) + + def register_resource(self, plugin, resource): + self.root.putChild(plugin.name, resource) + + def endroid_init(self, port, interface, media_dir): + self.root = IndexPage(self) + logging.info("Publishing static files from {}".format(media_dir)) + self._media = File(media_dir) + self.root.putChild("_media", self._media) + factory = Site(self.root) + reactor.listenTCP(port, factory, interface=interface) + +class HTTPInterface(Plugin): + """ + The actual plugin class. This may be instantiated multiple times, but is + just a wrapper around a HTTPInterfaceSingleton object. + """ + + object = None + enInited = False + name = "httpinterface" + hidden = True + + def __init__(self): + if HTTPInterface.object == None: + HTTPInterface.object = HTTPInterfaceSingleton() + + def register_regex_path(self, plugin, callback, path_regex): + """ + Register a callback to be called for requests whose URI matches: + http:////[?] + + Callback arguments: + request: A twisted.web.http.Request object. + """ + + HTTPInterface.object.register_regex_path(plugin, callback, path_regex) + + def register_path(self, plugin, callback, path_prefix): + """ + Register a callback to be called for requests whose URI matches: + http:////[?] + + Or: + http://///[?] + + Or if the prefix is the empty string: + http:////[?] + """ + + HTTPInterface.object.register_path(plugin, callback, path_prefix) + + def register_resource(self, plugin, resource): + HTTPInterface.object.register_resource(self, plugin, resource) + + def endroid_init(self): + if not HTTPInterface.enInited: + port = self.vars.get("port", DEFAULT_HTTP_PORT) + interface = self.vars.get("interface", DEFAULT_HTTP_INTERFACE) + media_dir = self.vars.get("media_dir", DEFAULT_MEDIA_DIR) + HTTPInterface.object.endroid_init(port, interface, media_dir) + HTTPInterface.enInited = True diff -Nru endroid-1.1.2/src/endroid/plugins/invite.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/invite.py --- endroid-1.1.2/src/endroid/plugins/invite.py 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/invite.py 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,191 @@ +# ----------------------------------------- +# Endroid - Room inviter +# Copyright 2013, Ensoft Ltd. +# Created by Ben Hutchings +# ----------------------------------------- + +from endroid.plugins.command import CommandPlugin, command +import shlex +import re + +def parse_string(string, options=None): + """ + Parse a shell command like string into an args tuple and a kwargs dict. + + Options is an iterable of string tuples, each tuple representing a keyword + followed by its synonyms. + + Words in string will be appended to the args tuple until a keyword is + reached, at which point they will be appended to a list in kwargs. + + Eg parse_string("a b c -u 1 2 3 -r 5 6", [("-u",), ("room", "-r")]) + will return: ('a', 'b', 'c'), {'-u': ['1','2','3'], 'room': ['5','6']} + + """ + options = options or [] + aliases = {} + + keys = [] + # build the kwargs dict with all the keywords and an aliases dict for synonyms + for option in options: + if isinstance(option, (list, tuple)): + main = option[0] + keys.append(main) + for alias in option[1:]: + aliases[alias] = main + elif isinstance(option, (str, unicode)): + keys.append(option) + + args = [] + kwargs = {} + current = None + # parse the string - first split into shell 'words' + parts = shlex.split(string) + # then add to args or the kwargs dictionary as appropriate + for part in parts: + # if it's a synonym get the main command, else leave it + part = aliases.get(part, part) + if part in keys: + # we have come to a keyword argument - create its list + kwargs[part] = [] + # keep track of where we are sending non-keyword words to + current = kwargs[part] + elif current is not None: + # we are adding words to a keyword's list + current.append(part) + else: + # no keywords has been found yet - we are still in args + args.append(part) + + return args, kwargs + +def replace(l, search, replace): + return [replace if item == search else item for item in l] + + +class Invite(CommandPlugin): + help = "Invite users to rooms" + name = "invite" + PARSE_OPTIONS = (("to", "into"),) + + @command(helphint="{to|into}? +") + def invite_me(self, msg, arg): + """ + Invite user to the rooms listed in args, or to all their rooms + if args is empty. + + """ + args, kwargs = parse_string(arg, self.PARSE_OPTIONS) + users = [msg.sender] + rooms = set(args + kwargs.get("to", [])) or ["all"] + + results = self._do_invites(users, rooms) + msg.reply(results) + + @command(helphint="", muc_only=True) + def invite_all(self, msg, arg): + """Invite all of a room's registered users to the room.""" + users = self.usermanagement.get_available_users(self.place_name) + rooms = [self.place_name] + + results = self._do_invites(users, rooms) + msg.reply(results) + + @command(helphint="+ {to|into} +") + def invite_users(self, msg, arg): + """Invite a list of users to a list of rooms.""" + args, kwargs = parse_string(arg, self.PARSE_OPTIONS) + users = replace(args, "me", msg.sender_full) + rooms = kwargs.get("to", []) + + results = self._do_invites(users, rooms) + msg.reply(results) + + def _do_invites(self, users, rooms): + if 'all' in users: + if len(rooms) == 1: + users = self.usermanagement.get_users(rooms[0]) + else: + return "Can only invite 'all' users to a single room" + + users = self._fuzzy_match(users, self.usermanagement.get_users()) + + if 'all' in rooms: + if len(users) == 1: + rooms = self.usermanagement.get_rooms(users[0]) + if not rooms: + return ("There are no rooms to invite user '{}' " + "to.".format(users[0])) + else: + return "Can only invite a single user to 'all' rooms" + + rooms = self._fuzzy_match(rooms, self.usermanagement.get_rooms()) + + if not users: + return "User not found." + if not rooms: + return "Room not found." + + results = [] + invitations = 0 + for room in rooms: + for user in users: + s, reason = self.usermanagement.invite(user, room) + if not s: + results.append("{} to {} failed: {}".format(user, room, reason)) + else: + invitations += 1 + + reply = "Sent {} invitations.".format(invitations) + if results: + reply += '\n' + '\n'.join(results) + return reply + + @staticmethod + def _fuzzy_match(partials, fulls): + """ + For lists 'partials' and 'fulls', elements of partials (

    ) are + mapped to elements of fulls () by the following rules: + + 1) If

    is in , then return

    (exact_match) + 2) If "

    @.*" matches exactly one then return

    (startswith_at) + 3) If "

    .*" matches exactly one then return

    (startswith) + 4) If ".*

    .*" matches exactly one then return

    (contains) + + """ + result = [] + + for partial in partials: + exact_match = None # an exact match for '' in fulls + startswith_at = [] # list of fulls starting with '@' + startswith = [] # list of fulls starting with '' + contains = [] # list of fulls containing '' + + for full in fulls: + if partial == full: + # we have found an exact match, don't look at other fulls + exact_match = full + break + # eg room will match room@serv.er + elif full.startswith(partial + '@'): + startswith_at.append(full) + # eg room will match room@serv.er, room1@serv.er etc + elif full.startswith(partial): + startswith.append(full) + # eg room will match aroom@serv.er, broom1@serv.er etc + elif partial in full: + contains.append(full) + + # case 1 + if exact_match: + result.append(exact_match) + # cases 2, 3, 4: for each check that there is exactly one match + elif len(startswith_at) == 1: + result.extend(startswith_at) + elif len(startswith) == 1: + result.extend(startswith) + elif len(contains) == 1: + result.extend(contains) + # else: we have multiple possible matches, so ignore them + + return result diff -Nru endroid-1.1.2/src/endroid/plugins/memo.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/memo.py --- endroid-1.1.2/src/endroid/plugins/memo.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/memo.py 2013-09-16 14:30:23.000000000 +0000 @@ -8,8 +8,7 @@ See MESSAGES['usage'] for details. """ -import endroid.messagehandler as messagehandler -from endroid.pluginmanager import Plugin +from endroid.plugins.command import CommandPlugin, command from endroid.database import Database from endroid.database import EndroidUniqueID @@ -27,11 +26,12 @@ memo usage - Output this message memo [list] - List messages in inbox memo send - Send a message -memo {view|delete} -Handle a specific message""", +memo {view|delete} - Handle a specific message""", 'usage-send' : "memo send \t Send a message", 'usage-view' : "memo view \t View a specific message", 'usage-delete' : "memo delete \t Delete a specific message", + 'duplicate-id-error' : "Error: There are messages sharing the same PK id", 'id-not-found-error' : "There is no message with id {0}. Try 'memo list'.", 'deletion-sucess' : "Sucessfully deleted one message", @@ -45,33 +45,21 @@ } -class Memo(Plugin): +class Memo(CommandPlugin): name = "memo" + help_topics = { + '': lambda _: MESSAGES['help'], + 'usage': lambda _: MESSAGES['usage'], + 'send': lambda _: MESSAGES['usage-send'], + 'view': lambda _: MESSAGES['usage-view'], + 'delete': lambda _: MESSAGES['usage-delete'], + } - def dependencies(self): - return ('endroid.plugins.command',) - - def enInit(self): - com = self.get('endroid.plugins.command') - com.register_chat(self._handle_list, 'memo', '[list]') - com.register_chat(self._handle_list, ('memo', 'list'), hidden=True) - com.register_chat(self._handle_send, ('memo', 'send'), - ' ') - com.register_chat(self._handle_view, ('memo', 'view'), '') - com.register_chat(self._handle_delete, ('memo', 'delete'), - '') - com.register_chat(lambda m, _: m.reply(MESSAGES['usage']), - ('memo', 'usage')) + def endroid_init(self): self.db = Database(DB_NAME) - self.setup_db() - - def setup_db(self): if not self.db.table_exists(DB_TABLE): self.db.create_table(DB_TABLE, DB_COLUMNS) - def help(self): - return MESSAGES['help'] - def _message_text_summary(self, text): # First assume text is too long, try to shorten. summary = "" @@ -82,9 +70,14 @@ summary += word + ' ' # Looks like memo is already short enough. Return it. return summary[:-1] - - def _handle_delete(self, msg, args): - args = arg.split() + + @command + def memo_usage(self, msg, args): + msg.reply(MESSAGES['usage']) + + @command(helphint="") + def memo_delete(self, msg, args): + args = args.split() if len(args) != 1 or not args[0].isdigit(): msg.reply(MESSAGES['usage-delete']) return @@ -100,9 +93,17 @@ self.db.delete(DB_TABLE, {EndroidUniqueID : int(args[0])}) msg.reply(MESSAGES['deletion-sucess']) - def _handle_list(self, msg, args): + @command(helphint="[list]") + def memo(self, msg, args): + args = args.split() + if len(args) > 0 and args[0] in {"send", "view", "delete", "usage"}: + msg.unhandled() + return + if len(args) > 1 or (len(args) == 1 and args[0] != "list"): + msg.reply(MESSAGES['usage']) + return memos = [] - cond = {"recipient" : msg.sender.userhost()} + cond = {"recipient" : msg.sender} rows = self.db.fetch(DB_TABLE, DB_COLUMNS, cond) for row in rows: data = (row.id, row["sender"], @@ -115,8 +116,9 @@ else: msg.reply(MESSAGES['inbox-empty']) - def _handle_view(self, msg, args): - args = arg.split() + @command(helphint="") + def memo_view(self, msg, args): + args = args.split() if len(args) != 1 or not args[0].isdigit(): msg.reply(MESSAGES['usage-view']) return @@ -133,18 +135,19 @@ data = (row["sender"], row["text"]) msg.reply(MESSAGES['view-message'].format(*data)) - def _handle_send(self, msg, args): - args = arg.split() + @command(helphint=" ") + def memo_send(self, msg, args): + args = args.split() if len(args) < 2: msg.reply(MESSAGES['usage-send']) return recipient, memo = args[0], ' '.join(args[1:]) - if recipient not in self.presence().get_roster(): + if recipient not in self.usermanagement.get_available_users(): msg.reply(MESSAGES['bad-recipient'].format(recipient)) return - self.db.insert(DB_TABLE, {'sender': msg.sender.userhost(), + self.db.insert(DB_TABLE, {'sender': msg.sender, 'recipient': recipient, 'text': memo}) msg.reply(MESSAGES['sent-message']) diff -Nru endroid-1.1.2/src/endroid/plugins/passthebomb.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/passthebomb.py --- endroid-1.1.2/src/endroid/plugins/passthebomb.py 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/passthebomb.py 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,211 @@ +from endroid.plugins.command import CommandPlugin +from endroid.cron import Cron +from collections import defaultdict +from endroid.database import Database + +DB_NAME = "PTB" +DB_TABLE = "PTB" + +class User(object): + __slots__ = ('name', 'kills', 'shield') + def __init__(self, name, kills=0, shield=True): + self.name = name + self.kills = kills + self.shield = shield + + def __repr__(self): + fmt = "User(name={}, kills={}, shield={})" + return fmt.format(self.name, self.kills, self.shield) + +class Bomb(object): + ID = 0 + def __init__(self, source, fuse, plugin): + self.source = source # who lit the bomb? + self.user = None # our current holder + self.plugin = plugin # plugin instance we belong to + self.history = set() # who has held us? + + idstring = self.get_id() # get a unique registration_name + plugin.cron.register(self.explode, idstring) + plugin.cron.setTimeout(fuse, idstring, None) # schedule detonation + + # this function is called by Cron and given an argument. We don't need an + # argument so just ignore it + def explode(self, _): + # some shorthands + get_rooms = self.plugin.usermanagement.get_available_rooms + send_muc = self.plugin.messagehandler.send_muc + send_chat = self.plugin.messagehandler.send_chat + + msg_explode = "!!!BOOM!!!" + msg_farexplode = "You hear a distant boom" + msg_kill = "{} was got by the bomb" + + rooms = get_rooms(self.user) + for room in rooms: + # let everyone in a room with self.user here the explosion + send_muc(room, msg_explode) + send_muc(room, msg_kill.format(self.user)) + + # alert those who passed the bomb that it has exploded + for user in self.history: + if user == self.user: + send_chat(self.user, msg_explode) + send_chat(self.user, msg_kill.format("You")) + else: + send_chat(user, msg_farexplode) + send_chat(user, msg_kill.format(self.user)) + + self.plugin.register_kill(self.source) + self.plugin.bombs[self.user].discard(self) + + + def throw(self, user): + # remove this bomb from our current user + self.plugin.bombs[self.user].discard(self) + + self.history.add(user) + self.user = user + + # add it to the new user + self.plugin.bombs[self.user].add(self) + + @classmethod + def get_id(cls): + # generate a unique id string to register our explode method against + result = Bomb.ID + cls.ID += 1 + return "bomb" + str(result) + + +class PassTheBomb(CommandPlugin): + help = "Pass the bomb game for EnDroid" + bombs = defaultdict(set) # users : set of bombs + users = dict() # user strings : User objects + + def endroid_init(self): + self.db = Database(DB_NAME) + if not self.db.table_exists(DB_TABLE): + self.db.create_table(DB_TABLE, ('user', 'kills')) + + # make a local copy of the registration database + data = self.db.fetch(DB_TABLE, ['user', 'kills']) + for dct in data: + self.users[dct['user']] = User(dct['user'], dct['kills']) + + def cmd_furl_umbrella(self, msg, arg): + """This is how a user enters the game - allows them to be targeted + and to create and throw bombs""" + user = msg.sender + if not self.get_shielded(user): + msg.reply("Your umbrella is already furled!") + else: + if self.get_registered(user): + self.users[user].shield = False + else: # they are not - register them + self.db.insert(DB_TABLE, {'user': user, 'kills': 0}) + self.users[user] = User(user, kills=0, shield=False) + msg.reply("You furl your umbrella!") + cmd_furl_umbrella.helphint = ("Furl your umbrella to participate in the " + "noble game of pass the bomb!") + + def cmd_unfurl_umbrella(self, msg, arg): + """A user with an unfurled umbrella cannot create or receive bombs""" + user = msg.sender + if self.get_shielded(user): + msg.reply("Your umbrella is already unfurled!") + else: + # to get user must not have been shielded ie they must have furled + # so they will be in the database + self.users[user].shield = True + msg.reply("You unfurl your umbrella! No bomb can reach you now!") + cmd_unfurl_umbrella.helphint = ("Unfurl your umbrella to cower from the " + "rain of boms!") + + def cmd_bomb(self, msg, arg): + """Create a bomb with a specified timer. + + eg: 'bomb 1.5' for a 1.5 second fuse + + """ + + holder = msg.sender + if self.get_shielded(holder): + return msg.reply("Your sense of honour insists that you furl your " + "umbrella before lighting the fuse") + # otherwise get a time from the first word of arg + try: + time = float(arg.split(' ', 1)[0]) + # make a new bomb and throw it to its creator + Bomb(msg.sender, time, self).throw(msg.sender) + msg.reply("Sniggering evilly, you light the fuse...") + # provision for a failure to read a time float... + except ValueError: + msg.reply("You struggle with the matches") + cmd_bomb.helphint = ("Light the fuse!") + + def cmd_throw(self, msg, arg): + """Throw a bomb to a user, eg: 'throw benh@ensoft.co.uk'""" + target = arg.split(' ')[0] + # we need a bomb to thrown + if not self.bombs[msg.sender]: + msg.reply("You idly throw your hat, wishing you had something " + "rounder, heavier and with more smoking fuses.") + # need our umbrella to be furled + elif self.get_shielded(msg.sender): + msg.reply("You notice that your unfurled umbrella would hinder " + "your throw.") + # check that target is online + elif not target in self.usermanagement.get_available_users(): + msg.reply("You look around but cannot spot your target") + elif self.get_shielded(target): # target registered/vulnerable? + msg.reply("You see your target hunkered down under their umbrella. " + "No doubt a bomb would have little effect on that " + "monstrosity.") + else: + self.bombs[msg.sender].pop().throw(target) + msg.reply("You throw the bomb!") + self.messagehandler.send_chat(target, "A bomb lands by your feet!") + cmd_throw.helphint = ("Throw a bomb!") + + def cmd_kills(self, msg, arg): + kills = self.get_kills(msg.sender) + nick = self.usermanagement.get_nickname(msg.sender, + self.place_name) + level = self.get_level(kills) + + text = "{} the {} has {} kill".format(nick, level, kills) + text += ("" if kills == 1 else "s") + msg.reply(text) + cmd_kills.helphint = ("Receive and gloat over you score!") + + def register_kill(self, user): + kills = self.get_kills(user) + # change the value of 'kills' to kills+1 in the row where 'user' = user + self.users[user].kills += 1 + self.db.update(DB_TABLE, {'kills': kills+1}, {'user': user}) + + def get_kills(self, user): + return self.users[user].kills if user in self.users else 0 + + def get_shielded(self, user): + return self.users[user].shield if user in self.users else True + + def get_registered(self, user): + return user in self.users + + @staticmethod + def get_level(kills): + if kills < 5: + level = 'novice' + elif kills < 15: + level = 'apprentice' + elif kills < 35: + level = 'journeyman' + elif kills < 65: + level = 'expert' + elif kills < 100: + level = 'master' + else: + level = 'grand-master' + return level diff -Nru endroid-1.1.2/src/endroid/plugins/patternmatcher.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/patternmatcher.py --- endroid-1.1.2/src/endroid/plugins/patternmatcher.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/patternmatcher.py 2013-09-16 14:30:23.000000000 +0000 @@ -12,6 +12,7 @@ class PatternMatcher(Plugin): name = "patternmatcher" + hidden = True def __init__(self): self._muc_match_list = [] diff -Nru endroid-1.1.2/src/endroid/plugins/pubpicker.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/pubpicker.py --- endroid-1.1.2/src/endroid/plugins/pubpicker.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/pubpicker.py 2013-09-16 14:30:23.000000000 +0000 @@ -16,6 +16,10 @@ # Some regular expressions for the alias command ALIAS_FOR = re.compile("(.*?) for (.*)", re.I) ALIAS_TO = re.compile("(.*?) to (.*)", re.I) +ALIAS_ERROR = "Alias loop detected! (starts at '{}')" + +class AliasError(Exception): + pass class PubPicker(Plugin): name = "pubpicker" @@ -75,12 +79,19 @@ self.add_alias(alias, alias.lower()) def resolve_alias(self, alias): + seen = set() while alias in self.aliases: + if alias in seen: + raise AliasError(alias) alias = self.aliases[alias] + seen.add(alias) return alias def vote_up(self, msg, pub): - pub = self.resolve_alias(pub) + try: + pub = self.resolve_alias(pub) + except AliasError as e: + return msg.reply(ALIAS_ERROR.format(e)) if pub not in self.pubs: self.pubs[pub] = 10 self.db.insert(DB_TABLE, {"name": pub, "score": 10}) @@ -90,9 +101,12 @@ self.save_pub(pub) def vote_down(self, msg, pub): - pub = self.resolve_alias(pub) + try: + pub = self.resolve_alias(pub) + except AliasError as e: + return msg.reply(ALIAS_ERROR.format(e)) if pub in self.pubs: - self.pubs[pub] = min(self.pubs[pub] - 1, 0) + self.pubs[pub] = max(self.pubs[pub] - 1, 0) self.save_pub(pub) def rename_pub(self, oldname, newname): @@ -113,14 +127,24 @@ pubs = [] for pub, score in self.pubs.items(): pubs += [pub] * score - return random.choice(pubs) + if pubs: + return random.choice(pubs) + else: + return None def register(self, msg, arg): - self.vote_up(self.resolve_alias(arg)) + try: + self.vote_up(msg, self.resolve_alias(arg)) + except AliasError as e: + msg.reply(ALIAS_ERROR.format(e)) def picker(self, msg, arg): pub = self.pick_a_pub() - msg.reply("Today, you should definitely go to %s" % pub) + if pub: + msg.reply("Today, you should definitely go to %s" % pub) + else: + msg.reply("Unfortunately, I don't seem to know about any pubs " + "that anyone wants to go to") def alias(self, msg, arg): mf = ALIAS_FOR.match(arg) @@ -135,7 +159,10 @@ def rename(self, msg, arg): mt = ALIAS_TO.match(arg) if mt: - self.rename_pub(self.resolve_alias(mt.group(1)), mt.group(2)) + try: + self.rename_pub(self.resolve_alias(mt.group(1)), mt.group(2)) + except AliasError as e: + msg.reply(ALIAS_ERROR.format(e)) else: msg.unhandled() diff -Nru endroid-1.1.2/src/endroid/plugins/ratelimit.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/ratelimit.py --- endroid-1.1.2/src/endroid/plugins/ratelimit.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/ratelimit.py 2013-09-16 14:30:23.000000000 +0000 @@ -4,18 +4,19 @@ # Created by Martin Morrison # ----------------------------------------------------------------------------- -from collections import defaultdict, deque + import time import logging +from collections import defaultdict, deque from endroid.pluginmanager import Plugin -from endroid.messagehandler import PRIORITY_URGENT -from endroid.database import Database -from endroid.cron import Cron +from endroid.messagehandler import Priority +from endroid.cron import task # Cron constants CRON_SENDAGAIN = "RateLimit_SendAgain" + class Bucket(object): """ Implementation of a Token Bucket. @@ -64,19 +65,19 @@ return "".format(self.tokens, self.capacity, self.fillrate, self.timestamp) + class SendClass(object): """ Represents an independently rate limited sender class. Usually, there is one instance of this class for each destination JID. """ - __slot__ = ('last_sent', 'queue', 'maxlen', 'bucket') + __slots__ = ('queue', 'maxlen', 'bucket') def __init__(self, bucket, maxlen=None): """ Initialise the sender class with the given TokenBucket, and the specified maxiumum queue length. """ - self.last_sent = 0 # Don't use maxlen in the deque - it drops from the wrong end! self.queue = deque() self.maxlen = maxlen @@ -99,10 +100,10 @@ Note that calls to this function consume tokens in the bucket. """ - if len(self.queue) > 0 and self.accept(): + if len(self.queue) > 0 and self._accept(): return self.queue.popleft() - def accept(self, msg=None, now=None): + def _accept(self, now=None): """ Check whether this sender class will accept sending a message, by checking the TokenBucket. If a msg is specified, and there are not @@ -114,12 +115,29 @@ """ if now is None: now = time.time() - acc = self.bucket.use_token(now=now) - if acc: - self.last_sent = now - elif msg: + + return self.bucket.use_token(now=now) + + def accept_msg(self, msg): + accept = self._accept() + + if accept: + # We can send a message, now lets find out which message + if len(self.queue) > 0: + # There are already msgs on the queue. Queue this message + # and return the first message from the queue to preserve + # ordering + self.append(msg) + msg_to_send = self.queue.popleft() + else: + msg_to_send = msg + else: + # Can't send a message so queue it self.append(msg) - return acc + msg_to_send = None + + return msg_to_send + class RateLimit(Plugin): """ @@ -127,13 +145,15 @@ """ name = "ratelimit" hidden = True + help = "Implements a Token Bucket rate limiter, per recipient JID" + preferences = ("endroid.plugins.blacklist",) - def enInit(self): + def endroid_init(self): """ Initialise the plugin. Registers required Crons, and extracts configuration. """ - self.min_interval = float(self.vars.get('min_interval', 1.0)) + self.maxburst = float(self.vars.get('maxburst', 5.0)) self.fillrate = float(self.vars.get('fillrate', 1.0)) self.maxqueuelen = int(self.vars.get('maxqueuelen', 20)) @@ -143,29 +163,21 @@ self.abusecooloff = int(self.vars.get('abusecooloff', 3600)) self.blacklist = self.get("endroid.plugins.blacklist") - self.register_muc_send_filter(self.ratelimit, priority=10) - self.register_chat_send_filter(self.ratelimit, priority=10) + self.messages.register(self.ratelimit, priority=10, send_filter=True) + self.messages.register(self.checkabuse, priority=10, recv_filter=True) - self.register_muc_filter(self.checkabuse, priority=10) - self.register_chat_filter(self.checkabuse, priority=10) + # Make all the state attributes class attributes + # This means that users are limited globally accross all usergroups and + # rooms rather than on a per room/user basis. + # It also means that it doesn't matter which instance is called back + # by the cron module + RateLimit.limiters = defaultdict( + lambda: SendClass(Bucket(self.maxburst, self.fillrate), + self.maxqueuelen)) + RateLimit.abusers = defaultdict( + lambda: Bucket(self.abuseallowance, self.abuserecovery)) - self.limiters = defaultdict(lambda: SendClass(Bucket(self.maxburst, - self.fillrate), - self.maxqueuelen)) - self.abusers = defaultdict(lambda: Bucket(self.abuseallowance, - self.abuserecovery)) - - self.waitingusers = set() - - self.cron = Cron.get().register(self.sendagain, CRON_SENDAGAIN) - - def preferences(self): - """Other plugins that we could use if they are loaded.""" - return ("endroid.plugins.blacklist",) - - def help(self): - "Help string for the plugin" - return "Implements a Token Bucket rate limiter, per recipient JID" + RateLimit.waitingusers = set() def ratelimit(self, msg): """ @@ -176,16 +188,25 @@ messages. TODO: store queued messages in the DB so they survive a process restart? - PRIORITY_URGENT messages are not rate limited. + Priority.URGENT messages are not rate limited. """ - now = time.time() sc = self.limiters[msg.recipient] # Don't ratelimit things we're sending ourselves, or URGENT messages - accept = (msg.sender is self or - msg.priority == PRIORITY_URGENT or - sc.accept(msg, now)) - self.set_timeout(msg.recipient, now) + if (msg.sender == self.name or + msg.priority == Priority.URGENT): + accept = True + else: + # Otherwise always return false because either: we'll send the msg + # ourselves or we're queuing the message for later sending + accept = False + msg_to_send = sc.accept_msg(msg) + if msg_to_send: + self.send(msg_to_send) + else: + logging.info("Ratelimiting msgs to {}".format(msg.recipient)) + + self.set_timeout(msg.recipient) return accept @@ -196,10 +217,18 @@ """ if not self.abusers[msg.sender].use_token(): if self.blacklist is not None: + logging.info("Blacklisting abusive user {}".format(msg.sender)) self.blacklist.blacklist(msg.sender.userhost(), self.abusecooloff) + else: + logging.info("Detected abusive user ({}) but unable to " + "blacklist. Dropping message instead".format( + msg.sender)) + return False + return True + @task(CRON_SENDAGAIN) def sendagain(self, user): """ Cron callback handler. Attempts to send as many messages as it can from @@ -207,28 +236,33 @@ """ sc = self.limiters[user] self.waitingusers.discard(user) + logging.info("Draining msg queue for {}, current len {}".format( + user, len(sc.queue))) while True: msg = sc.pop() if msg: - # Update the message to indicate it's sent from us (so we don't - # rate limit it again! - msg.sender = self - msg.send() + self.send(msg) else: break - self.set_timeout(user, time.time()) + self.set_timeout(user) - def set_timeout(self, user, now): + def send(self, msg): + # Update the message to indicate it's sent from us (so we don't + # rate limit it again! + msg.sender = self.name + msg.send() + + def set_timeout(self, user): """ Starts a cron timer if there are any messages queued for the given - user. the now argument is the current time (well, the time to assume is - now). + user. - The timer is always set for at least min_interval after the last - successful send to the given user (which gives an additional level of - delay. Maybe not useful, and should just use 1/fillrate?) + The timer is set for 1 / fillrate i.e. the time it takes for a single + token to be added to the bucket. """ + sc = self.limiters[user] - if len(sc.queue) > 0 and not user in self.waitingusers: + if len(sc.queue) > 0 and user not in self.waitingusers: self.waitingusers.add(user) - self.cron.setTimeout(self.fillrate / 1.0, user) + timedelta = 1.0 / self.fillrate + self.sendagain.setTimeout(timedelta, user) diff -Nru endroid-1.1.2/src/endroid/plugins/remote.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/remote.py --- endroid-1.1.2/src/endroid/plugins/remote.py 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/remote.py 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,202 @@ +# ----------------------------------------------------------------------------- +# EnDroid - Remote notification plugin +# Copyright 2012, Ensoft Ltd +# ----------------------------------------------------------------------------- + +import random +import string +import UserDict + +from endroid.database import Database +from endroid.pluginmanager import Plugin + +# Database and table names for the key database. +DB_NAME = "Remote" +DB_TABLE = "Keys" + +# Length and set of characters that make up keys. +KEY_LENGTH = 16 +KEY_CHARS = string.ascii_uppercase + string.digits + +# Templates for HTML responses. +FORM_PAGE = """ + + + +

    + {header_msg} + User: + Key:
    + Message:
    +
    +
    + + +""" + +ERROR_PAGE = """ + + + Error + +

    Error

    + Error: {error_string} + + +""" + +# Message displayed by the help message. +HELP_MESSAGE = """ +Send messages to users via a web interface. A key is required to send +a user messages. The following commands are supported: + - allow remote: Generate a key to allow others to message you. + - deny remote: Delete a previously generated key. +""" + +class InputError(Exception): + pass + +class DatabaseDict(object, UserDict.DictMixin): + """ + Dict-like object backed by a sqlite DB. + """ + def __init__(self, db, table, key_column, val_column): + """ + db: Database to store the dictionary data. + table: Name of the table to store the dictionary data. + key_column: Name of the column to store dictionary keys. + val_column: Name of the column to store dictionary values. + """ + + self.db = db + self.table = table + self.key_column = key_column + self.val_column = val_column + + if not self.db.table_exists(self.table): + self.db.create_table(self.table, (key_column, val_column)) + + def keys(): + return (result[self.key_column] for key + in self.db.fetch(self.table, [self.key_column])) + + def __delitem__(self, key): + if key in self: + c = self.db.delete(self.table, {self.key_column: key}) + assert c == 1 + + def __setitem__(self, key, val): + if key in self: + c = self.db.update(self.table, + {self.key_column: key, self.val_column: val}, + {self.key_column: key}) + assert c == 1 + else: + self.db.insert(self.table, + {self.key_column: key, self.val_column: val}) + + def __getitem__(self, key): + results = self.db.fetch(self.table, + [self.key_column, self.val_column], + {self.key_column: key}) + assert len(results) <= 1 + if len(results) == 0: + raise KeyError(key) + return results[0][self.val_column] + +class RemoteNotification(Plugin): + name = "remote" + + def endroid_init(self): + com = self.get('endroid.plugins.command') + + com.register_chat(self.allow, ('allow', 'remote')) + com.register_chat(self.deny, ('deny', 'remote')) + + http = self.get('endroid.plugins.httpinterface') + http.register_path(self, self.http_request_handler, '') + + self.keys = DatabaseDict(Database(DB_NAME), DB_TABLE, "users", "keys") + + def help(self): + return HELP_MESSAGE + + def dependencies(self): + return ['endroid.plugins.command', + 'endroid.plugins.httpinterface'] + + @staticmethod + def _make_key(): + return ''.join(random.choice(KEY_CHARS) for x in range(KEY_LENGTH)) + + def allow(self, msg, arg): + jid = msg.sender + if jid in self.keys: + msg.reply("Remote notifications already allowed. (Your key is %s.)" + % self.keys[jid]) + else: + key = RemoteNotification._make_key() + self.keys[jid] = key + msg.reply("Remote notifications allowed. Your key is %s." % key) + + def deny(self, msg, arg): + jid = msg.sender + if jid in self.keys: + del self.keys[jid] + msg.reply("Remote notifications denied.") + else: + msg.reply("Remote notifications already denied. Nothing to do.") + + def _validate_args(self, args): + """ + Validate an args dict from a POST request. Validation includes: + * Checking expected arguments are present. + * Checking the user is in the database. + * Checking the key matches the user's. + + Raises: + InputError: If the args are invalid + """ + + req_args = ['user', 'key', 'message'] + missing_args = list(set(req_args) - set(args.keys())) + + if len(missing_args) > 0: + raise InputError("Missing arguments: %s" % ' '.join(missing_args)) + + # Arguments come in the form of singleton arrays. Do some preprocessing + # to extract only the first element. + args = dict((key, val[0]) for key, val in args.iteritems()) + + if args['user'] not in self.keys: + raise InputError("User {user} has not allowed remote messaging. " + "{user} can generate a key with " + "'allow remote'".format(**args)) + + if self.keys[args['user']] != args['key']: + raise InputError("Incorrect key provided for {user}" + .format(**args)) + + def http_request_handler(self, request): + """ + A callback from the httpinterface plugin. Renders a form to allow + sending a user a message. If POST data from the form is included + a message is sent, unless the input is invalid in which case an + error page is displayed. + """ + + try: + header_msg = '' + if request.method == 'POST': + self._validate_args(request.args) + + self.messagehandler.send_chat(request.args['user'][0], + "Remote notification received: %s" % request.args['message'][0]) + header_msg = \ + "Message delivered to %s
    " % \ + request.args['user'][0] + + return FORM_PAGE.format(header_msg=header_msg) + except InputError as e: + return ERROR_PAGE.format(error_string=str(e)) + diff -Nru endroid-1.1.2/src/endroid/plugins/roomowner.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/roomowner.py --- endroid-1.1.2/src/endroid/plugins/roomowner.py 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/roomowner.py 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,80 @@ +import logging +from endroid.pluginmanager import Plugin +from twisted.internet.defer import DeferredList + + +class RoomOwner(Plugin): + + def endroid_init(self): + if self.place != "room": + return + + def owner_result(is_owner): + if not is_owner: + logging.error("EnDroid doesn't appear to be the owner for " + "room {}".format(self.place_name)) + return + + logging.debug("EnDroid owns room {}".format(self.place_name)) + + self._update_memberlist() + + # Apply the config + # @@@Config mark and sweep should be added here + # configure_room internally sanitises anything it gets + # allowing only predefined keys and ignoring the rest (so giving it + # our whole .vars dictionary is safe (and easy)) + self.usermanagement.configure_room(self.place_name, self.vars) + + # First check if EnDroid owns this room + d = self.usermanagement.is_room_owner(self.place_name) + d.addCallback(owner_result) + + def _get_configured_memberlist(self): + if 'users' in self.vars: + memberlist = set(self.vars['users']) + else: + # Check for old-style config + memberlist = self.usermanagement.get_configured_room_memberlist( + self.place_name) + + return memberlist + + def _update_memberlist(self): + """Update the member list of this room based on config.""" + + def parse_memberlist(results): + successes, results = zip(*results) + + if not all(successes): + # One of the calls failed + logging.error('Failed to get member or owner list for {}'. + format(self.place_name)) + return + + current_memberlist = set(results[0]) + current_ownerlist = set(results[1]) + conf_memberlist = self._get_configured_memberlist() + + logging.debug('Configured memberlist for room {}: {}'.format( + self.place_name, ", ".join(conf_memberlist))) + logging.debug('Current memberlist for room {}: {}'.format( + self.place_name, ", ".join(current_memberlist))) + logging.debug('Current ownerlist for room {}: {}'.format( + self.place_name, ", ".join(current_ownerlist))) + + # As another owner we can't perform any affiliation changes to + # existing owners so remove them from any lists + new_members = conf_memberlist - current_memberlist - current_ownerlist + dead_members = current_memberlist - conf_memberlist - current_ownerlist + if new_members: + self.usermanagement.room_memberlist_change( + self.place_name, new_members) + if dead_members: + self.usermanagement.room_memberlist_change( + self.place_name, dead_members, remove=True) + + # Get the current memberlist to determine what changes need to be made + d1 = self.usermanagement.get_room_memberlist(self.place_name) + d2 = self.usermanagement.get_room_ownerlist(self.place_name) + DeferredList([d1, d2]).addCallback(parse_memberlist) diff -Nru endroid-1.1.2/src/endroid/plugins/speak.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/speak.py --- endroid-1.1.2/src/endroid/plugins/speak.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/speak.py 2013-09-16 14:30:23.000000000 +0000 @@ -4,44 +4,32 @@ # Created by Jonathan Millican # ----------------------------------------- -from twisted.words.protocols.jabber.jid import JID -from endroid.pluginmanager import Plugin +from endroid.plugins.command import CommandPlugin -class Speak(Plugin): +class Speak(CommandPlugin): name = "speak" + help = ("Speak allows you to speak as EnDroid. Don't abuse it\n" + "Command syntax: speak ") - def dependencies(self): - return ['endroid.plugins.command'] - - def enInit(self): - com = self.get('endroid.plugins.command') - com.register_chat(self.do_speak, 'speak', ' ') - com.register_chat(self.do_many_speak, 'repeat', ' ') - - def do_speak(self, msg, args): - tojid, text = self.split(args) - if tojid in self.suc_users(): - self.send_chat(JID(tojid), text) + def cmd_speak(self, msg, args): + tojid, text = self._split(args) + if tojid in self.rosters.available_users: + self.messages.send_chat(tojid, text, msg.sender) else: msg.reply("You can't send messages to that user. Sorry.") + cmd_speak.helphint = " " - def do_many_speak(self, msg, args): + def repeat(self, msg, args): count, tojid, text = args.split(' ', 2) - if tojid in self.suc_users(): + if tojid in self.rosters.available_users: for i in range(int(count)): - self.send_chat(JID(tojid), text) + self.messages.send_chat(tojid, text, msg.sender) else: msg.reply("You can't send messages to that user. Sorry.") + repeat.helphint = " " - def split(self, message): + def _split(self, message): if message.count(' ') == 0: return (message, '') else: return message.split(' ', 1) - - def help(self): - return ("Speak allows you to speak as EnDroid. Don't abuse it\n" - "Command syntax: speak ") - -def get_plugin(): - return Speak() diff -Nru endroid-1.1.2/src/endroid/plugins/spell.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/spell.py --- endroid-1.1.2/src/endroid/plugins/spell.py 2012-09-04 12:12:59.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/spell.py 2013-09-16 14:30:23.000000000 +0000 @@ -24,17 +24,13 @@ words marked with a '(sp?)' after them. """ name = "spell" + dependencies = ("endroid.plugins.patternmatcher",) + help = "Check spelling of words by typing '(sp?)' after them." - def enInit(self): + def endroid_init(self): pat = self.get("endroid.plugins.patternmatcher") pat.register_both(self.heard, REOBJ) - def dependencies(self): - return ('endroid.plugins.patternmatcher',) - - def help(self): - return "Check spelling of words by typing '(sp?)' after them." - def heard(self, msg): """ Checks spelling of all the matches. diff -Nru endroid-1.1.2/src/endroid/plugins/trains.py endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/trains.py --- endroid-1.1.2/src/endroid/plugins/trains.py 1970-01-01 00:00:00.000000000 +0000 +++ endroid-1.2~68~ubuntu12.10.1/src/endroid/plugins/trains.py 2013-09-16 14:30:23.000000000 +0000 @@ -0,0 +1,120 @@ +# ----------------------------------------- +# Endroid - Trains Live Departures +# Copyright 2013, Ensoft Ltd. +# Created by Martin Morrison +# ----------------------------------------- + +import re +import urllib +from HTMLParser import HTMLParser +from twisted.web.client import getPage + +from endroid.plugins.command import CommandPlugin, command + +TIMERE_STR = r'(\d+)(?::(\d+))? *(am|pm)?' +ULRE = re.compile(r'
      (.*)', re.S) +CMDRE = re.compile(r"((?:from (.*) )?to (.*?)|home)(?:(?: (arriving|leaving))?(?: (tomorrow|(?:next )?\w*day))?(?: at ({}))?)?$".format(TIMERE_STR)) +RESULTRE = re.compile(r" *(.*?) *") +TIMERE = re.compile(TIMERE_STR) + +STATION_TABLE = "Stations" +HOME_TABLE = "Home" + +class TrainTimes(CommandPlugin): + name = "traintimes" + help_topics = { + "": "When do trains leave?", + } + + def endroid_init(self): + if not self.database.table_exists(STATION_TABLE): + self.database.create_table(STATION_TABLE, ("jid", "station")) + if not self.database.table_exists(HOME_TABLE): + self.database.create_table(HOME_TABLE, ("jid", "station")) + + def _station_update(self, msg, args, table, jid, display): + if not args: + rows = self.database.fetch(table, ("station",), + {"jid": jid}) + if rows: + msg.reply_to_sender("Your {} station is set to: {}" + .format(display, rows[0]['station'])) + else: + msg.reply_to_sender("You don't have a {} station set." + .format(display)) + return + self.database.delete(table, {"jid": msg.sender}) + if args != "delete": + self.database.insert(table, {"jid": jid, "station": args}) + msg.reply_to_sender("Your new {} station is: {}" + .format(display, args)) + else: + msg.reply_to_sender("{} station deleted." + .format(display.capitalize())) + + @command(helphint="{|delete}") + def nearest_station(self, msg, args): + self._station_update(msg, args, STATION_TABLE, msg.sender_full, + "nearest") + + @command(helphint="{|delete}") + def home_station(self, msg, args): + self._station_update(msg, args, HOME_TABLE, msg.sender, "home") + + @command(helphint="from to [[arriving|leaving] at