public inbox for gentoo-commits@lists.gentoo.org
 help / color / mirror / Atom feed
* [gentoo-commits] proj/council-webapp:master commit in: bot/tests/, bot/MeetBot/, bot/ircmeeting/, bot/, bot/doc/
@ 2011-06-05 20:37 Petteri Räty
  0 siblings, 0 replies; only message in thread
From: Petteri Räty @ 2011-06-05 20:37 UTC (permalink / raw
  To: gentoo-commits

commit:     e8c39d513356e14b60813c54824a63a6ad516348
Author:     Joachim Filip Ignacy Bartosik <jbartosik <AT> gmail <DOT> com>
AuthorDate: Wed May 18 14:44:11 2011 +0000
Commit:     Petteri Räty <betelgeuse <AT> gentoo <DOT> org>
CommitDate: Tue May 24 17:03:00 2011 +0000
URL:        http://git.overlays.gentoo.org/gitweb/?p=proj/council-webapp.git;a=commit;h=e8c39d51

MeetBot plugin from Debian

---
 bot/MeetBot/__init__.py                |   66 ++
 bot/MeetBot/config.py                  |   53 ++
 bot/MeetBot/plugin.py                  |  302 ++++++++
 bot/MeetBot/supybotconfig.py           |  174 +++++
 bot/MeetBot/test.py                    |   82 +++
 bot/README.txt                         |  113 +++
 bot/doc/Manual.txt                     |  926 ++++++++++++++++++++++++
 bot/doc/meetingLocalConfig-example.py  |   19 +
 bot/ircmeeting/css-log-default.css     |   15 +
 bot/ircmeeting/css-minutes-default.css |   34 +
 bot/ircmeeting/items.py                |  292 ++++++++
 bot/ircmeeting/meeting.py              |  672 ++++++++++++++++++
 bot/ircmeeting/template.html           |  102 +++
 bot/ircmeeting/template.txt            |   55 ++
 bot/ircmeeting/writers.py              | 1197 ++++++++++++++++++++++++++++++++
 bot/setup.py                           |   12 +
 bot/tests/run_test.py                  |  352 ++++++++++
 bot/tests/test-script-1.log.txt        |   85 +++
 bot/tests/test-script-2.log.txt        |   49 ++
 19 files changed, 4600 insertions(+), 0 deletions(-)

diff --git a/bot/MeetBot/__init__.py b/bot/MeetBot/__init__.py
new file mode 100644
index 0000000..5a43105
--- /dev/null
+++ b/bot/MeetBot/__init__.py
@@ -0,0 +1,66 @@
+###
+# Copyright (c) 2009, Richard Darst
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#   * Redistributions of source code must retain the above copyright notice,
+#     this list of conditions, and the following disclaimer.
+#   * Redistributions in binary form must reproduce the above copyright notice,
+#     this list of conditions, and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#   * Neither the name of the author of this software nor the name of
+#     contributors to this software may be used to endorse or promote products
+#     derived from this software without specific prior written consent.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+###
+
+"""
+Add a description of the plugin (to be presented to the user inside the wizard)
+here.  This should describe *what* the plugin does.
+"""
+
+import supybot
+import supybot.world as world
+
+# Use this for the version of this plugin.  You may wish to put a CVS keyword
+# in here if you're keeping the plugin in CVS or some similar system.
+__version__ = ""
+
+# XXX Replace this with an appropriate author or supybot.Author instance.
+__author__ = supybot.Author('Richard Darst', 'darst', 'rkd@zgib.net')
+
+# This is a dictionary mapping supybot.Author instances to lists of
+# contributions.
+__contributors__ = {}
+
+# This is a url where the most recent plugin package can be downloaded.
+__url__ = '' # 'http://supybot.com/Members/yourname/MeetBot/download'
+
+import config
+import plugin
+reload(plugin) # In case we're being reloaded.
+# Add more reloads here if you add third-party modules and want them to be
+# reloaded when this plugin is reloaded.  Don't forget to import them as well!
+
+if world.testing:
+    import test
+
+Class = plugin.Class
+configure = config.configure
+
+
+# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79:

diff --git a/bot/MeetBot/config.py b/bot/MeetBot/config.py
new file mode 100644
index 0000000..911cbb2
--- /dev/null
+++ b/bot/MeetBot/config.py
@@ -0,0 +1,53 @@
+###
+# Copyright (c) 2009, Richard Darst
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#   * Redistributions of source code must retain the above copyright notice,
+#     this list of conditions, and the following disclaimer.
+#   * Redistributions in binary form must reproduce the above copyright notice,
+#     this list of conditions, and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#   * Neither the name of the author of this software nor the name of
+#     contributors to this software may be used to endorse or promote products
+#     derived from this software without specific prior written consent.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+###
+
+import supybot.conf as conf
+import supybot.registry as registry
+
+def configure(advanced):
+    # This will be called by supybot to configure this module.  advanced is
+    # a bool that specifies whether the user identified himself as an advanced
+    # user or not.  You should effect your configuration by manipulating the
+    # registry as appropriate.
+    from supybot.questions import expect, anything, something, yn
+    conf.registerPlugin('MeetBot', True)
+
+
+MeetBot = conf.registerPlugin('MeetBot')
+# This is where your configuration variables (if any) should go.  For example:
+# conf.registerGlobalValue(MeetBot, 'someConfigVariableName',
+#     registry.Boolean(False, """Help for someConfigVariableName."""))
+conf.registerGlobalValue(MeetBot, 'enableSupybotBasedConfig',
+    registry.Boolean(False, """Enable configuration via the supybot config """
+                            """mechanism."""))
+
+
+
+# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79:

diff --git a/bot/MeetBot/plugin.py b/bot/MeetBot/plugin.py
new file mode 100644
index 0000000..e7a621a
--- /dev/null
+++ b/bot/MeetBot/plugin.py
@@ -0,0 +1,302 @@
+###
+# Copyright (c) 2009, Richard Darst
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#   * Redistributions of source code must retain the above copyright notice,
+#     this list of conditions, and the following disclaimer.
+#   * Redistributions in binary form must reproduce the above copyright notice,
+#     this list of conditions, and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#   * Neither the name of the author of this software nor the name of
+#     contributors to this software may be used to endorse or promote products
+#     derived from this software without specific prior written consent.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+###
+
+import supybot.utils as utils
+from supybot.commands import *
+import supybot.plugins as plugins
+import supybot.ircutils as ircutils
+import supybot.callbacks as callbacks
+import supybot.ircmsgs as ircmsgs
+
+import time
+import ircmeeting.meeting as meeting
+import supybotconfig
+# Because of the way we override names, we need to reload these in order.
+meeting = reload(meeting)
+supybotconfig = reload(supybotconfig)
+
+if supybotconfig.is_supybotconfig_enabled(meeting.Config):
+    supybotconfig.setup_config(meeting.Config)
+    meeting.Config = supybotconfig.get_config_proxy(meeting.Config)
+
+# By doing this, we can not lose all of our meetings across plugin
+# reloads.  But, of course, you can't change the source too
+# drastically if you do that!
+try:               meeting_cache
+except NameError:  meeting_cache = {}
+try:               recent_meetings
+except NameError:  recent_meetings = [ ]
+
+
+class MeetBot(callbacks.Plugin):
+    """Add the help for "@plugin help MeetBot" here
+    This should describe *how* to use this plugin."""
+
+    def __init__(self, irc):
+        self.__parent = super(MeetBot, self)
+        self.__parent.__init__(irc)
+                        
+    # Instead of using real supybot commands, I just listen to ALL
+    # messages coming in and respond to those beginning with our
+    # prefix char.  I found this helpful from a not duplicating logic
+    # standpoint (as well as other things).  Ask me if you have more
+    # questions.
+
+    # This captures all messages coming into the bot.
+    def doPrivmsg(self, irc, msg):
+        nick = msg.nick
+        channel = msg.args[0]
+        payload = msg.args[1]
+        network = irc.msg.tags['receivedOn']
+
+        # The following is for debugging.  It's excellent to get an
+        # interactive interperter inside of the live bot.  use
+        # code.interact instead of my souped-up version if you aren't
+        # on my computer:
+        #if payload == 'interact':
+        #    from rkddp.interact import interact ; interact()
+
+        # Get our Meeting object, if one exists.  Have to keep track
+        # of different servers/channels.
+        # (channel, network) tuple is our lookup key.
+        Mkey = (channel,network)
+        M = meeting_cache.get(Mkey, None)
+
+        # Start meeting if we are requested
+        if payload[:13] == '#startmeeting':
+            if M is not None:
+                irc.error("Can't start another meeting, one is in progress.")
+                return
+            # This callback is used to send data to the channel:
+            def _setTopic(x):
+                irc.sendMsg(ircmsgs.topic(channel, x))
+            def _sendReply(x):
+                irc.sendMsg(ircmsgs.privmsg(channel, x))
+            def _channelNicks():
+                return irc.state.channels[channel].users
+            M = meeting.Meeting(channel=channel, owner=nick,
+                                oldtopic=irc.state.channels[channel].topic,
+                                writeRawLog=True,
+                                setTopic = _setTopic, sendReply = _sendReply,
+                                getRegistryValue = self.registryValue,
+                                safeMode=True, channelNicks=_channelNicks,
+                                network=network,
+                                )
+            meeting_cache[Mkey] = M
+            recent_meetings.append(
+                (channel, network, time.ctime()))
+            if len(recent_meetings) > 10:
+                del recent_meetings[0]
+        # If there is no meeting going on, then we quit
+        if M is None: return
+        # Add line to our meeting buffer.
+        M.addline(nick, payload)
+        # End meeting if requested:
+        if M._meetingIsOver:
+            #M.save()  # now do_endmeeting in M calls the save functions
+            del meeting_cache[Mkey]
+
+    def outFilter(self, irc, msg):
+        """Log outgoing messages from supybot.
+        """
+        # Catch supybot's own outgoing messages to log them.  Run the
+        # whole thing in a try: block to prevent all output from
+        # getting clobbered.
+        try:
+            if msg.command in ('PRIVMSG'):
+                # Note that we have to get our nick and network parameters
+                # in a slightly different way here, compared to doPrivmsg.
+                nick = irc.nick
+                channel = msg.args[0]
+                payload = msg.args[1]
+                Mkey = (channel,irc.network)
+                M = meeting_cache.get(Mkey, None)
+                if M is not None:
+                    M.addrawline(nick, payload)
+        except:
+            import traceback
+            print traceback.print_exc()
+            print "(above exception in outFilter, ignoring)"
+        return msg
+
+    # These are admin commands, for use by the bot owner when there
+    # are many channels which may need to be independently managed.
+    def listmeetings(self, irc, msg, args):
+        """
+
+        List all currently-active meetings."""
+        reply = ""
+        reply = ", ".join(str(x) for x in sorted(meeting_cache.keys()) )
+        if reply.strip() == '':
+            irc.reply("No currently active meetings.")
+        else:
+            irc.reply(reply)
+    listmeetings = wrap(listmeetings, ['admin'])
+    def savemeetings(self, irc, msg, args):
+        """
+
+        Save all currently active meetings."""
+        numSaved = 0
+        for M in meeting_cache.iteritems():
+            M.config.save()
+        irc.reply("Saved %d meetings."%numSaved)
+    savemeetings = wrap(savemeetings, ['admin'])
+    def addchair(self, irc, msg, args, channel, network, nick):
+        """<channel> <network> <nick>
+
+        Add a nick as a chair to the meeting."""
+        Mkey = (channel,network)
+        M = meeting_cache.get(Mkey, None)
+        if not M:
+            irc.reply("Meeting on channel %s, network %s not found"%(
+                channel, network))
+            return
+        M.chairs.setdefault(nick, True)
+        irc.reply("Chair added: %s on (%s, %s)."%(nick, channel, network))
+    addchair = wrap(addchair, ['admin', "channel", "something", "nick"])
+    def deletemeeting(self, irc, msg, args, channel, network, save):
+        """<channel> <network> <saveit=True>
+
+        Delete a meeting from the cache.  If save is given, save the
+        meeting first, defaults to saving."""
+        Mkey = (channel,network)
+        if Mkey not in meeting_cache:
+            irc.reply("Meeting on channel %s, network %s not found"%(
+                channel, network))
+            return
+        if save:
+            M = meeting_cache.get(Mkey, None)
+            import time
+            M.endtime = time.localtime()
+            M.config.save()
+        del meeting_cache[Mkey]
+        irc.reply("Deleted: meeting on (%s, %s)."%(channel, network))
+    deletemeeting = wrap(deletemeeting, ['admin', "channel", "something",
+                               optional("boolean", True)])
+    def recent(self, irc, msg, args):
+        """
+
+        List recent meetings for admin purposes.
+        """
+        reply = []
+        for channel, network, ctime in recent_meetings:
+            Mkey = (channel,network)
+            if Mkey in meeting_cache:   state = ", running"
+            else:                       state = ""
+            reply.append("(%s, %s, %s%s)"%(channel, network, ctime, state))
+        if reply:
+            irc.reply(" ".join(reply))
+        else:
+            irc.reply("No recent meetings in internal state.")
+    recent = wrap(recent, ['admin'])
+
+    def pingall(self, irc, msg, args, message):
+        """<text>
+
+        Send a broadcast ping to all users on the channel.
+
+        An message to be sent along with this ping must also be
+        supplied for this command to work.
+        """
+        nick = msg.nick
+        channel = msg.args[0]
+        payload = msg.args[1]
+
+        # We require a message to go out with the ping, we don't want
+        # to waste people's time:
+        if channel[0] != '#':
+            irc.reply("Not joined to any channel.")
+            return
+        if message is None:
+            irc.reply("You must supply a description with the `pingall` command.  We don't want to go wasting people's times looking for why they are pinged.")
+            return
+
+        # Send announcement message
+        irc.sendMsg(ircmsgs.privmsg(channel, message))
+        # ping all nicks in lines of about 256
+        nickline = ''
+        nicks = sorted(irc.state.channels[channel].users,
+                       key=lambda x: x.lower())
+        for nick in nicks:
+            nickline = nickline + nick + ' '
+            if len(nickline) > 256:
+                irc.sendMsg(ircmsgs.privmsg(channel, nickline))
+                nickline = ''
+        irc.sendMsg(ircmsgs.privmsg(channel, nickline))
+        # Send announcement message
+        irc.sendMsg(ircmsgs.privmsg(channel, message))
+
+    pingall = wrap(pingall, [optional('text', None)])
+
+    def __getattr__(self, name):
+        """Proxy between proper supybot commands and # MeetBot commands.
+
+        This allows you to use MeetBot: <command> <line of the command>
+        instead of the typical #command version.  However, it's disabled
+        by default as there are some possible unresolved issues with it.
+
+        To enable this, you must comment out a line in the main code.
+        It may be enabled in a future version.
+        """
+        # First, proxy to our parent classes (__parent__ set in __init__)
+        try:
+            return self.__parent.__getattr__(name)
+        except AttributeError:
+            pass
+        # Disabled for now.  Uncomment this if you want to use this.
+        raise AttributeError
+
+        if not hasattr(meeting.Meeting, "do_"+name):
+            raise AttributeError
+
+        def wrapped_function(self, irc, msg, args, message):
+            channel = msg.args[0]
+            payload = msg.args[1]
+
+            #from fitz import interactnow ; reload(interactnow)
+
+            #print type(payload)
+            payload = "#%s %s"%(name,message)
+            #print payload
+            import copy
+            msg = copy.copy(msg)
+            msg.args = (channel, payload)
+
+            self.doPrivmsg(irc, msg)
+        # Give it the signature we need to be a callable supybot
+        # command (it does check more than I'd like).  Heavy Wizardry.
+        instancemethod = type(self.__getattr__)
+        wrapped_function = wrap(wrapped_function, [optional('text', '')])
+        return instancemethod(wrapped_function, self, MeetBot)
+
+Class = MeetBot
+
+
+# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79:

diff --git a/bot/MeetBot/supybotconfig.py b/bot/MeetBot/supybotconfig.py
new file mode 100644
index 0000000..d4921a9
--- /dev/null
+++ b/bot/MeetBot/supybotconfig.py
@@ -0,0 +1,174 @@
+# Richard Darst, June 2009
+
+###
+# Copyright (c) 2009, Richard Darst
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#   * Redistributions of source code must retain the above copyright notice,
+#     this list of conditions, and the following disclaimer.
+#   * Redistributions in binary form must reproduce the above copyright notice,
+#     this list of conditions, and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#   * Neither the name of the author of this software nor the name of
+#     contributors to this software may be used to endorse or promote products
+#     derived from this software without specific prior written consent.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+###
+
+
+import types
+
+import supybot.conf as conf
+import supybot.registry as registry
+
+import ircmeeting.meeting as meeting
+import ircmeeting.writers as writers
+
+# The plugin group for configuration
+MeetBotConfigGroup = conf.registerPlugin('MeetBot')
+
+class WriterMap(registry.String):
+    """List of output formats to write.  This is a space-separated
+    list of 'WriterName:.ext' pairs.  WriterName must be from the
+    writers.py module, '.ext' must be a extension ending in a .
+    """
+    def set(self, s):
+        s = s.split()
+        writer_map = { }
+        for writer in s:
+            #from fitz import interact ; interact.interact()
+            writer, ext = writer.split(':', 1)
+            if not hasattr(writers, writer):
+                raise ValueError("Writer name not found: %s"%writer)
+            #if len(ext) < 2 or ext[0] != '.':
+            #    raise ValueError("Extension must start with '.' and have "
+            #                     "at least one more character.")
+            writer_map[ext] = getattr(writers, writer)
+        self.setValue(writer_map)
+    def setValue(self, writer_map):
+        for e, w in writer_map.iteritems():
+            if not hasattr(w, "format"):
+                raise ValueError("Writer %s must have method .format()"%
+                                 w.__name__)
+            self.value = writer_map
+    def __str__(self):
+        writers_string = [ ]
+        for ext, w in self.value.iteritems():
+            name = w.__name__
+            writers_string.append("%s:%s"%(name, ext))
+        return " ".join(writers_string)
+
+
+class SupybotConfigProxy(object):
+    def __init__(self, *args, **kwargs):
+        """Do the regular default configuration, and sta"""
+        OriginalConfig = self.__OriginalConfig
+        self.__C = OriginalConfig.__new__(OriginalConfig, *args, **kwargs)
+        # We need to call the __init__ *after* we have rebound the
+        # method to get variables from the config proxy.
+        old_init = self.__C.__init__
+        new_init = types.MethodType(old_init.im_func, self, old_init.im_class)
+        new_init(*args, **kwargs)
+    
+    def __getattr__(self, attrname):
+        """Try to get the value from the supybot registry.  If it's in
+        the registry, return it.  If it's not, then proxy it to th.
+        """
+        if attrname in settable_attributes:
+            M = self.M
+            value = M._registryValue(attrname, channel=M.channel)
+            if not isinstance(value, (str, unicode)):
+                return value
+            # '.' is used to mean "this is not set, use the default
+            # value from the python config class.
+            if value != '.':
+                value = value.replace('\\n', '\n')
+                return value
+        # If the attribute is a _property_, we need to rebind the
+        # "fget" method to the proxy class.
+        # See http://docs.python.org/library/functions.html#property
+        #     http://docs.python.org/reference/datamodel.html#descriptors
+        C = self.__C
+        # is this a class attribute AND does it have a fget ?
+        if hasattr(C.__class__, attrname) and \
+               hasattr(getattr(C.__class__, attrname), 'fget'):
+            # Get the 'fget' descriptor, rebind it to self, return its
+            # value.
+            fget = getattr(C.__class__, attrname).fget
+            fget = types.MethodType(fget, self, C.__class__)
+            return fget()
+        # We don't have this value in the registry.  So, proxy it to
+        # the normal config object.  This is also the path that all
+        # functions take.
+        value = getattr(self.__C, attrname)
+        # If the value is an instance method, we need to re-bind it to
+        # the new config class so that we will get the data values
+        # defined in supydot (otherwise attribute lookups in the
+        # method will bypass the supybot proxy and just use default
+        # values).  This will slow things down a little bit, but
+        # that's just the cost of duing business.
+        if hasattr(value, 'im_func'):
+            return types.MethodType(value.im_func, self, value.im_class)
+        return value
+
+
+
+#conf.registerGlobalValue(MeetBot
+use_supybot_config = conf.registerGlobalValue(MeetBotConfigGroup,
+                                              'enableSupybotBasedConfig',
+                                              registry.Boolean(False, ''))
+def is_supybotconfig_enabled(OriginalConfig):
+    return (use_supybot_config.value and
+            not getattr(OriginalConfig, 'dontBotConfig', False))
+
+settable_attributes = [ ]
+def setup_config(OriginalConfig):
+    # Set all string variables in the default Config class as supybot
+    # registry variables.
+    for attrname in dir(OriginalConfig):
+        # Don't configure attributs starting with '_'
+        if attrname[0] == '_':
+            continue
+        attr = getattr(OriginalConfig, attrname)
+        # Don't configure attributes that aren't strings.
+        if isinstance(attr, (str, unicode)):
+            attr = attr.replace('\n', '\\n')
+            # For a global value: conf.registerGlobalValue and remove the
+            # channel= option from registryValue call above.
+            conf.registerChannelValue(MeetBotConfigGroup, attrname,
+                                      registry.String(attr,""))
+            settable_attributes.append(attrname)
+        if isinstance(attr, bool):
+            conf.registerChannelValue(MeetBotConfigGroup, attrname,
+                                      registry.Boolean(attr,""))
+            settable_attributes.append(attrname)
+
+    # writer_map
+    # (doing the commented out commands below will erase the previously
+    # stored value of a config variable)
+    #if 'writer_map' in MeetBotConfigGroup._children:
+    #    MeetBotConfigGroup.unregister('writer_map')
+    conf.registerChannelValue(MeetBotConfigGroup, 'writer_map',
+                      WriterMap(OriginalConfig.writer_map, ""))
+    settable_attributes.append('writer_map')
+
+def get_config_proxy(OriginalConfig):
+    # Here is where the real proxying occurs.
+    SupybotConfigProxy._SupybotConfigProxy__OriginalConfig = OriginalConfig
+    return SupybotConfigProxy
+
+

diff --git a/bot/MeetBot/test.py b/bot/MeetBot/test.py
new file mode 100644
index 0000000..28dba8a
--- /dev/null
+++ b/bot/MeetBot/test.py
@@ -0,0 +1,82 @@
+###
+# Copyright (c) 2009, Richard Darst
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#   * Redistributions of source code must retain the above copyright notice,
+#     this list of conditions, and the following disclaimer.
+#   * Redistributions in binary form must reproduce the above copyright notice,
+#     this list of conditions, and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#   * Neither the name of the author of this software nor the name of
+#     contributors to this software may be used to endorse or promote products
+#     derived from this software without specific prior written consent.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+###
+
+from supybot.test import *
+
+import os
+import sys
+
+class MeetBotTestCase(ChannelPluginTestCase):
+    channel = "#testchannel"
+    plugins = ('MeetBot',)
+
+    def testRunMeeting(self):
+        test_script = file(os.path.join("test-script-2.log.txt"))
+        for line in test_script:
+            # Normalize input lines somewhat.
+            line = line.strip()
+            if not line: continue
+            # This consists of input/output pairs we expect.  If it's
+            # not here, it's not checked for.
+            match_pairs = (('#startmeeting', 'Meeting started'),
+                           ('#endmeeting', 'Meeting ended'),
+                           ('#topic +(.*)', 1),
+                           ('#meetingtopic +(.*)', 1),
+                           ('#meetingname','The meeting name has been set to'),
+                           ('#chair', 'Current chairs:'),
+                           ('#unchair', 'Current chairs:'),
+                           )
+            # Run the command and get any possible output
+            reply = [ ]
+            self.feedMsg(line)
+            r = self.irc.takeMsg()
+            while r:
+                reply.append(r.args[1])
+                r = self.irc.takeMsg()
+            reply = "\n".join(reply)
+            # If our input line matches a test pattern, then insist
+            # that the output line matches the expected output
+            # pattern.
+            for test in match_pairs:
+                if re.search(test[0], line):
+                    groups = re.search(test[0], line).groups()
+                    # Output pattern depends on input pattern
+                    if isinstance(test[1], int):
+                        print groups[test[1]-1], reply
+                        assert re.search(re.escape(groups[test[1]-1]), reply),\
+                              'line "%s" gives output "%s"'%(line, reply)
+                    # Just match the given pattern.
+                    else:
+                        print test[1], reply
+                        assert re.search(test[1], reply.decode('utf-8')), \
+                               'line "%s" gives output "%s"'%(line, reply)
+
+
+# vim:set shiftwidth=4 tabstop=4 expandtab textwidth=79:

diff --git a/bot/README.txt b/bot/README.txt
new file mode 100644
index 0000000..dcb3041
--- /dev/null
+++ b/bot/README.txt
@@ -0,0 +1,113 @@
+ABOUT
+~~~~~
+http://wiki.debian.org/MeetBot
+
+Inspired by the original MeetBot, by Holger Levsen, which was itself a
+derivative of Mootbot (https://wiki.ubuntu.com/ScribesTeam/MootBot),
+by the Ubuntu Scribes team.
+
+The Supybot file GETTING_STARTED
+(/usr/share/doc/supybot/GETTING_STARTED.gz on Debian systems) provides
+hinformation on configuring supybot the first time, including taking
+ownership the first time.  You really need to read this if you haven't
+used supybot before.
+
+
+
+INSTALLATION
+~~~~~~~~~~~~
+
+Requirements
+------------
+* pygments (optional) (debian package python-pygments) (for pretty IRC
+  logs).  This package is no longer required (after HTMLlog2 became
+  default)
+
+
+Install Supybot
+---------------
+* You need to install supybot yourself.  You can use supybot-wizard to
+  make a bot configuration.
+
+  * See the file GETTING_STARTED
+    (/usr/share/doc/supybot/GETTING_STARTED.gz on a Debian system).
+    This tells all about supybot installation, and is an important
+    prerequisite to understanding MeetBot configuration.
+
+  * Don't use a prefix character.  (disable this:
+      supybot.reply.whenAddressedBy.chars: 
+    in the config file - leave it blank afterwards.)  If you do use a
+    prefix character, it should be different than the "#" MeetBot
+    prefix character.  There are issues here which still need to be
+    worked out.
+
+Install the MeetBot plugin
+--------------------------
+
+* Move the MeetBot directory into your ``plugins`` directory of
+  Supybot.
+
+* You need the ``ircmeeting`` directory to be importable as a python
+  module.
+
+  * Easy method:  Copy ``ircmeeting`` into the ``MeetBot`` directory.
+    This makes ``ircmeeting`` work as a relative import.  However,
+    this will probably stop working with some future Python version.
+
+  * Other method: Copy ``ircmeeting`` somewhere into $PYTHONPATH.
+
+* Make sure the plugin is loaded.  Use the command ``load MeetBot``.
+  You can check the command ``config plugins`` to check what is
+  loaded.
+
+Configuration
+-------------
+
+* Make supybot join any channels you are interested in.  The wizard
+  handles this the first time around.  After that, I guess you have to
+  learn about supybot.  If the plugin is loaded, it is active on ALL
+  channels the bot is on.  You can also command the bot after it's
+  online.
+
+* Make a `meetingLocalConfig.py` file and put it somewhere that it can
+  be found:
+  - in $PYTHONPATH
+  - in the ircmeeting/ directory
+  - in the current working directory
+
+* Configuration of meetingLocalConfig.py is covered in the manual,
+  doc/Manual.txt
+
+Supybot does a lot, far more than this one readme can talk about.  You
+need to learn about supybot a bit, too, in order to be able to use
+MeetBot properly.
+
+
+
+DESIGN DECISIONS
+~~~~~~~~~~~~~~~~
+The MeetBot plugin doesn't operate like a regular supybot plugin.  It
+bypasses the normal command system.  Instead it listens for all lines
+(it has to log them all anyway) and if it sees a command, it acts on it.
+
+- Separation of meeting code and plugin code.  This should make it
+  easy to port to other bots, and perhaps more importantly make it
+  easier to maintain, or rearrange, the structure within supybot.
+
+- Not making users have to register and have capabilities added.  The
+  original meetbot ran as a service to many channels not necessarily
+  connected to the original owner.
+
+- Makes it easier to replay stored logs.  I don't have to duplicate the
+  supybot command parsing logic, such as detecting the bot nick and
+  running the proper command.  Also, there might be command overlaps
+  with some preexisting plugins.
+
+
+
+LICENSE
+~~~~~~~
+The MeetBot plugin is under the same license as supybot is, a 3-clause
+BSD.  The license is documented in each code file (and also applies to
+this README file).
+

diff --git a/bot/doc/Manual.txt b/bot/doc/Manual.txt
new file mode 100644
index 0000000..2fe82ba
--- /dev/null
+++ b/bot/doc/Manual.txt
@@ -0,0 +1,926 @@
+====================================================
+MeetBot, a supybot plugin for IRC meeting notetaking
+====================================================
+
+
+
+MeetBot is a plugin to the IRC bot supybot to facilitate taking of
+notes during IRC meetings.  This allows you to better communicate with
+your project or groups after the IRC meeting, as well as keep the
+meeting more under control and on-topic.
+
+.. contents::
+
+
+
+
+Tutorial
+========
+
+Let's go through, step by step, how a typical meeting might run::
+
+  < MrBeige> #startmeeting
+
+We use the ``#startmeeting`` command to tell MeetBot to start the
+meeting.  The person who calls the command becomes the chair - having
+the power to guide the meeting.  However, by default MeetBot allows
+other participants to enter most things into the logs, since inviting
+contributions is generally a good thing.::
+
+  < MeetBot> Meeting started Wed Jun 17 05:00:49 2009 UTC.  The chair
+             is MrBeige.
+  < MeetBot> Information about MeetBot at
+             http://wiki.debian.org/MeetBot , Useful Commands: #action
+             #agreed #halp #info #idea #link #topic.
+
+MeetBot gives us a little bit of information about the meeting.::
+
+  < MrBeige> #topic should we release or not?
+  -!- MeetBot changed the topic of #meetbot-test to: should we release
+      or not?
+
+We use the ``#topic`` command to tell MeetBot to move to the first
+topic.  MeetBot sets the topic in the channel to the topic which is
+given on the line.  Don't worry, the topic will be restored at the end
+of the meeting.::
+
+  < MrBeige> #info we have two major blocking bugs: the character set
+             conversion, and the segfaults heisenbug in the save
+             routine.
+
+When there is important things said, we don't want them to be lost in
+the irclogs.  Thus, we use the ``#info`` command to make a note of
+it in the meeting minutes.  It is also highlighted in the irclogs
+which MeetBot takes.::
+
+  < MrBeige> #agreed we give one week to fix these (no other changes
+             accepted), and then release
+
+We also have the ``#agreed`` command to use.  This can only be used by
+the chairs of the meeting, and should (obviously) be used to document
+agreement.  The rest of the line goes into the minutes as the thing
+agreed on.::
+
+  < MrBeige> #action MrGreen and MrMauve work together to fix the bugs
+  < MrBeige> #action MrBeige releases when done
+
+We have the ``#action`` command.  This one is works just like the last
+two, but has one extra feature: at the end of the meeting, it makes a
+list of "Action Items", useful for being sure things get taken care
+of.  But there is more: it also makes a list of action items sorted by
+*nick*.  This can be used to easily see what is assigned to you.  In
+order for an item to be sorted by a nick, that nick has got to say
+something during the meeting (but also see the ``#nick`` command), and
+you have to use their nick exactly (use tab completion!).::
+
+  < MrBeige> #topic goals for release after next
+  -!- MeetBot changed the topic of #meetbot-test to: goals for release
+      after next
+
+Moving on to the next topic...::
+
+  ...
+  < MrBeige> #info make it better
+  ...
+  < MrBeige> #info release faster
+  ...
+
+Record some of the important items from this section.::
+
+  < MrBeige> #endmeeting
+
+Hit the ``#endmeeting`` command.  The meeting ends, and logs and
+minutes are saved::
+
+  -!- MeetBot changed the topic of #meetbot-test to: General
+      discussion of MeetBot
+  < MeetBot> Meeting ended Wed Jun 17 05:03:45 2009 UTC.  Information
+             about MeetBot at http://wiki.debian.org/MeetBot .
+  < MeetBot> Minutes: http://rkd.zgib.net/meetbot/meetbot-test/meetbot-test.html
+  < MeetBot> Log: http://rkd.zgib.net/meetbot/meetbot-test/meetbot-test.log.html
+
+MeetBot conveniently tells us where all of the logs are stored.  You
+can look at the `logs`_ and `minutes`_ online.
+
+.. _logs: http://rkd
+.. _minutes: http://rkd
+
+
+
+
+User reference
+==============
+
+Commands
+--------
+
+All commands are case-insensitive, and use the ``#`` prefix
+character.   Not all commands have output.  This might be confusing,
+because you don't know if it's been acted on or not.  However, this is
+a conscious design decision to try to keep out of the way and not
+distract from the real people.  If something goes wrong, you can
+adjust and have MeetBot re-process the logs later.
+
+#startmeeting
+  Starts a meeting.  The calling nick becomes the chair.  If any text
+  is given on the rest of the line, this becomes the meeting topic,
+  see ``#meetingtopic`` above.
+
+#endmeeting
+  End a meeting, save logs, restore previous topic, give links to
+  logs.  You know the drill.  (Chairs only.)
+
+#topic
+  Set the current topic of discussion.  MeetBot changes the topic in
+  the channel (saving the original topic to be restored at the end of
+  the meeting).  (Chairs only.)
+
+#agreed (alias #agree)
+  Mark something as agreed on.  The rest of the line is the details.
+  (Chairs only.)
+
+#chair and #unchair
+  Add new chairs to the meeting.  The rest of the line is a list of
+  nicks, separated by commas and/or spaces.  The nick which started
+  the meeting is the ``owner`` and can't be de-chaired.  The command
+  replies with a list of the current chairs, for verification (Chairs
+  only.)  Example::
+
+    < MrBeige> #chair MrGreen MsAlizarin
+    < MeetBot> Current chairs are: MsAlizarin MrBeige MrGreen
+
+#action
+
+  Add an ``ACTION`` item to the minutes.  Provide irc nicks of people
+  involved, and will be both a complete listing of action items, and a
+  listing of action items sorted by nick at the end of the meeting.
+  This is very useful for making sure this gets done.  Example::
+
+    < MrBeige> #action MrGreen will read the entire Internet to
+               determine why the hive cluster is under attack.
+
+  If MrGreen has said something during the meeting, this will be
+  automatically assigned to him.
+
+#info
+  Add an ``INFO`` item to the minutes.  Example::
+
+    < MrBeige> #info We need to spawn more overlords before the next
+               release.
+
+#link
+
+  Add a link to the minutes.  The URL will be properly detected within
+  the line in most cases - the URL can't contain spaces.  This command
+  is automatically detected if the line starts with http:, https:,
+  mailto:, and some other common protocols defined in the
+  ``UrlProtocols`` configuration variable.  Examples::
+
+    < MrBeige> #link http://wiki.debian.org/MeetBot/ is the main page
+    < MrBeige> http://wiki.debian.org/MeetBot/ is the main page
+    < MrBeige> #link the main page is http://wiki.debian.org/MeetBot/
+               so go there
+    < MrBeige> the main page is http://wiki.debian.org/MeetBot/ so go
+               there.  (This will NOT be detected automatically)
+
+
+
+
+Less-used commands
+------------------
+
+#meetingtopic
+  Sets the "meeting topic".  This will always appear in the topic in
+  the channel, even as the #topic changes.  The format of the IRCtopic
+  is "<topic> (Meeting Topic: <meeting topic>)".  (Chairs only.)
+
+#commands
+  List recognized supybot commands.  This is the actual "help" command.
+
+#idea
+  Add an ``IDEA`` to the minutes.
+
+#help (alias #halp)
+  Add a ``HELP`` item to the minutes.  Confusingly, this does *not* give
+  supybot help.  See #commands.
+
+#accepted (alias #accept)
+  Mark something as accepted.  The rest of the line is the details.
+  (Chairs only.)
+
+#rejected (alias #reject)
+  Mark something as rejected.  The rest of the line is the details.
+  (Chairs only.)
+
+#save
+  Write out the logs right now.  (Chairs only.)
+
+#nick
+  Make a nick be recognized by supybot, even though it hasn't said
+  anything.  This is only useful in order to make a list of action
+  items be grouped by this nick at the end of the meeting.
+
+#undo
+  Remove the last item from the meeting minutes.  Only applies to
+  commands which appear in the final output.  (Chairs only.)
+
+#restrictlogs
+  When logs are saved, remove the permissions specified in the
+  configuration variable ``RestrictPerm``.  (Chairs only.)
+
+#lurk and #unlurk
+  When ``lurk`` is set, MeetBot will only passively listen and take
+  notes (and save the notes), not reply or change the topic  This is
+  useful for when you don't want disruptions during the meeting.
+  (Chairs only.)
+
+#meetingname
+  Provide a friendly name which can be used as a variable in the
+  filename patterns.  For example, you can set
+  filenamePattern = '%(channel)s/%%Y/%(meetingname)s.%%F-%%H.%%M'
+  to allow #meetingname to categorize multiple types of meeting
+  occurring in one channel.
+
+  All spaces are removed from the rest of the line and the string is
+  converted to lowercase.  If ``meetingname`` is not provided, it
+  defaults to ``channel``.  (Chairs only.)
+
+
+
+
+Hints on how to run an effective meeting
+----------------------------------------
+
+*Please contribute to this section!*
+
+* Have an agenda.  Think about the agenda beforehand, so that
+  attendees are not tempted to jump ahead and discuss future items.
+  This will make it very hard to follow.
+* *Liberally* use the ``#action`` command, making sure to include the
+  nick of the person responsible.  It will produce an easy-to-scan
+  list of things to do, as well as a sorted-by-nick version.  This
+  will make these things more likely to get done.
+* In the same spirit, liberally use ``#info`` on important pieces of
+  data.  If you think you'll want to refer to it again, ``#info``
+  it.  Assigning someone to watch the meeting to ``#info`` other
+  people's lines (if they forget) usually pays off.
+* Don't be afraid to tell attendees to wait for a future topic to
+  discuss something.
+* Delegate where possible and have those interested discuss the
+  details after the meeting, where applicable.  No need to take
+  everyone's time if not everyone needs to decide.  (This only
+  applies to some types of meetings)
+* Sometimes one chair to manage the topic at hand, and one chair to
+  manage all people who are going off-topic, can help.
+
+
+
+
+Administrators
+==============
+
+Overview
+--------
+
+Unfortunately, MeetBot seems a bit complex to configure.  In order to
+keep things straight, keep this in mind: MeetBot has two distinct
+pieces.  The first (``meeting.py`` and friends) is the meeting parser
+and note/logfile generator.  This part can run independently,
+*without* the supybot plugin.  The second part interfaces the core
+``meeting.py`` to supybot, to make it usable via IRC.
+
+When reading about how to run MeetBot, keep this in mind, and if
+something is applicable to ``meeting.py`` features, just supybot
+features, or both.
+
+This design split greatly increases modularity (a "good thing"), and
+also allows the Replay functionality.  It should also allow other bot
+plugins to easily be written.
+
+
+
+
+Replay functionality
+--------------------
+
+Let's say you had a meeting which happened a while ago, and you would
+like to update the logs to a newer format.  If supybot was the only
+way to use MeetBot, you'd be out of luck.  Luckily, there is an
+independent way to replay meetings::
+
+    python /path/to/meeting.py replay /path/to/old_meeting.log.txt
+
+You run the meeting.py file as a script, giving the subcommand
+``replay`` and then a path to a ``.log.txt`` file from a previous
+meeting (or from some other source of IRC logs, it's essentially the
+irssi/xchat format).  It parses it and processes the meeting, and
+outputs all of the usual ``.html``, ``.log.html``, and so on files in
+the directory parallel to the input file.
+
+This is useful if you want to upgrade your output formats, MeetBot
+broke and you lost the realtime log and want to generate a summary
+using your own logfiles, remove or change something in the logs that
+was incorrect during the meeting.  As such, this is an important
+feature of MeetBot.
+
+However, this does make configuration changes harder.  Since the
+replay function works *independent* of supybot, any configuration that
+is done in supybot will be invisible to the replay function.  Thus, we
+have to have a non-supybot mechanism of configuring MeetBot.  There
+was a supybot way of configuring MeetBot added later, which can adjust
+most variables.  However, if something is configured here, it won't be
+seen if a file is replayed.  This might be OK, or it might not be,
+depending on the variable.
+
+
+
+
+Configuration
+-------------
+
+meetingLocalConfig.py configuration
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+This is the non-supybot method of configuration, and allows the most
+flexibility.  **It works for configuring supybot, too**, but requires
+shell access and a MeetBot reload to change.
+
+Configuration is done by creating a file ``meetingLocalConfig.py`` in
+the plugin directory, or somewhere in your PYTHONPATH.  It works by
+(automatically, not user-visible) subclassing the Config class.
+
+Here is a minimal usage example.  You need at *least* this much to
+make it run. Put this in ``meetingLocalConfig.py`` before you first
+start supybot::
+
+    class Config(object):
+        # These two are **required**:
+        logFileDir = '/home/richard/meetbot/'
+        logUrlPrefix = 'http://rkd.zgib.net/meetbot/'
+
+Two other more commonly used options are::
+
+        filenamePattern = '%(channel)s/%%Y/%(channel)s.%%F-%%H.%%M'
+        MeetBotInfoURL = 'http://some_other_side.tld'
+
+Place all of the configuration variables inside of the class
+body like this.
+
+``meetingLocalConfig.py`` is imported via python, so all the usual
+benefits and caveats of that apply.  It causes a subclass of the main
+Config object.  Thus, you can do some advanced (or just crazy) things
+like add a new meeting command, meeting agenda item type, or more.
+Some of these ideas are documented under the "Advanced configuration"
+section below.
+
+To reload a configuration in a running supybot, you can just reload
+the plugin in supybot --- the module is reloaded.  Specifically,
+``/msg YourBotName reload MeetBot``.
+
+
+
+Supybot-based config
+~~~~~~~~~~~~~~~~~~~~
+
+This is the system that configures MeetBot based on the supybot
+registry system.  Thus, it can be adjusted by anyone with the proper
+supybot capabilities.  However, the configuration in the supybot
+registry *won't* be used if the ``replay`` functionality is used (see
+above).  Thus, for example, if you configure the MediaWiki writer
+using ``supybot.plugins.MeetBot.writer_map``, and then ``replay`` the
+meeting, the MediaWiki output will *not* be updated.
+
+To enable this system, first the
+``supybot.plugins.MeetBot.enableSupybotBasedConfig`` variable must be
+set to True.  Then the MeetBot plugin must be reloaded::
+
+    /msg YourBot config supybot.plugins.MeetBot.enableSupybotBasedConfig True
+    /msg YourBot reload MeetBot
+
+Now you can list the values available for configuration (the list
+below may not be up to date)::
+
+    /msg YourBot config list supybot.plugins.MeetBot
+    ----> #endMeetingMessage, #filenamePattern, #input_codec,
+          #logFileDir, #logUrlPrefix, #MeetBotInfoURL, #output_codec,
+          #pygmentizeStyle, #specialChannelFilenamePattern,
+          #startMeetingMessage, #timeZone, #usefulCommands,
+          enableSupybotBasedConfig, and public
+
+Setting a value for a variable::
+
+    /msg YourBot config supybot.plugins.MeetBot.logUrlPrefix http://meetings.yoursite.net/
+
+Most variables (those with # prepended) can be set on a per-channel
+basis (they are set up as channel-specific variables).
+
+
+At present, not all variables are exported to supybot.  All string and
+boolean variables are, as well certain other variables for which a
+wrapper has been written (``writer_map`` in particular).  If a
+variable doesn't appear in the supybot registry, it can't be set via
+the registry.
+
+If you want to disable supybot-based config for security reasons, set
+``dontBotConfig`` to True in your custom configuration class in
+``meetingLocalConfig.py``.
+
+
+
+Required or important configuration
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+These variables are set either in ``meetingLocalConfig.py`` (in the
+``Config`` class) or in the supybot registry.
+
+``logFileDir``
+    The filesystem directory in which the meeting outputs are stored,
+    defaulting to ".".  **Required**.
+
+``logUrlPrefix``
+    The URL corresponding to ``logFileDir``.  This is prepended to
+    filenames when giving end-of-meeting links in the channel.
+    **Required** or supybot's URLs will be missing.
+
+``filenamePattern``
+    This defaults to ``'%(channel)s/%%Y/%(channel)s.%%F-%%H.%%M'``,
+    and is the pattern used for replacements to identify the name of
+    the file basename (including possible sub-directories) in which to
+    store the meeting output files.  This is the suffix to
+    ``logFileDir`` and ``logUrlPrefix``.
+
+    Variables available for replacement using ``%(name)s`` include:
+    ``channel``, ``network``, ``meetingname``.  Double percent signs
+    (e.g.: ``%%Y`` are time formats, from ``time.strftime``.
+
+    You should *not* include filename extensions here.  Those are
+    found from the writers, via the variable ``writer_map``.
+
+Putting these all together, a set of variables could be:
+  1) ``logFileDir  =  /srv/www/meetings/``
+  2) ``%(channel)s/%%Y/%(channel)s.%%F-%%H.%%M``
+  3) (``.html``, ``.txt``, etc, extensions come from ``writers_map``)
+
+``MeetBotInfoURL``
+    This is a URL given in beginning and ending messages and minutes
+    files as a "go here for more information" link.
+
+
+writer_map configuration
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+``writer_map`` tells how we want to output the results of the meeting.
+It is, as you can guess, a mapping from filename extensions
+(``.html``, ``.log.html``, ...) to what we want to output to that file
+(``writers.HTML``, ``writers.HTMLlog``, ...)
+
+Using ``meetingLocalConfig.py``, it is python dictionary listing what
+output formats will be used to write our final results to a file,
+along with extensions to use.  For example, in
+``meetingLocalConfig.py``::
+
+    import writers
+
+    class Config:
+        writer_map = {
+            '.log.html':writers.HTMLlog,
+            '.html': writers.HTML,
+            '.txt': writers.RST,
+            '.mw':writers.MediaWiki,
+            }
+
+If an extension begins in ``.none`` the output will *not* be written
+to a file.  Note that you can't have the same extension multiple times
+due to the way python dictionaries work: use ``.none1``, ``.none2``,
+etc.
+
+This *can* be configured through supybot.  To do this, set
+``supybot.plugins.MeetBot.writer_map`` to a space-separated list of
+``WriterName:.extension`` pairs (note the different ordering from the
+python dictionary).  For example, to list the current setting (in
+private message with the bot)::
+
+    <MrBeige> config plugins.MeetBot.writer_map
+    <MeetBot> HTML2:.html MediaWiki:.mw HTMLlog2:.log.html Text:.txt
+
+And to set it (again, in private message with the bot)::
+
+    <MrBeige> config plugins.MeetBot.writer_map HTML2:.html MediaWiki:.mw HTMLlog2:.log.html Text:.txt
+
+There is a special way to pass arguments to writers.  Learn by
+example::
+
+    writer_map = {
+        '.mw|mwsite=http://site.net|mwpath=Meetings':writers.MediaWiki,
+    }
+
+or via supybot::
+
+    config plugins.MeetBot.writer_map MediaWiki:.mw|mwsite=http://site.net|mwpath=Meetings
+
+
+
+
+
+The available writers are (with default extensions, if enabled by
+default):
+
+``TextLog`` (``.log.txt``)
+    Plain-text logs suitable for replaying.  This does **not** have to
+    be explicitly enabled listed-- it is automatically enabled
+    whenever it is needed (currently, only when a meeting comes
+    realtime from supybot, not when being replayed)
+
+``HTMLlog`` (``.log.html``)
+    Alias for the current default HTML-pretty logs output format,
+    currently ``HTMLlog2``.
+
+``HTMLlog2``
+    Writes the logs in a HTML-pretty, CSS-customizable way (see section
+    below).
+
+``HTML`` (``.html``)
+    Alias for the current default HTML output format for the meeting
+    notes, currently ``HTML2``.
+
+``HTML2``
+    Meeting notes, in a numbered list HTML output format.
+    Configurable via CSS (see section below).
+
+``Text`` (``.txt``)
+    A meeting notes format, as a plain text file
+
+``MediaWiki``
+    MediaWiki output.
+
+    The MediaWiki writer has the
+    ability to upload to a MediaWiki site directly.  You use the
+    custom variables ``mwsite``: site name to upload to, ``mwpath``:
+    subpage to upload to (final location is
+    ``%(mwpath)/%(file_basename)), ``mwusername`` and ``mwpassword``:
+    username and password to log in as.
+
+    An upload is attempted if ``mwsite`` is given.  A login is
+    attempted if ``mwusername`` is given.  An example configuration::
+
+        writer_map = {
+            '.mw|mwsite=http://site.net|mwpath=Meetings':writers.MediaWiki,
+            }
+
+``PmWiki``
+    PmWiki output.  This doesn't upload *to* a PmWiki instance,
+    but that could be added later.
+
+``Template``
+    This writer allows a user-defined template to used to output the
+    meeting logs.  This allows complete control of the output by
+    embedding special tags into templates.  This writer depends on the
+    `Genshi templating engine`_.  For information on how the
+    templating engine works, please see its website or the example
+    templates provided.
+
+.. _`Genshi templating engine`: http://genshi.edgewall.org/
+
+    To use the templating engine, you must specify the template file
+    to use.  This is done via a special argument syntax.  Instead of
+    an file extension name, the extension should be specified as
+    ``.EXTENSION_NAME|template=TEMPLATE_FILE``, with the metavariables
+    explaining what the parts do.  For example, in
+    ``meetingLocalConfig.py`` one would do::
+
+        class Config:
+            writer_map = {
+                ...
+                '.tmpl.txt|template=+template.txt' = writers.Template,
+                }
+
+    When setting a template writer by the suybot registry, one would do::
+
+        /msg YourBot config plugins.MeetBot.writer_map <other writers> Template:.EXTENSION_NAME|template=TEMPLATE_FILE ...
+
+    ``TEMPLATE_FILE`` is an absolute or relative filename.  As a
+    special case, ``+TEMPLATE_NAME`` can be used to specify a path
+    relative to the MeetBot source directory.  This is used to include
+    the default templates: ``+template.html`` or ``+template.txt`` .
+
+
+Obsolete writers are:
+
+``HTMLlog1``
+    Old HTML writer.  This one requires the Python module ``pygments``
+    to be installed.  HTMLlog2 was written to remove this dependency.
+    HTMLlog2 is just as pretty as this is, and HTMLlog2 is
+    configurable via CSS.
+
+``HTML1``
+    Old, table-based HTML writer.
+
+``ReST``
+    ReStructured Text output.  Since ReStructured Text is a bit strict
+    in it's parser, user input from the IRC meeting will often mess up
+    the conversion, and thus this isn't recommended for true use until
+    better escape functions can be written.  (There is no security
+    risk from this plugin, ReST is run in secure mode).
+
+``HTMLfromReST``
+    This runs the ReStructured Text writer, and uses ``docutils`` to
+    convert it to HTML.  This requires the ``docutils`` package of
+    Python to be installed.
+
+
+
+Other config variables
+~~~~~~~~~~~~~~~~~~~~~~
+
+These variables are set either in ``meetingLocalConfig.py`` (in the
+``Config`` class) or in the supybot registry.
+
+``RestrictPerm``
+    An int listing which permissions to remove when using the
+    ``#restrictlogs`` command.  It is best to use the python ``stat``
+    module to set it::
+
+        RestrictPerm = stat.S_IRWXO|stat.S_IRWXG
+
+``specialChannels`` and ``specialChannelFilenamePattern``
+    When you are doing MeetBot testing, you would rather not have
+    nonstop different filenames each time you do a test meeting.
+    If a channel is in ``specialChannels``, it will use
+    ``specialChannelFilenamePattern`` instead of ``filenamePattern``
+    when storing logs.  ``specialChannels`` is a tuple listing channel
+    names.  Example: the defaults are ``("#meetbot-test",
+    "#meetbot-test2")`` and ``'%(channel)s/%(channel)s'`` (note that
+    there is no time-dependence in the name).
+
+``UrlProtocols``
+    Tuple of protocols to use to automatically detect link.  Example:
+    the default tuple is ``('http:', 'https:', 'irc:', 'ftp:',
+    'mailto:', 'ssh:')``.
+
+``command_RE``
+    How commands are detected.  See code.
+
+``pygmentizeStyle``
+    Style for the Pygments module to use to colorize the IRC logs.
+    The default is ``"friendly"``.
+
+``timeZone``
+    Timezone used in the bot.  Note: This will not yet work on a
+    per-channel basis.  The default is ``"UTC"``
+
+``update_realtime``
+    If this is set to true (default false), then upon each line being
+    input, the ``Text`` writer will rewrite the data.  This means that
+    people joining the meeting late can catch up on what they have
+    missed.  It doesn't play will with the #meetingname command, since
+    the filename would then change during the meeting, and it doesn't
+    delete the old filename(s).
+
+``startMeetingMessage``
+
+``endMeetingMessage``
+    Message printed at the beginning/end of the meetings.  Some
+    ``%(name)s`` replacements are available: ``chair``, ``starttime``,
+    ``timeZone``, ``endtime``, ``MeetBotInfoURL``, ``urlBasename``.
+
+``input_codec``
+
+``output_codec``
+    Input and output character set encodings.
+
+``writer_map``
+    See the description in the section above.
+
+``cssFile_minutes`` and ``cssFile_log``
+
+    If given, this is a file containing CSS for the .html and
+    .log.html outputs (HTML2 and HTMLlog2 writers).  Embedding control
+    is described below.
+
+    If this value is the null string or 'default', then the default
+    CSS is used (see css-\*-default.css in the MeetBot distribution).
+    If this value is 'none', then no stylesheet information is written
+    whatsoever.
+
+    Note that for when embedded (see below), ``cssFile`` should be a
+    filesystem path readable locally.  When you are *not* embedding,
+    ``cssFile`` should be the URL to the stylesheet, and this value
+    given is included literally to the output.
+
+``cssEmbed_minutes`` and ``cssEmbed_log``
+
+    If these are True, then the contents of ``cssFile`` (above) are
+    read and embedded into the HTML document.  If these are False,
+    then a stylesheet link is written.
+
+
+
+Advanced configuration
+~~~~~~~~~~~~~~~~~~~~~~
+
+This gives a few examples of things you can do via
+``meetingLocalConfig.py``.  Most people probably won't need these
+things, and they aren't thoroughly explained here.
+
+You can make a per-channel config::
+
+    class Config(object):
+        def init_hook(self):
+            if self.M.channel == '#some-channel':
+                self.logFileDir = '/some/directory'
+            else:
+                self.logFileDir = '/some/other/directory'
+
+Make a per-channel writer_map (note that you shouldn't change
+writer_map in place)::
+
+    import writers
+    class Config(object):
+        def init_hook(self):
+            if self.M.channel == '#some-channel':
+                self.writer_map = self.writer_map.copy()
+                self.writer_map['.mw'] = writers.MediaWiki
+
+
+The display styles (in html writers) can be modified also, by using
+the starthtml and endhtml attributes (put this in
+meetingLocalConfig.py::
+
+    import items
+    items.Agreed.starthtml = '<font color="red">'
+    items.Agreed.endhtml = '</font>'
+
+
+Adding a new custom command via ``meetingLocalConfig.py``.  (This
+likely won't make sense unless you examine the code a bit and know
+esoteric things about python method types)::
+
+    import types
+    class Config(object):
+        def init(self):
+            def do_party(self, nick, time_, **kwargs):
+                self.reply("We're having a party in this code!")
+                self.reply(("Owner, Chairs: %s %s"%(
+                                     self.owner,sorted(self.chairs.keys()))))
+            self.M.do_party = types.MethodType(
+                                           do_party, self.M, self.M.__class__)
+
+
+Make a command alias.  Make ``#weagree`` an alias for ``#agreed``::
+
+    class Config(object):
+        def init(self):
+	    self.M.do_weagree = self.M.do_agreed
+
+
+
+
+Supybot admin commands
+----------------------
+
+These commands are for the bot owners to manage all meetings served by
+their bot.  The expected use of these commands is when the bot is on
+many channels as a public service, and the bot owner sometimes needs
+to be able to monitor and adjust the overall situation, even if she is
+not the chair of a meeting.
+
+All of these are regular supybot commands (as opposed to the commands
+above).  That means that the supybot capability system applies, and
+they can be given either in any channel, either by direct address
+(``BotName: <command> <args> ...``) or with the bot prefix character
+(e.g. ``@<commandname> <args> ...``).  If there are commands with the
+same name in multiple plugins, you need to prefix the command with the
+plugin name (for example, ``BotName: meetbot recent`` instead of
+``BotName: recent``)
+
+These are restricted to anyone with the ``admin`` capability on the
+bot.
+
+``listmeetings``
+  List all meetings.
+
+``savemeetings``
+  Saves all active meetings on all channels and all networks.
+
+``addchair <channel> <network> <nick>``
+  Forcibly adds this nick as a chair on the giver channel on the given
+  network, if a meeting is active there.
+
+``deletemeeting <channel> <network> <saveit=True>``
+  Delete a meeting from the cache.  If save is given, save the meeting
+  first.  The default value of ``save`` is True.  This is useful for
+  when MeetBot becomes broken and is unable to properly save a
+  meeting, rendering the ``#endmeeting`` command non-functional.
+
+``recent``
+  Display the last ten or so started meetings, along with their
+  channels.  This is useful if you are the bot admin and want to see
+  just who all is using your bot, for example to better communicate
+  with those channels.
+
+To connect to multiple IRC networks, use the supybot ``Network``
+plugin to manage them.  First, load the ``Network`` plugin, then use
+the ``connect`` command to connect to the other network.  Finally, you
+need to tell supybot to join channels on the new.  To do
+that, you can use ``network command <other_network> join <channel>``.
+(Alternatively, you can /msg the bot through the other network, but
+you'll have to register your nick to it on the other network in order
+for it to accept commands from you.)
+
+
+
+
+Developers
+==========
+
+To speak with other developers and users, please join ``#meetbot`` on
+*irc.oftc.net*.
+
+Code contributions to MeetBot are encouraged, but you probably want to
+check with others in #meetbot first to discuss general plans.
+
+Architecture
+------------
+
+MeetBot is primarily used as a supybot plugin, however, it is designed
+to not be limited to use with supybot.  Thus, there are some design
+choices which are slightly more complicated.
+
+``meeting.py`` contains the core of the MeetBot code.  Most meeting
+functionality modifications would begin here.
+
+* The ``Meeting`` and ``MeetingCommands`` are the core of the meeting
+  loop.
+* The ``Config`` class stores all of the local configuration
+  information.  An implicit subclass of this done for local
+  configuration.  A proxy is set up for the ``Config`` class to engage
+  in the supybot-based configuration (``supybotconfig.py``).
+
+``items.py`` contains MeetingItem objects of different classes.  These
+hold data about different #commands, most importantly their formatting
+information.
+
+``writers.py`` contains the code to write the output files.  It
+depends on the objects in ``items.py`` to be able to format
+themselves, and the various classes in here
+
+``plugin.py``, ``config.py``, ``test.py``, ``__init__.py`` are all
+supybot based files.  (yes, the supybot/not-supybot split is not as
+rigorous as it should be).  All of the supybot commands to interact
+with the meeting and send lines to the ``Meeting`` object are in
+``plugin.py``.  If you want to add a *supybot*-based feature, this
+would be the place to start.
+
+
+Source control
+--------------
+
+To get a copy of the repo, the first time, use the **get** command::
+
+    darcs get http://code.zgib.net/MeetBot/                        # dev
+    darcs get http://darcs.debian.org/darcs/collab-maint/MeetBot/  # stable
+
+After that, to get code updates, use the **pull** command::
+
+    darcs get http://code.zgib.net/MeetBot/                        # dev
+    darcs get http://darcs.debian.org/darcs/collab-maint/MeetBot/  # stable
+
+Darcs truly supports "cherry-picking": you can pull patches from
+either branch at will (They will be kept synchronized enough so that
+this works).  You may skip any patches you do not desire, and pull any
+later patches as long as you have all earlier dependencies.
+
+To send code back, you can use ``darcs diff -u`` for a simple
+strategy, or you may record and send actual darcs patches.  To
+**record** darcs patches at first::
+
+    darcs record     # 1) interactively select the group of changes
+                     #    (y/n questions)
+                     # 2) Enter a patch name.  Say yes for entering a
+                     #    long coment
+                     # 3) Enter in a descriptive comment.  See other
+                     #    patches for a model, but I tend to use a
+                     #    bulleted list.
+
+The **send** command will send a patch to the developers via a HTTP
+POST::
+
+    darcs send http://code.zgib.net/MeetBot/
+
+If it is not signed with an authorized PGP key, it will be forwarded
+to the developers, and the developers can manually approve and apply
+the patch.  Developers can have their PGP key added.
+
+There are many other useful darcs commands.  Discuss on ``#meetbot``
+if you would like to find out more.
+
+The darcs **push** command is the counterpart to ``pull``, and used
+to move changes around when you have direct write access to the remote
+repository.
+
+
+
+Help and support
+================
+
+The channel ``#meetbot`` on *irc.oftc.net* is the best place to go.

diff --git a/bot/doc/meetingLocalConfig-example.py b/bot/doc/meetingLocalConfig-example.py
new file mode 100644
index 0000000..3263eb6
--- /dev/null
+++ b/bot/doc/meetingLocalConfig-example.py
@@ -0,0 +1,19 @@
+# Richard Darst, July 2009
+#
+# Minimal meetingLocalConfig.py
+#
+# This file is released into the public domain, or released under the
+# supybot license in areas where releasing into the public domain is
+# not possible.
+#
+
+class Config(object):
+    # These are "required":
+    logFileDir = '/home/richard/meetbot/'
+    logUrlPrefix = 'http://rkd.zgib.net/meetbot/'
+
+    # These, you might want to change:
+    #MeetBotInfoURL = 'http://wiki.debian.org/MeetBot'
+    #filenamePattern = '%(channel)s/%%Y/%(channel)s.%%F-%%H.%%M'
+
+

diff --git a/bot/ircmeeting/__init__.py b/bot/ircmeeting/__init__.py
new file mode 100644
index 0000000..e69de29

diff --git a/bot/ircmeeting/css-log-default.css b/bot/ircmeeting/css-log-default.css
new file mode 100644
index 0000000..ee7c6a3
--- /dev/null
+++ b/bot/ircmeeting/css-log-default.css
@@ -0,0 +1,15 @@
+/* For the .log.html */
+pre { /*line-height: 125%;*/
+      white-space: pre-wrap; }
+body { background: #f0f0f0; }
+
+body .tm  { color: #007020 }                      /* time */
+body .nk  { color: #062873; font-weight: bold }   /* nick, regular */
+body .nka { color: #007020; font-weight: bold }  /* action nick */
+body .ac  { color: #00A000 }                      /* action line */
+body .hi  { color: #4070a0 }                 /* hilights */
+/* Things to make particular MeetBot commands stick out */
+body .topic     { color: #007020; font-weight: bold }
+body .topicline { color: #000080; font-weight: bold }
+body .cmd       { color: #007020; font-weight: bold }
+body .cmdline  { font-weight: bold }

diff --git a/bot/ircmeeting/css-minutes-default.css b/bot/ircmeeting/css-minutes-default.css
new file mode 100644
index 0000000..c383b14
--- /dev/null
+++ b/bot/ircmeeting/css-minutes-default.css
@@ -0,0 +1,34 @@
+/* This is for the .html in the HTML2 writer */
+body {
+    font-family: Helvetica, sans-serif;
+    font-size:14px;
+}
+h1 {
+    text-align: center;
+}
+a {
+    color:navy;
+    text-decoration: none;
+    border-bottom:1px dotted navy;
+}
+a:hover {
+    text-decoration:none;
+    border-bottom: 0;
+    color:#0000B9;
+}
+hr {
+    border: 1px solid #ccc;
+}
+/* The (nick, time) item pairs, and other body text things. */
+.details {
+    font-size: 12px;
+    font-weight:bold;
+}
+/* The 'AGREED:', 'IDEA', etc, prefix to lines. */
+.itemtype {
+    font-style: normal;    /* un-italics it */
+    font-weight: bold;
+}
+/* Example: change single item types.  Capitalized command name.
+/* .TOPIC  {  color:navy;  } */
+/* .AGREED {  color:lime;  } */

diff --git a/bot/ircmeeting/items.py b/bot/ircmeeting/items.py
new file mode 100644
index 0000000..1109fb0
--- /dev/null
+++ b/bot/ircmeeting/items.py
@@ -0,0 +1,292 @@
+# Richard Darst, June 2009
+
+###
+# Copyright (c) 2009, Richard Darst
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#   * Redistributions of source code must retain the above copyright notice,
+#     this list of conditions, and the following disclaimer.
+#   * Redistributions in binary form must reproduce the above copyright notice,
+#     this list of conditions, and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#   * Neither the name of the author of this software nor the name of
+#     contributors to this software may be used to endorse or promote products
+#     derived from this software without specific prior written consent.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+###
+
+import os
+import re
+import time
+
+import writers
+#from writers import html, rst
+import itertools
+
+def inbase(i, chars='abcdefghijklmnopqrstuvwxyz', place=0):
+    """Converts an integer into a postfix in base 26 using ascii chars.
+
+    This is used to make a unique postfix for ReStructured Text URL
+    references, which must be unique.  (Yes, this is over-engineering,
+    but keeps it short and nicely arranged, and I want practice
+    writing recursive functions.)
+    """
+    div, mod = divmod(i, len(chars)**(place+1))
+    if div == 0:
+        return chars[mod]
+    else:
+        return inbase2(div, chars=chars, place=place+1)+chars[mod]
+
+
+
+#
+# These are objects which we can add to the meeting minutes.  Mainly
+# they exist to aid in HTML-formatting.
+#
+class _BaseItem(object):
+    itemtype = None
+    starthtml = ''
+    endhtml = ''
+    startrst = ''
+    endrst = ''
+    starttext = ''
+    endtext = ''
+    startmw = ''
+    endmw = ''
+    def get_replacements(self, M, escapewith):
+        replacements = { }
+        for name in dir(self):
+            if name[0] == "_": continue
+            replacements[name] = getattr(self, name)
+        replacements['nick'] = escapewith(replacements['nick'])
+        replacements['link'] = self.logURL(M)
+        for key in ('line', 'prefix', 'suffix', 'topic'):
+            if key in replacements:
+                replacements[key] = escapewith(replacements[key])
+        if 'url' in replacements:
+            replacements['url_quoteescaped'] = \
+                                      escapewith(self.url.replace('"', "%22"))
+
+        return replacements
+    def template(self, M, escapewith):
+        template = { }
+        for k,v in self.get_replacements(M, escapewith).iteritems():
+            if k not in ('itemtype', 'line', 'topic',
+                         'url', 'url_quoteescaped',
+                         'nick', 'time', 'link', 'anchor'):
+                continue
+            template[k] = v
+        return template
+    def makeRSTref(self, M):
+        if self.nick[-1] == '_':
+            rstref = rstref_orig = "%s%s"%(self.nick, self.time)
+        else:
+            rstref = rstref_orig = "%s-%s"%(self.nick, self.time)
+        count = 0
+        while rstref in M.rst_refs:
+            rstref = rstref_orig + inbase(count)
+            count += 1
+        link = self.logURL(M)
+        M.rst_urls.append(".. _%s: %s"%(rstref, link+"#"+self.anchor))
+        M.rst_refs[rstref] = True
+        return rstref
+    @property
+    def anchor(self):
+        return 'l-'+str(self.linenum)
+    def logURL(self, M):
+        return M.config.basename+'.log.html'
+
+class Topic(_BaseItem):
+    itemtype = 'TOPIC'
+    html_template = """<tr><td><a href='%(link)s#%(anchor)s'>%(time)s</a></td>
+        <th colspan=3>%(starthtml)sTopic: %(topic)s%(endhtml)s</th>
+        </tr>"""
+    #html2_template = ("""<b>%(starthtml)s%(topic)s%(endhtml)s</b> """
+    #                  """(%(nick)s, <a href='%(link)s#%(anchor)s'>%(time)s</a>)""")
+    html2_template = ("""%(starthtml)s%(topic)s%(endhtml)s """
+                      """<span class="details">"""
+                      """(<a href='%(link)s#%(anchor)s'>%(nick)s</a>, """
+                      """%(time)s)"""
+                      """</span>""")
+    rst_template = """%(startrst)s%(topic)s%(endrst)s  (%(rstref)s_)"""
+    text_template = """%(starttext)s%(topic)s%(endtext)s  (%(nick)s, %(time)s)"""
+    mw_template = """%(startmw)s%(topic)s%(endmw)s  (%(nick)s, %(time)s)"""
+    startrst = '**'
+    endrst = '**'
+    startmw = "'''"
+    endmw = "'''"
+    starthtml = '<b class="TOPIC">'
+    endhtml = '</b>'
+    def __init__(self, nick, line, linenum, time_):
+        self.nick = nick ; self.topic = line ; self.linenum = linenum
+        self.time = time.strftime("%H:%M:%S", time_)
+    def _htmlrepl(self, M):
+        repl = self.get_replacements(M, escapewith=writers.html)
+        repl['link'] = self.logURL(M)
+        return repl
+    def html(self, M):
+        return self.html_template%self._htmlrepl(M)
+    def html2(self, M):
+        return self.html2_template%self._htmlrepl(M)
+    def rst(self, M):
+        self.rstref = self.makeRSTref(M)
+        repl = self.get_replacements(M, escapewith=writers.rst)
+        if repl['topic']=='': repl['topic']=' '
+        repl['link'] = self.logURL(M)
+        return self.rst_template%repl
+    def text(self, M):
+        repl = self.get_replacements(M, escapewith=writers.text)
+        repl['link'] = self.logURL(M)
+        return self.text_template%repl
+    def mw(self, M):
+        repl = self.get_replacements(M, escapewith=writers.mw)
+        return self.mw_template%repl
+
+class GenericItem(_BaseItem):
+    itemtype = ''
+    html_template = """<tr><td><a href='%(link)s#%(anchor)s'>%(time)s</a></td>
+        <td>%(itemtype)s</td><td>%(nick)s</td><td>%(starthtml)s%(line)s%(endhtml)s</td>
+        </tr>"""
+    #html2_template = ("""<i>%(itemtype)s</i>: %(starthtml)s%(line)s%(endhtml)s """
+    #                  """(%(nick)s, <a href='%(link)s#%(anchor)s'>%(time)s</a>)""")
+    html2_template = ("""<i class="itemtype">%(itemtype)s</i>: """
+                      """<span class="%(itemtype)s">"""
+                      """%(starthtml)s%(line)s%(endhtml)s</span> """
+                      """<span class="details">"""
+                      """(<a href='%(link)s#%(anchor)s'>%(nick)s</a>, """
+                      """%(time)s)"""
+                      """</span>""")
+    rst_template = """*%(itemtype)s*: %(startrst)s%(line)s%(endrst)s  (%(rstref)s_)"""
+    text_template = """%(itemtype)s: %(starttext)s%(line)s%(endtext)s  (%(nick)s, %(time)s)"""
+    mw_template = """''%(itemtype)s:'' %(startmw)s%(line)s%(endmw)s  (%(nick)s, %(time)s)"""
+    def __init__(self, nick, line, linenum, time_):
+        self.nick = nick ; self.line = line ; self.linenum = linenum
+        self.time = time.strftime("%H:%M:%S", time_)
+    def _htmlrepl(self, M):
+        repl = self.get_replacements(M, escapewith=writers.html)
+        repl['link'] = self.logURL(M)
+        return repl
+    def html(self, M):
+        return self.html_template%self._htmlrepl(M)
+    def html2(self, M):
+        return self.html2_template%self._htmlrepl(M)
+    def rst(self, M):
+        self.rstref = self.makeRSTref(M)
+        repl = self.get_replacements(M, escapewith=writers.rst)
+        repl['link'] = self.logURL(M)
+        return self.rst_template%repl
+    def text(self, M):
+        repl = self.get_replacements(M, escapewith=writers.text)
+        repl['link'] = self.logURL(M)
+        return self.text_template%repl
+    def mw(self, M):
+        repl = self.get_replacements(M, escapewith=writers.mw)
+        return self.mw_template%repl
+
+
+class Info(GenericItem):
+    itemtype = 'INFO'
+    html2_template = ("""<span class="%(itemtype)s">"""
+                      """%(starthtml)s%(line)s%(endhtml)s</span> """
+                      """<span class="details">"""
+                      """(<a href='%(link)s#%(anchor)s'>%(nick)s</a>, """
+                      """%(time)s)"""
+                      """</span>""")
+    rst_template = """%(startrst)s%(line)s%(endrst)s  (%(rstref)s_)"""
+    text_template = """%(starttext)s%(line)s%(endtext)s  (%(nick)s, %(time)s)"""
+    mw_template = """%(startmw)s%(line)s%(endmw)s  (%(nick)s, %(time)s)"""
+class Idea(GenericItem):
+    itemtype = 'IDEA'
+class Agreed(GenericItem):
+    itemtype = 'AGREED'
+class Action(GenericItem):
+    itemtype = 'ACTION'
+class Help(GenericItem):
+    itemtype = 'HELP'
+class Accepted(GenericItem):
+    itemtype = 'ACCEPTED'
+    starthtml = '<font color="green">'
+    endhtml = '</font>'
+class Rejected(GenericItem):
+    itemtype = 'REJECTED'
+    starthtml = '<font color="red">'
+    endhtml = '</font>'
+class Link(_BaseItem):
+    itemtype = 'LINK'
+    html_template = """<tr><td><a href='%(link)s#%(anchor)s'>%(time)s</a></td>
+        <td>%(itemtype)s</td><td>%(nick)s</td><td>%(starthtml)s%(prefix)s<a href="%(url)s">%(url_readable)s</a>%(suffix)s%(endhtml)s</td>
+        </tr>"""
+    html2_template = ("""%(starthtml)s%(prefix)s<a href="%(url)s">%(url_readable)s</a>%(suffix)s%(endhtml)s """
+                      """<span class="details">"""
+                      """(<a href='%(link)s#%(anchor)s'>%(nick)s</a>, """
+                      """%(time)s)"""
+                      """</span>""")
+    rst_template = """*%(itemtype)s*: %(startrst)s%(prefix)s%(url)s%(suffix)s%(endrst)s  (%(rstref)s_)"""
+    text_template = """%(itemtype)s: %(starttext)s%(prefix)s%(url)s%(suffix)s%(endtext)s  (%(nick)s, %(time)s)"""
+    mw_template = """''%(itemtype)s:'' %(startmw)s%(prefix)s%(url)s%(suffix)s%(endmw)s  (%(nick)s, %(time)s)"""
+    def __init__(self, nick, line, linenum, time_, M):
+        self.nick = nick ; self.linenum = linenum
+        self.time = time.strftime("%H:%M:%S", time_)
+        self.line = line
+
+        protocols = M.config.UrlProtocols
+        protocols = '|'.join(re.escape(p) for p in protocols)
+        protocols = '(?:'+protocols+')'
+        # This is gross.
+        # (.*?)          - any prefix, non-greedy
+        # (%s//[^\s]+    - protocol://... until the next space
+        # (?<!\.|\))     - but the last character can NOT be . or )
+        # (.*)           - any suffix
+        url_re = re.compile(r'(.*?)(%s//[^\s]+(?<!\.|\)))(.*)'%protocols)
+        m = url_re.match(line)
+        if m:
+            self.prefix = m.group(1)
+            self.url    = m.group(2)
+            self.suffix = m.group(3)
+        else:
+            # simple matching, the old way.
+            self.url, self.suffix = (line+' ').split(' ', 1)
+            self.suffix = ' '+self.suffix
+            self.prefix = ''
+        # URL-sanitization
+        self.url_readable = self.url # readable line version
+        self.url = self.url
+        self.line = self.line.strip()
+    def _htmlrepl(self, M):
+        repl = self.get_replacements(M, escapewith=writers.html)
+        # special: replace doublequote only for the URL.
+        repl['url'] = writers.html(self.url.replace('"', "%22"))
+        repl['url_readable'] = writers.html(self.url)
+        repl['link'] = self.logURL(M)
+        return repl
+    def html(self, M):
+        return self.html_template%self._htmlrepl(M)
+    def html2(self, M):
+        return self.html2_template%self._htmlrepl(M)
+    def rst(self, M):
+        self.rstref = self.makeRSTref(M)
+        repl = self.get_replacements(M, escapewith=writers.rst)
+        repl['link'] = self.logURL(M)
+        #repl['url'] = writers.rst(self.url)
+        return self.rst_template%repl
+    def text(self, M):
+        repl = self.get_replacements(M, escapewith=writers.text)
+        repl['link'] = self.logURL(M)
+        return self.text_template%repl
+    def mw(self, M):
+        repl = self.get_replacements(M, escapewith=writers.mw)
+        return self.mw_template%repl

diff --git a/bot/ircmeeting/meeting.py b/bot/ircmeeting/meeting.py
new file mode 100644
index 0000000..85880a6
--- /dev/null
+++ b/bot/ircmeeting/meeting.py
@@ -0,0 +1,672 @@
+# Richard Darst, May 2009
+
+###
+# Copyright (c) 2009, Richard Darst
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#   * Redistributions of source code must retain the above copyright notice,
+#     this list of conditions, and the following disclaimer.
+#   * Redistributions in binary form must reproduce the above copyright notice,
+#     this list of conditions, and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#   * Neither the name of the author of this software nor the name of
+#     contributors to this software may be used to endorse or promote products
+#     derived from this software without specific prior written consent.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+###
+
+import time
+import os
+import re
+import stat
+
+import writers
+import items
+reload(writers)
+reload(items)
+
+__version__ = "0.1.4"
+
+class Config(object):
+    #
+    # Throw any overrides into meetingLocalConfig.py in this directory:
+    #
+    # Where to store files on disk
+    # Example:   logFileDir = '/home/richard/meetbot/'
+    logFileDir = '.'
+    # The links to the logfiles are given this prefix
+    # Example:   logUrlPrefix = 'http://rkd.zgib.net/meetbot/'
+    logUrlPrefix = ''
+    # Give the pattern to save files into here.  Use %(channel)s for
+    # channel.  This will be sent through strftime for substituting it
+    # times, howover, for strftime codes you must use doubled percent
+    # signs (%%).  This will be joined with the directories above.
+    filenamePattern = '%(channel)s/%%Y/%(channel)s.%%F-%%H.%%M'
+    # Where to say to go for more information about MeetBot
+    MeetBotInfoURL = 'http://wiki.debian.org/MeetBot'
+    # This is used with the #restrict command to remove permissions from files.
+    RestrictPerm = stat.S_IRWXO|stat.S_IRWXG  # g,o perm zeroed
+    # RestrictPerm = stat.S_IRWXU|stat.S_IRWXO|stat.S_IRWXG  #u,g,o perm zeroed
+    # used to detect #link :
+    UrlProtocols = ('http:', 'https:', 'irc:', 'ftp:', 'mailto:', 'ssh:')
+    # regular expression for parsing commands.  First group is the cmd name,
+    # second group is the rest of the line.
+    command_RE = re.compile(r'#([\w]+)[ \t]*(.*)')
+    # The channels which won't have date/time appended to the filename.
+    specialChannels = ("#meetbot-test", "#meetbot-test2")
+    specialChannelFilenamePattern = '%(channel)s/%(channel)s'
+    # HTML irc log highlighting style.  `pygmentize -L styles` to list.
+    pygmentizeStyle = 'friendly'
+    # Timezone setting.  You can use friendly names like 'US/Eastern', etc.
+    # Check /usr/share/zoneinfo/ .  Or `man timezone`: this is the contents
+    # of the TZ environment variable.
+    timeZone = 'UTC'
+    # These are the start and end meeting messages, respectively.
+    # Some replacements are done before they are used, using the
+    # %(name)s syntax.  Note that since one replacement is done below,
+    # you have to use doubled percent signs.  Also, it gets split by
+    # '\n' and each part between newlines get said in a separate IRC
+    # message.
+    startMeetingMessage = ("Meeting started %(starttime)s %(timeZone)s.  "
+              "The chair is %(chair)s. Information about MeetBot at "
+              "%(MeetBotInfoURL)s.\n"
+              "Useful Commands: #action #agreed #help #info #idea #link "
+              "#topic.")
+    endMeetingMessage = ("Meeting ended %(endtime)s %(timeZone)s.  "
+                         "Information about MeetBot at %(MeetBotInfoURL)s . "
+                         "(v %(__version__)s)\n"
+                         "Minutes:        %(urlBasename)s.html\n"
+                         "Minutes (text): %(urlBasename)s.txt\n"
+                         "Log:            %(urlBasename)s.log.html")
+    # Input/output codecs.
+    input_codec = 'utf-8'
+    output_codec = 'utf-8'
+    # Functions to do the i/o conversion.
+    def enc(self, text):
+        return text.encode(self.output_codec, 'replace')
+    def dec(self, text):
+        return text.decode(self.input_codec, 'replace')
+    # Write out select logfiles
+    update_realtime = True
+    # CSS configs:
+    cssFile_log      = 'default'
+    cssEmbed_log     = True
+    cssFile_minutes  = 'default'
+    cssEmbed_minutes = True
+
+    # This tells which writers write out which to extensions.
+    writer_map = {
+        '.log.html':writers.HTMLlog,
+        #'.1.html': writers.HTML,
+        '.html': writers.HTML2,
+        #'.rst': writers.ReST,
+        '.txt': writers.Text,
+        #'.rst.html':writers.HTMLfromReST,
+        }
+
+
+    def __init__(self, M, writeRawLog=False, safeMode=False,
+                 extraConfig={}):
+        self.M = M
+        self.writers = { }
+        # Update config values with anything we may have
+        for k,v in extraConfig.iteritems():
+            setattr(self, k, v)
+
+        if hasattr(self, "init_hook"):
+            self.init_hook()
+        if writeRawLog:
+            self.writers['.log.txt'] = writers.TextLog(self.M)
+        for extension, writer in self.writer_map.iteritems():
+            self.writers[extension] = writer(self.M)
+        self.safeMode = safeMode
+    def filename(self, url=False):
+        # provide a way to override the filename.  If it is
+        # overridden, it must be a full path (and the URL-part may not
+        # work.):
+        if getattr(self.M, '_filename', None):
+            return self.M._filename
+        # names useful for pathname formatting.
+        # Certain test channels always get the same name - don't need
+        # file prolifiration for them
+        if self.M.channel in self.specialChannels:
+            pattern = self.specialChannelFilenamePattern
+        else:
+            pattern = self.filenamePattern
+        channel = self.M.channel.strip('# ').lower().replace('/', '')
+        network = self.M.network.strip(' ').lower().replace('/', '')
+        if self.M._meetingname:
+            meetingname = self.M._meetingname.replace('/', '')
+        else:
+            meetingname = channel
+        path = pattern%{'channel':channel, 'network':network,
+                        'meetingname':meetingname}
+        path = time.strftime(path, self.M.starttime)
+        # If we want the URL name, append URL prefix and return
+        if url:
+            return os.path.join(self.logUrlPrefix, path)
+        path = os.path.join(self.logFileDir, path)
+        # make directory if it doesn't exist...
+        dirname = os.path.dirname(path)
+        if not url and dirname and not os.access(dirname, os.F_OK):
+            os.makedirs(dirname)
+        return path
+    @property
+    def basename(self):
+        return os.path.basename(self.M.config.filename())
+
+    def save(self, realtime_update=False):
+        """Write all output files.
+
+        If `realtime_update` is true, then this isn't a complete save,
+        it will only update those writers with the update_realtime
+        attribute true.  (default update_realtime=False for this method)"""
+        if realtime_update and not hasattr(self.M, 'starttime'):
+            return
+        rawname = self.filename()
+        # We want to write the rawlog (.log.txt) first in case the
+        # other methods break.  That way, we have saved enough to
+        # replay.
+        writer_names = list(self.writers.keys())
+        results = { }
+        if '.log.txt' in writer_names:
+            writer_names.remove('.log.txt')
+            writer_names = ['.log.txt'] + writer_names
+        for extension in writer_names:
+            writer = self.writers[extension]
+            # Why this?  If this is a realtime (step-by-step) update,
+            # then we only want to update those writers which say they
+            # should be updated step-by-step.
+            if (realtime_update and
+                ( not getattr(writer, 'update_realtime', False) or
+                  getattr(self, '_filename', None) )
+                ):
+                continue
+            # Parse embedded arguments
+            if '|' in extension:
+                extension, args = extension.split('|', 1)
+                args = args.split('|')
+                args = dict([a.split('=', 1) for a in args] )
+            else:
+                args = { }
+
+            text = writer.format(extension, **args)
+            results[extension] = text
+            # If the writer returns a string or unicode object, then
+            # we should write it to a filename with that extension.
+            # If it doesn't, then it's assumed that the write took
+            # care of writing (or publishing or emailing or wikifying)
+            # it itself.
+            if isinstance(text, unicode):
+                text = self.enc(text)
+            if isinstance(text, (str, unicode)):
+                # Have a way to override saving, so no disk files are written.
+                if getattr(self, "dontSave", False):
+                    pass
+                # ".none" or a single "." disable writing.
+                elif extension.lower()[:5] in (".none", "."):
+                    pass
+                else:
+                    filename = rawname + extension
+                    self.writeToFile(text, filename)
+        if hasattr(self, 'save_hook'):
+            self.save_hook(realtime_update=realtime_update)
+        return results
+    def writeToFile(self, string, filename):
+        """Write a given string to a file"""
+        # The reason we have this method just for this is to proxy
+        # through the _restrictPermissions logic.
+        f = open(filename, 'w')
+        if self.M._restrictlogs:
+            self.restrictPermissions(f)
+        f.write(string)
+        f.close()
+    def restrictPermissions(self, f):
+        """Remove the permissions given in the variable RestrictPerm."""
+        f.flush()
+        newmode = os.stat(f.name).st_mode & (~self.RestrictPerm)
+        os.chmod(f.name, newmode)
+    def findFile(self, fname):
+        """Find template files by searching paths.
+
+        Expand '+' prefix to the base data directory.
+        """
+        # If `template` begins in '+', then it in relative to the
+        # MeetBot source directory.
+        if fname[0] == '+':
+            basedir = os.path.dirname(__file__)
+            fname = os.path.join(basedir, fname[1:])
+        # If we don't test here, it might fail in the try: block
+        # below, then f.close() will fail and mask the original
+        # exception
+        if not os.access(fname, os.F_OK):
+            raise IOError('File not found: %s'%fname)
+        return fname
+
+
+
+# Set the timezone, using the variable above
+os.environ['TZ'] = Config.timeZone
+time.tzset()
+
+# load custom local configurations
+LocalConfig = None
+import __main__
+# Two conditions where we do NOT load any local configuration files
+if getattr(__main__, 'running_tests', False): pass
+elif 'MEETBOT_RUNNING_TESTS' in os.environ:   pass
+else:
+    # First source of config: try just plain importing it
+    try:
+        import meetingLocalConfig
+        meetingLocalConfig = reload(meetingLocalConfig)
+        if hasattr(meetingLocalConfig, 'Config'):
+            LocalConfig = meetingLocalConfig.Config
+    except ImportError:
+        pass
+    if LocalConfig is None:
+        for dirname in (os.path.dirname("__file__"), "."):
+            fname = os.path.join(dirname, "meetingLocalConfig.py")
+            if os.access(fname, os.F_OK):
+                meetingLocalConfig = { }
+                execfile(fname, meetingLocalConfig)
+                LocalConfig = meetingLocalConfig["Config"]
+                break
+    if LocalConfig is not None:
+        # Subclass Config and LocalConfig, new type overrides Config.
+        Config = type('Config', (LocalConfig, Config), {})
+
+
+class MeetingCommands(object):
+    # Command Definitions
+    # generic parameters to these functions:
+    #  nick=
+    #  line=    <the payload of the line>
+    #  linenum= <the line number, 1-based index (for logfile)>
+    #  time_=   <time it was said>
+    # Commands for Chairs:
+    def do_startmeeting(self, nick, time_, line, **kwargs):
+        """Begin a meeting."""
+        self.starttime = time_
+        repl = self.replacements()
+        message = self.config.startMeetingMessage%repl
+        for messageline in message.split('\n'):
+            self.reply(messageline)
+        if line.strip():
+            self.do_meetingtopic(nick=nick, line=line, time_=time_, **kwargs)
+    def do_endmeeting(self, nick, time_, **kwargs):
+        """End the meeting."""
+        if not self.isChair(nick): return
+        if self.oldtopic:
+            self.topic(self.oldtopic)
+        self.endtime = time_
+        self.config.save()
+        repl = self.replacements()
+        message = self.config.endMeetingMessage%repl
+        for messageline in message.split('\n'):
+            self.reply(messageline)
+        self._meetingIsOver = True
+    def do_topic(self, nick, line, **kwargs):
+        """Set a new topic in the channel."""
+        if not self.isChair(nick): return
+        self.currenttopic = line
+        m = items.Topic(nick=nick, line=line, **kwargs)
+        self.additem(m)
+        self.settopic()
+    def do_meetingtopic(self, nick, line, **kwargs):
+        """Set a meeting topic (included in all sub-topics)"""
+        if not self.isChair(nick): return
+        line = line.strip()
+        if line == '' or line.lower() == 'none' or line.lower() == 'unset':
+            self._meetingTopic = None
+        else:
+            self._meetingTopic = line
+        self.settopic()
+    def do_save(self, nick, time_, **kwargs):
+        """Add a chair to the meeting."""
+        if not self.isChair(nick): return
+        self.endtime = time_
+        self.config.save()
+    def do_agreed(self, nick, **kwargs):
+        """Add aggreement to the minutes - chairs only."""
+        if not self.isChair(nick): return
+        m = items.Agreed(nick, **kwargs)
+        self.additem(m)
+    do_agree = do_agreed
+    def do_accepted(self, nick, **kwargs):
+        """Add aggreement to the minutes - chairs only."""
+        if not self.isChair(nick): return
+        m = items.Accepted(nick, **kwargs)
+        self.additem(m)
+    do_accept = do_accepted
+    def do_rejected(self, nick, **kwargs):
+        """Add aggreement to the minutes - chairs only."""
+        if not self.isChair(nick): return
+        m = items.Rejected(nick, **kwargs)
+        self.additem(m)
+    do_reject = do_rejected
+    def do_chair(self, nick, line, **kwargs):
+        """Add a chair to the meeting."""
+        if not self.isChair(nick): return
+        for chair in re.split('[, ]+', line.strip()):
+            chair = chair.strip()
+            if not chair: continue
+            if chair not in self.chairs:
+                if self._channelNicks is not None and \
+                       ( chair.encode(self.config.input_codec)
+                         not in self._channelNicks()):
+                    self.reply("Warning: Nick not in channel: %s"%chair)
+                self.addnick(chair, lines=0)
+                self.chairs.setdefault(chair, True)
+        chairs = dict(self.chairs) # make a copy
+        chairs.setdefault(self.owner, True)
+        self.reply("Current chairs: %s"%(" ".join(sorted(chairs.keys()))))
+    def do_unchair(self, nick, line, **kwargs):
+        """Remove a chair to the meeting (founder can not be removed)."""
+        if not self.isChair(nick): return
+        for chair in line.strip().split():
+            chair = chair.strip()
+            if chair in self.chairs:
+                del self.chairs[chair]
+        chairs = dict(self.chairs) # make a copy
+        chairs.setdefault(self.owner, True)
+        self.reply("Current chairs: %s"%(" ".join(sorted(chairs.keys()))))
+    def do_undo(self, nick, **kwargs):
+        """Remove the last item from the minutes."""
+        if not self.isChair(nick): return
+        if len(self.minutes) == 0: return
+        self.reply("Removing item from minutes: %s"%str(self.minutes[-1]))
+        del self.minutes[-1]
+    def do_restrictlogs(self, nick, **kwargs):
+        """When saved, remove permissions from the files."""
+        if not self.isChair(nick): return
+        self._restrictlogs = True
+        self.reply("Restricting permissions on minutes: -%s on next #save"%\
+                   oct(RestrictPerm))
+    def do_lurk(self, nick, **kwargs):
+        """Don't interact in the channel."""
+        if not self.isChair(nick): return
+        self._lurk = True
+    def do_unlurk(self, nick, **kwargs):
+        """Do interact in the channel."""
+        if not self.isChair(nick): return
+        self._lurk = False
+    def do_meetingname(self, nick, time_, line, **kwargs):
+        """Set the variable (meetingname) which can be used in save.
+
+        If this isn't set, it defaults to the channel name."""
+        meetingname = line.strip().lower().replace(" ", "")
+        meetingname = "_".join(line.strip().lower().split())
+        self._meetingname = meetingname
+        self.reply("The meeting name has been set to '%s'"%meetingname)
+    # Commands for Anyone:
+    def do_action(self, **kwargs):
+        """Add action item to the minutes.
+
+        The line is searched for nicks, and a per-person action item
+        list is compiled after the meeting.  Only nicks which have
+        been seen during the meeting will have an action item list
+        made for them, but you can use the #nick command to cause a
+        nick to be seen."""
+        m = items.Action(**kwargs)
+        self.additem(m)
+    def do_info(self, **kwargs):
+        """Add informational item to the minutes."""
+        m = items.Info(**kwargs)
+        self.additem(m)
+    def do_idea(self, **kwargs):
+        """Add informational item to the minutes."""
+        m = items.Idea(**kwargs)
+        self.additem(m)
+    def do_help(self, **kwargs):
+        """Add call for help to the minutes."""
+        m = items.Help(**kwargs)
+        self.additem(m)
+    do_halp = do_help
+    def do_nick(self, nick, line, **kwargs):
+        """Make meetbot aware of a nick which hasn't said anything.
+
+        To see where this can be used, see #action command"""
+        nicks = re.split('[, ]+', line.strip())
+        for nick in nicks:
+            nick = nick.strip()
+            if not nick: continue
+            self.addnick(nick, lines=0)
+    def do_link(self, **kwargs):
+        """Add informational item to the minutes."""
+        m = items.Link(M=self, **kwargs)
+        self.additem(m)
+    def do_commands(self, **kwargs):
+        commands = [ "#"+x[3:] for x in dir(self) if x[:3]=="do_" ]
+        commands.sort()
+        self.reply("Available commands: "+(" ".join(commands)))
+            
+
+
+class Meeting(MeetingCommands, object):
+    _lurk = False
+    _restrictlogs = False
+    def __init__(self, channel, owner, oldtopic=None,
+                 filename=None, writeRawLog=False,
+                 setTopic=None, sendReply=None, getRegistryValue=None,
+                 safeMode=False, channelNicks=None,
+                 extraConfig={}, network='nonetwork'):
+        if getRegistryValue is not None:
+            self._registryValue = getRegistryValue
+        if sendReply is not None:
+            self._sendReply = sendReply
+        if setTopic is not None:
+            self._setTopic = setTopic
+        self.owner = owner
+        self.channel = channel
+        self.network = network
+        self.currenttopic = ""
+        self.config = Config(self, writeRawLog=writeRawLog, safeMode=safeMode,
+                            extraConfig=extraConfig)
+        if oldtopic:
+            self.oldtopic = self.config.dec(oldtopic)
+        else:
+            self.oldtopic = None
+        self.lines = [ ]
+        self.minutes = [ ]
+        self.attendees = { }
+        self.chairs = { }
+        self._writeRawLog = writeRawLog
+        self._meetingTopic = None
+        self._meetingname = ""
+        self._meetingIsOver = False
+        self._channelNicks = channelNicks
+        if filename:
+            self._filename = filename
+
+    # These commands are callbacks to manipulate the IRC protocol.
+    # set self._sendReply and self._setTopic to an callback to do these things.
+    def reply(self, x):
+        """Send a reply to the IRC channel."""
+        if hasattr(self, '_sendReply') and not self._lurk:
+            self._sendReply(self.config.enc(x))
+        else:
+            print "REPLY:", self.config.enc(x)
+    def topic(self, x):
+        """Set the topic in the IRC channel."""
+        if hasattr(self, '_setTopic') and not self._lurk:
+            self._setTopic(self.config.enc(x))
+        else:
+            print "TOPIC:", self.config.enc(x)
+    def settopic(self):
+        "The actual code to set the topic"
+        if self._meetingTopic:
+            topic = '%s (Meeting topic: %s)'%(self.currenttopic,
+                                              self._meetingTopic)
+        else:
+            topic = self.currenttopic
+        self.topic(topic)
+    def addnick(self, nick, lines=1):
+        """This person has spoken, lines=<how many lines>"""
+        self.attendees[nick] = self.attendees.get(nick, 0) + lines
+    def isChair(self, nick):
+        """Is the nick a chair?"""
+        return (nick == self.owner  or  nick in self.chairs)
+    def save(self, **kwargs):
+        return self.config.save(**kwargs)
+    # Primary enttry point for new lines in the log:
+    def addline(self, nick, line, time_=None):
+        """This is the way to add lines to the Meeting object.
+        """
+        linenum = self.addrawline(nick, line, time_)
+
+        if time_ is None: time_ = time.localtime()
+        nick = self.config.dec(nick)
+        line = self.config.dec(line)
+
+        # Handle any commands given in the line.
+        matchobj = self.config.command_RE.match(line)
+        if matchobj is not None:
+            command, line = matchobj.groups()
+            command = command.lower()
+            # to define new commands, define a method do_commandname .
+            if hasattr(self, "do_"+command):
+                getattr(self, "do_"+command)(nick=nick, line=line,
+                                             linenum=linenum, time_=time_)
+        else:
+            # Detect URLs automatically
+            if line.split('//')[0] in self.config.UrlProtocols:
+                self.do_link(nick=nick, line=line,
+                             linenum=linenum, time_=time_)
+        self.save(realtime_update=True)
+
+    def addrawline(self, nick, line, time_=None):
+        """This adds a line to the log, bypassing command execution.
+        """
+        nick = self.config.dec(nick)
+        line = self.config.dec(line)
+        self.addnick(nick)
+        line = line.strip(' \x01') # \x01 is present in ACTIONs
+        # Setting a custom time is useful when replying logs,
+        # otherwise use our current time:
+        if time_ is None: time_ = time.localtime()
+
+        # Handle the logging of the line
+        if line[:6] == 'ACTION':
+            logline = "%s * %s %s"%(time.strftime("%H:%M:%S", time_),
+                                 nick, line[7:].strip())
+        else:
+            logline = "%s <%s> %s"%(time.strftime("%H:%M:%S", time_),
+                                 nick, line.strip())
+        self.lines.append(logline)
+        linenum = len(self.lines)
+        return linenum
+
+    def additem(self, m):
+        """Add an item to the meeting minutes list.
+        """
+        self.minutes.append(m)
+    def replacements(self):
+        repl = { }
+        repl['channel'] = self.channel
+        repl['network'] = self.network
+        repl['MeetBotInfoURL'] = self.config.MeetBotInfoURL
+        repl['timeZone'] = self.config.timeZone
+        repl['starttime'] = repl['endtime'] = "None"
+        if getattr(self, "starttime", None) is not None:
+            repl['starttime'] = time.asctime(self.starttime)
+        if getattr(self, "endtime", None) is not None:
+            repl['endtime'] = time.asctime(self.endtime)
+        repl['__version__'] = __version__
+        repl['chair'] = self.owner
+        repl['urlBasename'] = self.config.filename(url=True)
+        repl['basename'] = os.path.basename(self.config.filename())
+        return repl
+
+
+
+
+
+def parse_time(time_):
+    try: return time.strptime(time_, "%H:%M:%S")
+    except ValueError: pass
+    try: return time.strptime(time_, "%H:%M")
+    except ValueError: pass
+logline_re = re.compile(r'\[?([0-9: ]*)\]? *<[@+]?([^>]+)> *(.*)')
+loglineAction_re = re.compile(r'\[?([0-9: ]*)\]? *\* *([^ ]+) *(.*)')
+
+
+def process_meeting(contents, channel, filename,
+                    extraConfig = {},
+                    dontSave=False,
+                    safeMode=True):
+    M = Meeting(channel=channel, owner=None,
+                filename=filename, writeRawLog=False, safeMode=safeMode,
+                extraConfig=extraConfig)
+    if dontSave:
+        M.config.dontSave = True
+    # process all lines
+    for line in contents.split('\n'):
+        # match regular spoken lines:
+        m = logline_re.match(line)
+        if m:
+            time_ = parse_time(m.group(1).strip())
+            nick = m.group(2).strip()
+            line = m.group(3).strip()
+            if M.owner is None:
+                M.owner = nick ; M.chairs = {nick:True}
+            M.addline(nick, line, time_=time_)
+        # match /me lines
+        m = loglineAction_re.match(line)
+        if m:
+            time_ = parse_time(m.group(1).strip())
+            nick = m.group(2).strip()
+            line = m.group(3).strip()
+            M.addline(nick, "ACTION "+line, time_=time_)
+    return M
+
+# None of this is very well refined.
+if __name__ == '__main__':
+    import sys
+    if sys.argv[1] == 'replay':
+        fname = sys.argv[2]
+        m = re.match('(.*)\.log\.txt', fname)
+        if m:
+            filename = m.group(1)
+        else:
+            filename = os.path.splitext(fname)[0]
+        print 'Saving to:', filename
+        channel = '#'+os.path.basename(sys.argv[2]).split('.')[0]
+
+        M = Meeting(channel=channel, owner=None,
+                    filename=filename, writeRawLog=False)
+        for line in file(sys.argv[2]):
+            # match regular spoken lines:
+            m = logline_re.match(line)
+            if m:
+                time_ = parse_time(m.group(1).strip())
+                nick = m.group(2).strip()
+                line = m.group(3).strip()
+                if M.owner is None:
+                    M.owner = nick ; M.chairs = {nick:True}
+                M.addline(nick, line, time_=time_)
+            # match /me lines
+            m = loglineAction_re.match(line)
+            if m:
+                time_ = parse_time(m.group(1).strip())
+                nick = m.group(2).strip()
+                line = m.group(3).strip()
+                M.addline(nick, "ACTION "+line, time_=time_)
+        #M.save() # should be done by #endmeeting in the logs!
+    else:
+        print 'Command "%s" not found.'%sys.argv[1]
+

diff --git a/bot/ircmeeting/template.html b/bot/ircmeeting/template.html
new file mode 100644
index 0000000..b67b935
--- /dev/null
+++ b/bot/ircmeeting/template.html
@@ -0,0 +1,102 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns:py="http://genshi.edgewall.org/">
+<head>
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8"/>
+ <title>${meeting.title}</title>
+ <style type="text/css">
+/* This is for the .html in the HTML2 writer */
+body {
+    font-family: Helvetica, sans-serif;
+    font-size:14px;
+}
+h1 {
+    text-align: center;
+}
+a {
+    color:navy;
+    text-decoration: none;
+    border-bottom:1px dotted navy;
+}
+a:hover {
+    text-decoration:none;
+    border-bottom: 0;
+    color:#0000B9;
+}
+hr {
+    border: 1px solid #ccc;
+}
+/* The (nick, time) item pairs, and other body text things. */
+.details {
+    font-size: 12px;
+    font-weight:bold;
+}
+/* The 'AGREED:', 'IDEA', etc, prefix to lines. */
+.itemtype {
+    font-style: normal;    /* un-italics it */
+    font-weight: bold;
+}
+/* Example: change single item types.  Capitalized command name.
+/* .TOPIC  {  color:navy;  } */
+/* .AGREED {  color:lime;  } */
+
+ </style>
+</head>
+
+<body>
+ <h1>${meeting.title}</h1>
+ <span class="details"> Meeting started by ${meeting.owner} at ${time.start} ${time.timezone} (<a href="${meeting.logs}">full logs</a>).</span>
+
+ <h3>Meeting summary</h3>
+ <ol>
+  <li py:for="item in agenda">
+   <b class="TOPIC">${item.topic.topic}</b> <span py:if="item.topic.nick" class="details">(<a href='${meeting.logs}#${item.topic.anchor}'>${item.topic.nick}</a>, ${item.topic.time})</span>
+   <ol type="a">
+    <py:if test="len(item.notes) > 0">
+    <li py:for="note in item.notes">
+     <i class="itemtype">${note.itemtype}</i>: 
+     <py:choose>
+      <py:when test="note.itemtype == 'LINK'">
+      <span class="${note.itemtype}">
+       <a href="${note.url}">
+        <py:choose>
+         <py:when test="note.line">${note.line}</py:when>
+         <py:otherwise>${note.url}</py:otherwise>
+        </py:choose>
+       </a>
+      </span>
+      </py:when>
+      <py:otherwise>
+       <span class="${note.itemtype}">${note.line}</span>
+      </py:otherwise>
+     </py:choose>
+     <span class="details">(<a href='${meeting.logs}#${note.anchor}'>${note.nick}</a>, ${note.time})</span>
+    </li>
+    </py:if>
+   </ol>
+  </li>
+ </ol>
+
+ <span class="details">Meeting ended at ${time.end} ${time.timezone} (<a href="${meeting.logs}">full logs</a>).</span>
+
+ <h3>Action items</h3>
+ <ol>
+  <li py:for="action in actions">${action}</li>
+ </ol>
+
+ <h3>Action items, by person</h3>
+ <ol>
+  <li py:for="attendee in actions_person">${attendee.nick}
+   <ol>
+    <li py:for="action in attendee.actions">${action}</li>
+   </ol>
+  </li>
+ </ol>
+
+ <h3>People present (lines said)</h3>
+ <ol>
+  <li py:for="attendee in attendees">${attendee.nick} (${attendee.count})</li>
+ </ol>
+
+ <span class="details">Generated by <a href="${meetbot.url}">MeetBot</a> ${meetbot.version}.</span>
+</body>
+</html>

diff --git a/bot/ircmeeting/template.txt b/bot/ircmeeting/template.txt
new file mode 100644
index 0000000..5cb8bb8
--- /dev/null
+++ b/bot/ircmeeting/template.txt
@@ -0,0 +1,55 @@
+{% python 
+    heading = "="*len(meeting['title'])
+
+    from textwrap import TextWrapper
+    def wrap(text, level):
+        return TextWrapper(width=72, initial_indent=' '*(level-1)*2, subsequent_indent=' '*level*2, break_long_words=False).fill(text)
+%}
+${heading}
+${meeting.title}
+${heading}
+
+
+${wrap("Meeting started by %s at %s %s. The full logs are available at %s ."%(meeting.owner, time.start, time.timezone, meeting.logsFullURL), 1)}
+
+
+
+Meeting summary
+---------------
+
+{% for item in agenda %}\
+{% choose %}
+{% when item.topic.nick %}${wrap("* %s  (%s, %s)"%(item.topic.topic, item.topic.nick, item.topic.time), 1)}{% end %}\
+{% otherwise %}${wrap("* %s"%(item.topic.topic), 1)}{% end %}
+{% end %}\
+{% for note in item.notes %}\
+{% choose %}\
+{% when note.itemtype == 'LINK' %}${wrap("* %s: %s %s  (%s, %s)"%(note.itemtype, note.url, note.line, note.nick, note.time), 2)}{% end %}\
+{% otherwise %}${wrap("* %s: %s  (%s, %s)"%(note.itemtype, note.line, note.nick, note.time), 2)}{% end %}
+{% end %}\
+{% end %}\
+{% end %}
+
+${wrap("Meeting ended at %s %s."%(time.end, time.timezone), 1)}
+
+
+
+Action items, by person
+-----------------------
+
+{% for attendee in actions_person %}\
+* ${attendee.nick}
+{% for action in attendee.actions %}\
+${wrap("* %s"%action, 2)}
+{% end %}
+{% end %}
+
+People present (lines said)
+---------------------------
+
+{% for attendee in attendees %}\
+* ${attendee.nick} (${attendee.count})
+{% end %}
+
+
+Generated by `MeetBot`_ ${meetbot.version}

diff --git a/bot/ircmeeting/writers.py b/bot/ircmeeting/writers.py
new file mode 100644
index 0000000..6eed012
--- /dev/null
+++ b/bot/ircmeeting/writers.py
@@ -0,0 +1,1197 @@
+# Richard Darst, June 2009
+
+###
+# Copyright (c) 2009, Richard Darst
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+#   * Redistributions of source code must retain the above copyright notice,
+#     this list of conditions, and the following disclaimer.
+#   * Redistributions in binary form must reproduce the above copyright notice,
+#     this list of conditions, and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#   * Neither the name of the author of this software nor the name of
+#     contributors to this software may be used to endorse or promote products
+#     derived from this software without specific prior written consent.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+###
+
+import os
+import re
+import textwrap
+import time
+
+#from meeting import timeZone, meetBotInfoURL
+
+# Needed for testing with isinstance() for properly writing.
+#from items import Topic, Action
+import items
+
+# Data sanitizing for various output methods
+def html(text):
+    """Escape bad sequences (in HTML) in user-generated lines."""
+    return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
+rstReplaceRE = re.compile('_( |-|$)')
+def rst(text):
+    """Escapes bad sequences in reST"""
+    return rstReplaceRE.sub(r'\_\1', text)
+def text(text):
+    """Escapes bad sequences in text (not implemented yet)"""
+    return text
+def mw(text):
+    """Escapes bad sequences in MediaWiki markup (not implemented yet)"""
+    return text
+
+
+# wraping functions (for RST)
+class TextWrapper(textwrap.TextWrapper):
+    wordsep_re = re.compile(r'(\s+)')
+def wrapList(item, indent=0):
+    return TextWrapper(width=72, initial_indent=' '*indent,
+                       subsequent_indent= ' '*(indent+2),
+                       break_long_words=False).fill(item)
+def replaceWRAP(item):
+    re_wrap = re.compile(r'sWRAPs(.*)eWRAPe', re.DOTALL)
+    def repl(m):
+        return TextWrapper(width=72, break_long_words=False).fill(m.group(1))
+    return re_wrap.sub(repl, item)
+
+def makeNickRE(nick):
+    return re.compile('\\b'+re.escape(nick)+'\\b', re.IGNORECASE)
+
+def MeetBotVersion():
+    import meeting
+    if hasattr(meeting, '__version__'):
+        return ' '+meeting.__version__
+    else:
+        return ''
+
+
+class _BaseWriter(object):
+    def __init__(self, M, **kwargs):
+        self.M = M
+
+    def format(self, extension=None, **kwargs):
+        """Override this method to implement the formatting.
+
+        For file output writers, the method should return a unicode
+        object containing the contents of the file to write.
+
+        The argument 'extension' is the key from `writer_map`.  For
+        file writers, this can (and should) be ignored.  For non-file
+        outputs, this can be used to This can be used to pass data,
+
+        **kwargs is a dictionary of keyword arguments which are found
+        via parsing the extension to the writer.  If an extension is
+        this:
+          .txt|arg1=val1|arg2=val2
+        then kwargs will be passed as {'arg1':'val1', 'arg2':'val2'}.
+        This can be used for extra configuration for writers.
+        """
+        raise NotImplementedError
+
+    @property
+    def pagetitle(self):
+        if self.M._meetingTopic:
+            return "%s: %s"%(self.M.channel, self.M._meetingTopic)
+        return "%s Meeting"%self.M.channel
+
+    def replacements(self):
+        return {'pageTitle':self.pagetitle,
+                'owner':self.M.owner,
+                'starttime':time.strftime("%H:%M:%S", self.M.starttime),
+                'endtime':time.strftime("%H:%M:%S", self.M.endtime),
+                'timeZone':self.M.config.timeZone,
+                'fullLogs':self.M.config.basename+'.log.html',
+                'fullLogsFullURL':self.M.config.filename(url=True)+'.log.html',
+                'MeetBotInfoURL':self.M.config.MeetBotInfoURL,
+                'MeetBotVersion':MeetBotVersion(),
+             }
+    def iterNickCounts(self):
+        nicks = [ (n,c) for (n,c) in self.M.attendees.iteritems() ]
+        nicks.sort(key=lambda x: x[1], reverse=True)
+        return nicks
+
+    def iterActionItemsNick(self):
+        for nick in sorted(self.M.attendees.keys(), key=lambda x: x.lower()):
+            nick_re = makeNickRE(nick)
+            def nickitems(nick_re):
+                for m in self.M.minutes:
+                    # The hack below is needed because of pickling problems
+                    if m.itemtype != "ACTION": continue
+                    if nick_re.search(m.line) is None: continue
+                    m.assigned = True
+                    yield m
+            yield nick, nickitems(nick_re=nick_re)
+    def iterActionItemsUnassigned(self):
+        for m in self.M.minutes:
+            if m.itemtype != "ACTION": continue
+            if getattr(m, 'assigned', False): continue
+            yield m
+
+    def get_template(self, escape=lambda s: s):
+        M = self.M
+        repl = self.replacements()
+
+
+        MeetingItems = [ ]
+        # We can have initial items with NO initial topic.  This
+        # messes up the templating, so, have this null topic as a
+        # stopgap measure.
+        nextTopic = {'topic':{'itemtype':'TOPIC', 'topic':'Prologue',
+                              'nick':'',
+                              'time':'', 'link':'', 'anchor':''},
+                     'items':[] }
+        haveTopic = False
+        for m in M.minutes:
+            if m.itemtype == "TOPIC":
+                if nextTopic['topic']['nick'] or nextTopic['items']:
+                    MeetingItems.append(nextTopic)
+                nextTopic = {'topic':m.template(M, escape), 'items':[] }
+                haveTopic = True
+            else:
+                nextTopic['items'].append(m.template(M, escape))
+        MeetingItems.append(nextTopic)
+        repl['MeetingItems'] = MeetingItems
+        # Format of MeetingItems:
+        # [ {'topic': {item_dict},
+        #    'items': [item_dict, item_object, item_object, ...]
+        #    },
+        #   { 'topic':...
+        #     'items':...
+        #    },
+        #   ....
+        # ]
+        #
+        # an item_dict has:
+        # item_dict = {'itemtype': TOPIC, ACTION, IDEA, or so on...
+        #              'line': the actual line that was said
+        #              'nick': nick of who said the line
+        #              'time': 10:53:15, for example, the time
+        #              'link': ${link}#${anchor} is the URL to link to.
+        #                      (page name, and bookmark)
+        #              'anchor': see above
+        #              'topic': if itemtype is TOPIC, 'line' is not given,
+        #                      instead we have 'topic'
+        #              'url':  if itemtype is LINK, the line should be created
+        #                      by "${link} ${line}", where 'link' is the URL
+        #                      to link to, and 'line' is the rest of the line
+        #                      (that isn't a URL)
+        #              'url_quoteescaped': 'url' but with " escaped for use in
+        #                                  <a href="$url_quoteescaped">
+        ActionItems = [ ]
+        for m in M.minutes:
+            if m.itemtype != "ACTION": continue
+            ActionItems.append(escape(m.line))
+        repl['ActionItems'] = ActionItems
+        # Format of ActionItems: It's just a very simple list of lines.
+        # [line, line, line, ...]
+        # line = (string of what it is)
+
+
+        ActionItemsPerson = [ ]
+        numberAssigned = 0
+        for nick, items in self.iterActionItemsNick():
+            thisNick = {'nick':escape(nick), 'items':[ ] }
+            for m in items:
+                numberAssigned += 1
+                thisNick['items'].append(escape(m.line))
+            if len(thisNick['items']) > 0:
+                ActionItemsPerson.append(thisNick)
+        # Work on the unassigned nicks.
+        thisNick = {'nick':'UNASSIGNED', 'items':[ ] }
+        for m in self.iterActionItemsUnassigned():
+            thisNick['items'].append(escape(m.line))
+        if len(thisNick['items']) > 1:
+            ActionItemsPerson.append(thisNick)
+        #if numberAssigned == 0:
+        #    ActionItemsPerson = None
+        repl['ActionItemsPerson'] = ActionItemsPerson
+        # Format of ActionItemsPerson
+        # ActionItemsPerson =
+        #  [ {'nick':nick_of_person,
+        #     'items': [item1, item2, item3, ...],
+        #    },
+        #   ...,
+        #   ...,
+        #    {'nick':'UNASSIGNED',
+        #     'items': [item1, item2, item3, ...],
+        #    }
+        #  ]
+
+
+        PeoplePresent = []
+        # sort by number of lines spoken
+        for nick, count in self.iterNickCounts():
+            PeoplePresent.append({'nick':escape(nick),
+                                  'count':count})
+        repl['PeoplePresent'] = PeoplePresent
+        # Format of PeoplePresent
+        # [{'nick':the_nick, 'count':count_of_lines_said},
+        #  ...,
+        #  ...,
+        # ]
+
+        return repl
+
+    def get_template2(self, escape=lambda s: s):
+        # let's make the data structure easier to use in the template
+        repl = self.get_template(escape=escape)
+        repl = {
+        'time':           { 'start': repl['starttime'], 'end': repl['endtime'], 'timezone': repl['timeZone'] },
+        'meeting':        { 'title': repl['pageTitle'], 'owner': repl['owner'], 'logs': repl['fullLogs'], 'logsFullURL': repl['fullLogsFullURL'] },
+        'attendees':      [ person for person in repl['PeoplePresent'] ],
+        'agenda':         [ { 'topic': item['topic'], 'notes': item['items'] } for item in repl['MeetingItems'] ],
+        'actions':        [ action for action in repl['ActionItems'] ],
+        'actions_person': [ { 'nick': attendee['nick'], 'actions': attendee['items'] } for attendee in repl['ActionItemsPerson'] ],
+        'meetbot':        { 'version': repl['MeetBotVersion'], 'url': repl['MeetBotInfoURL'] },
+        }
+        return repl
+
+
+class Template(_BaseWriter):
+    """Format a notes file using the genshi templating engine
+
+    Send an argument template=<filename> to specify which template to
+    use.  If `template` begins in '+', then it is relative to the
+    MeetBot source directory.  Included templates are:
+      +template.html
+      +template.txt
+
+    Some examples of using these options are:
+      writer_map['.txt|template=+template.html'] = writers.Template
+      writer_map['.txt|template=/home/you/template.txt] = writers.Template
+
+    If a template ends in .txt, parse with a text-based genshi
+    templater.  Otherwise, parse with a HTML-based genshi templater.
+    """
+    def format(self, extension=None, template='+template.html'):
+        repl = self.get_template2()
+
+        # Do we want to use a text template or HTML ?
+        import genshi.template
+        if template[-4:] in ('.txt', '.rst'):
+            Template = genshi.template.NewTextTemplate   # plain text
+        else:
+            Template = genshi.template.MarkupTemplate    # HTML-like
+
+        template = self.M.config.findFile(template)
+
+        # Do the actual templating work
+        try:
+            f = open(template, 'r')
+            tmpl = Template(f.read())
+            stream = tmpl.generate(**repl)
+        finally:
+            f.close()
+
+        return stream.render()
+
+
+
+class _CSSmanager(object):
+    _css_head = textwrap.dedent('''\
+        <style type="text/css">
+        %s
+        </style>
+        ''')
+    def getCSS(self, name):
+        cssfile = getattr(self.M.config, 'cssFile_'+name, '')
+        if cssfile.lower() == 'none':
+            # special string 'None' means no style at all
+            return ''
+        elif cssfile in ('', 'default'):
+            # default CSS file
+            css_fname = '+css-'+name+'-default.css'
+        else:
+            css_fname = cssfile
+        css_fname = self.M.config.findFile(css_fname)
+        try:
+            # Stylesheet specified
+            if getattr(self.M.config, 'cssEmbed_'+name, True):
+                # external stylesheet
+                css = file(css_fname).read()
+                return self._css_head%css
+            else:
+                # linked stylesheet
+                css_head = ('''<link rel="stylesheet" type="text/css" '''
+                            '''href="%s">'''%cssfile)
+                return css_head
+        except Exception, exc:
+            if not self.M.config.safeMode:
+                raise
+            import traceback
+            traceback.print_exc()
+            print "(exception above ignored, continuing)"
+            try:
+                css_fname = os.path.join(os.path.dirname(__file__),
+                                         'css-'+name+'-default.css')
+                css = open(css_fname).read()
+                return self._css_head%css
+            except:
+                if not self.M.config.safeMode:
+                    raise
+                import traceback
+                traceback.print_exc()
+                return ''
+
+
+class TextLog(_BaseWriter):
+    def format(self, extension=None):
+        M = self.M
+        """Write raw text logs."""
+        return "\n".join(M.lines)
+    update_realtime = True
+
+
+
+class HTMLlog1(_BaseWriter):
+    def format(self, extension=None):
+        """Write pretty HTML logs."""
+        M = self.M
+        # pygments lexing setup:
+        # (pygments HTML-formatter handles HTML-escaping)
+        import pygments
+        from pygments.lexers import IrcLogsLexer
+        from pygments.formatters import HtmlFormatter
+        import pygments.token as token
+        from pygments.lexer import bygroups
+        # Don't do any encoding in this function with pygments.
+        # That's only right before the i/o functions in the Config
+        # object.
+        formatter = HtmlFormatter(lineanchors='l',
+                                  full=True, style=M.config.pygmentizeStyle,
+                                  outencoding=self.M.config.output_codec)
+        Lexer = IrcLogsLexer
+        Lexer.tokens['msg'][1:1] = \
+           [ # match:   #topic commands
+            (r"(\#topic[ \t\f\v]*)(.*\n)",
+             bygroups(token.Keyword, token.Generic.Heading), '#pop'),
+             # match:   #command   (others)
+            (r"(\#[^\s]+[ \t\f\v]*)(.*\n)",
+             bygroups(token.Keyword, token.Generic.Strong), '#pop'),
+           ]
+        lexer = Lexer()
+        #from rkddp.interact import interact ; interact()
+        out = pygments.highlight("\n".join(M.lines), lexer, formatter)
+        # Hack it to add "pre { white-space: pre-wrap; }", which make
+        # it wrap the pygments html logs.  I think that in a newer
+        # version of pygmetns, the "prestyles" HTMLFormatter option
+        # would do this, but I want to maintain compatibility with
+        # lenny.  Thus, I do these substitution hacks to add the
+        # format in.  Thanks to a comment on the blog of Francis
+        # Giannaros (http://francis.giannaros.org) for the suggestion
+        # and instructions for how.
+        out,n = re.subn(r"(\n\s*pre\s*\{[^}]+;\s*)(\})",
+                        r"\1\n      white-space: pre-wrap;\2",
+                        out, count=1)
+        if n == 0:
+            out = re.sub(r"(\n\s*</style>)",
+                         r"\npre { white-space: pre-wrap; }\1",
+                         out, count=1)
+        return out
+
+class HTMLlog2(_BaseWriter, _CSSmanager):
+    def format(self, extension=None):
+        """Write pretty HTML logs."""
+        M = self.M
+        lines = [ ]
+        line_re = re.compile(r"""\s*
+            (?P<time> \[?[0-9:\s]*\]?)\s*
+            (?P<nick>\s+<[@+\s]?[^>]+>)\s*
+            (?P<line>.*)
+        """, re.VERBOSE)
+        action_re = re.compile(r"""\s*
+            (?P<time> \[?[0-9:\s]*\]?)\s*
+            (?P<nick>\*\s+[@+\s]?[^\s]+)\s*
+            (?P<line>.*)
+        """,re.VERBOSE)
+        command_re = re.compile(r"(#[^\s]+[ \t\f\v]*)(.*)")
+        command_topic_re = re.compile(r"(#topic[ \t\f\v]*)(.*)")
+        hilight_re = re.compile(r"([^\s]+:)( .*)")
+        lineNumber = 0
+        for l in M.lines:
+            lineNumber += 1  # starts from 1
+            # is it a regular line?
+            m = line_re.match(l)
+            if m is not None:
+                line = m.group('line')
+                # Match #topic
+                m2 = command_topic_re.match(line)
+                if m2 is not None:
+                    outline = ('<span class="topic">%s</span>'
+                               '<span class="topicline">%s</span>'%
+                               (html(m2.group(1)),html(m2.group(2))))
+                # Match other #commands
+                if m2 is None:
+                  m2 = command_re.match(line)
+                  if m2 is not None:
+                    outline = ('<span class="cmd">%s</span>'
+                               '<span class="cmdline">%s</span>'%
+                               (html(m2.group(1)),html(m2.group(2))))
+                # match hilights
+                if m2 is None:
+                  m2 = hilight_re.match(line)
+                  if m2 is not None:
+                    outline = ('<span class="hi">%s</span>'
+                               '%s'%
+                               (html(m2.group(1)),html(m2.group(2))))
+                if m2 is None:
+                    outline = html(line)
+                lines.append('<a name="l-%(lineno)s"></a>'
+                             '<span class="tm">%(time)s</span>'
+                             '<span class="nk">%(nick)s</span> '
+                             '%(line)s'%{'lineno':lineNumber,
+                                         'time':html(m.group('time')),
+                                         'nick':html(m.group('nick')),
+                                         'line':outline,})
+                continue
+            m = action_re.match(l)
+            # is it a action line?
+            if m is not None:
+                lines.append('<a name="l-%(lineno)s"></a>'
+                             '<span class="tm">%(time)s</span>'
+                             '<span class="nka">%(nick)s</span> '
+                             '<span class="ac">%(line)s</span>'%
+                               {'lineno':lineNumber,
+                                'time':html(m.group('time')),
+                                'nick':html(m.group('nick')),
+                                'line':html(m.group('line')),})
+                continue
+            print l
+            print m.groups()
+            print "**error**", l
+
+        css = self.getCSS(name='log')
+        return html_template%{'pageTitle':"%s log"%html(M.channel),
+                              #'body':"<br>\n".join(lines),
+                              'body':"<pre>"+("\n".join(lines))+"</pre>",
+                              'headExtra':css,
+                              }
+HTMLlog = HTMLlog2
+
+
+
+html_template = textwrap.dedent('''\
+    <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
+    <html>
+    <head>
+    <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+    <title>%(pageTitle)s</title>
+    %(headExtra)s</head>
+
+    <body>
+    %(body)s
+    </body></html>
+    ''')
+
+
+class HTML1(_BaseWriter):
+
+    body = textwrap.dedent('''\
+    <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
+    <html>
+    <head>
+    <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+    <title>%(pageTitle)s</title>
+    </head>
+    <body>
+    <h1>%(pageTitle)s</h1>
+    Meeting started by %(owner)s at %(starttime)s %(timeZone)s.
+    (<a href="%(fullLogs)s">full logs</a>)<br>
+
+
+    <table border=1>
+    %(MeetingItems)s
+    </table>
+    Meeting ended at %(endtime)s %(timeZone)s.
+    (<a href="%(fullLogs)s">full logs</a>)
+
+    <br><br><br>
+
+    <b>Action Items</b><ol>
+    %(ActionItems)s
+    </ol>
+    <br>
+
+    <b>Action Items, by person</b>
+    <ol>
+    %(ActionItemsPerson)s
+    </ol><br>
+
+    <b>People Present (lines said):</b><ol>
+    %(PeoplePresent)s
+    </ol>
+
+    <br>
+    Generated by <a href="%(MeetBotInfoURL)s">MeetBot</a>%(MeetBotVersion)s.
+    </body></html>
+    ''')
+
+    def format(self, extension=None):
+        """Write the minutes summary."""
+        M = self.M
+
+        # Add all minute items to the table
+        MeetingItems = [ ]
+        for m in M.minutes:
+            MeetingItems.append(m.html(M))
+        MeetingItems = "\n".join(MeetingItems)
+
+        # Action Items
+        ActionItems = [ ]
+        for m in M.minutes:
+            # The hack below is needed because of pickling problems
+            if m.itemtype != "ACTION": continue
+            ActionItems.append("  <li>%s</li>"%html(m.line))
+        if len(ActionItems) == 0:
+            ActionItems.append("  <li>(none)</li>")
+        ActionItems = "\n".join(ActionItems)
+
+        # Action Items, by person (This could be made lots more efficient)
+        ActionItemsPerson = [ ]
+        for nick, items in self.iterActionItemsNick():
+            headerPrinted = False
+            for m in items:
+                if not headerPrinted:
+                    ActionItemsPerson.append("  <li> %s <ol>"%html(nick))
+                    headerPrinted = True
+                ActionItemsPerson.append("    <li>%s</li>"%html(m.line))
+            if headerPrinted:
+                ActionItemsPerson.append("  </ol></li>")
+        # unassigned items:
+        ActionItemsPerson.append("  <li><b>UNASSIGNED</b><ol>")
+        numberUnassigned = 0
+        for m in self.iterActionItemsUnassigned():
+            ActionItemsPerson.append("    <li>%s</li>"%html(m.line))
+            numberUnassigned += 1
+        if numberUnassigned == 0:
+            ActionItemsPerson.append("    <li>(none)</li>")
+        ActionItemsPerson.append('  </ol>\n</li>')
+        ActionItemsPerson = "\n".join(ActionItemsPerson)
+
+        # People Attending
+        PeoplePresent = [ ]
+        # sort by number of lines spoken
+        for nick, count in self.iterNickCounts():
+            PeoplePresent.append('  <li>%s (%s)</li>'%(html(nick), count))
+        PeoplePresent = "\n".join(PeoplePresent)
+
+        # Actual formatting and replacement
+        repl = self.replacements()
+        repl.update({'MeetingItems':MeetingItems,
+                     'ActionItems': ActionItems,
+                     'ActionItemsPerson': ActionItemsPerson,
+                     'PeoplePresent':PeoplePresent,
+                     })
+        body = self.body
+        body = body%repl
+        body = replaceWRAP(body)
+        return body
+
+
+
+class HTML2(_BaseWriter, _CSSmanager):
+    """HTML formatter without tables.
+    """
+    def meetingItems(self):
+        """Return the main 'Meeting minutes' block."""
+        M = self.M
+
+        # Add all minute items to the table
+        MeetingItems = [ ]
+        MeetingItems.append(self.heading('Meeting summary'))
+        MeetingItems.append("<ol>")
+
+        haveTopic = None
+        inSublist = False
+        for m in M.minutes:
+            item = '<li>'+m.html2(M)
+            if m.itemtype == "TOPIC":
+                if inSublist:
+                    MeetingItems.append("</ol>")
+                    inSublist = False
+                if haveTopic:
+                    MeetingItems.append("<br></li>")
+                item = item
+                haveTopic = True
+            else:
+                if not inSublist:
+                    if not haveTopic:
+                        MeetingItems.append('<li>')
+                        haveTopic = True
+                    MeetingItems.append('<ol type="a">')
+                    inSublist = True
+                if haveTopic: item = wrapList(item, 2)+"</li>"
+                else:         item = wrapList(item, 0)+"</li>"
+            MeetingItems.append(item)
+            #MeetingItems.append("</li>")
+
+        if inSublist:
+            MeetingItems.append("</ol>")
+        if haveTopic:
+            MeetingItems.append("</li>")
+
+        MeetingItems.append("</ol>")
+        MeetingItems = "\n".join(MeetingItems)
+        return MeetingItems
+
+    def actionItems(self):
+        """Return the 'Action items' block."""
+        M = self.M
+        # Action Items
+        ActionItems = [ ]
+        ActionItems.append(self.heading('Action items'))
+        ActionItems.append('<ol>')
+        numActionItems = 0
+        for m in M.minutes:
+            # The hack below is needed because of pickling problems
+            if m.itemtype != "ACTION": continue
+            ActionItems.append("  <li>%s</li>"%html(m.line))
+            numActionItems += 1
+        if numActionItems == 0:
+            ActionItems.append("  <li>(none)</li>")
+        ActionItems.append('</ol>')
+        ActionItems = "\n".join(ActionItems)
+        return ActionItems
+    def actionItemsPerson(self):
+        """Return the 'Action items, by person' block."""
+        M = self.M
+        # Action Items, by person (This could be made lots more efficient)
+        ActionItemsPerson = [ ]
+        ActionItemsPerson.append(self.heading('Action items, by person'))
+        ActionItemsPerson.append('<ol>')
+        numberAssigned = 0
+        for nick, items in self.iterActionItemsNick():
+            headerPrinted = False
+            for m in items:
+                numberAssigned += 1
+                if not headerPrinted:
+                    ActionItemsPerson.append("  <li> %s <ol>"%html(nick))
+                    headerPrinted = True
+                ActionItemsPerson.append("    <li>%s</li>"%html(m.line))
+            if headerPrinted:
+                ActionItemsPerson.append("  </ol></li>")
+        # unassigned items:
+        if len(ActionItemsPerson) == 0:
+            doActionItemsPerson = False
+        else:
+            doActionItemsPerson = True
+        Unassigned = [ ]
+        Unassigned.append("  <li><b>UNASSIGNED</b><ol>")
+        numberUnassigned = 0
+        for m in self.iterActionItemsUnassigned():
+            Unassigned.append("    <li>%s</li>"%html(m.line))
+            numberUnassigned += 1
+        if numberUnassigned == 0:
+            Unassigned.append("    <li>(none)</li>")
+        Unassigned.append('  </ol>\n</li>')
+        if numberUnassigned > 1:
+            ActionItemsPerson.extend(Unassigned)
+        ActionItemsPerson.append('</ol>')
+        ActionItemsPerson = "\n".join(ActionItemsPerson)
+
+        # Only return anything if there are assigned items.
+        if numberAssigned == 0:
+            return None
+        else:
+            return ActionItemsPerson
+    def peoplePresent(self):
+        """Return the 'People present' block."""
+        # People Attending
+        PeoplePresent = []
+        PeoplePresent.append(self.heading('People present (lines said)'))
+        PeoplePresent.append('<ol>')
+        # sort by number of lines spoken
+        for nick, count in self.iterNickCounts():
+            PeoplePresent.append('  <li>%s (%s)</li>'%(html(nick), count))
+        PeoplePresent.append('</ol>')
+        PeoplePresent = "\n".join(PeoplePresent)
+        return PeoplePresent
+    def heading(self, name):
+        return '<h3>%s</h3>'%name
+
+    def format(self, extension=None):
+        """Write the minutes summary."""
+        M = self.M
+
+        repl = self.replacements()
+
+        body = [ ]
+        body.append(textwrap.dedent("""\
+            <h1>%(pageTitle)s</h1>
+            <span class="details">
+            Meeting started by %(owner)s at %(starttime)s %(timeZone)s
+            (<a href="%(fullLogs)s">full logs</a>).</span>
+            """%repl))
+        body.append(self.meetingItems())
+        body.append(textwrap.dedent("""\
+            <span class="details">
+            Meeting ended at %(endtime)s %(timeZone)s
+            (<a href="%(fullLogs)s">full logs</a>).</span>
+            """%repl))
+        body.append(self.actionItems())
+        body.append(self.actionItemsPerson())
+        body.append(self.peoplePresent())
+        body.append("""<span class="details">"""
+                    """Generated by <a href="%(MeetBotInfoURL)s">MeetBot</a>"""
+                    """%(MeetBotVersion)s.</span>"""%repl)
+        body = [ b for b in body if b is not None ]
+        body = "\n<br><br>\n\n\n\n".join(body)
+        body = replaceWRAP(body)
+
+
+        css = self.getCSS(name='minutes')
+        repl.update({'body': body,
+                     'headExtra': css,
+                     })
+        html = html_template % repl
+
+        return html
+HTML = HTML2
+
+
+class ReST(_BaseWriter):
+
+    body = textwrap.dedent("""\
+    %(titleBlock)s
+    %(pageTitle)s
+    %(titleBlock)s
+
+
+    sWRAPsMeeting started by %(owner)s at %(starttime)s %(timeZone)s.
+    The `full logs`_ are available.eWRAPe
+
+    .. _`full logs`: %(fullLogs)s
+
+
+
+    Meeting summary
+    ---------------
+    %(MeetingItems)s
+
+    Meeting ended at %(endtime)s %(timeZone)s.
+
+
+
+
+    Action Items
+    ------------
+    %(ActionItems)s
+
+
+
+
+    Action Items, by person
+    -----------------------
+    %(ActionItemsPerson)s
+
+
+
+
+    People Present (lines said)
+    ---------------------------
+    %(PeoplePresent)s
+
+
+
+
+    Generated by `MeetBot`_%(MeetBotVersion)s
+
+    .. _`MeetBot`: %(MeetBotInfoURL)s
+    """)
+
+    def format(self, extension=None):
+        """Return a ReStructured Text minutes summary."""
+        M = self.M
+
+        # Agenda items
+        MeetingItems = [ ]
+        M.rst_urls = [ ]
+        M.rst_refs = { }
+        haveTopic = None
+        for m in M.minutes:
+            item = "* "+m.rst(M)
+            if m.itemtype == "TOPIC":
+                if haveTopic:
+                    MeetingItems.append("")
+                item = wrapList(item, 0)
+                haveTopic = True
+            else:
+                if haveTopic: item = wrapList(item, 2)
+                else:         item = wrapList(item, 0)
+            MeetingItems.append(item)
+        MeetingItems = '\n\n'.join(MeetingItems)
+        MeetingURLs = "\n".join(M.rst_urls)
+        del M.rst_urls, M.rst_refs
+        MeetingItems = MeetingItems + '\n\n'+MeetingURLs
+
+        # Action Items
+        ActionItems = [ ]
+        for m in M.minutes:
+            # The hack below is needed because of pickling problems
+            if m.itemtype != "ACTION": continue
+            #already escaped
+            ActionItems.append(wrapList("* %s"%rst(m.line), indent=0))
+        ActionItems = "\n\n".join(ActionItems)
+
+        # Action Items, by person (This could be made lots more efficient)
+        ActionItemsPerson = [ ]
+        for nick in sorted(M.attendees.keys(), key=lambda x: x.lower()):
+            nick_re = makeNickRE(nick)
+            headerPrinted = False
+            for m in M.minutes:
+                # The hack below is needed because of pickling problems
+                if m.itemtype != "ACTION": continue
+                if nick_re.search(m.line) is None: continue
+                if not headerPrinted:
+                    ActionItemsPerson.append("* %s"%rst(nick))
+                    headerPrinted = True
+                ActionItemsPerson.append(wrapList("* %s"%rst(m.line), 2))
+                m.assigned = True
+        # unassigned items:
+        Unassigned = [ ]
+        Unassigned.append("* **UNASSIGNED**")
+        numberUnassigned = 0
+        for m in M.minutes:
+            if m.itemtype != "ACTION": continue
+            if getattr(m, 'assigned', False): continue
+            Unassigned.append(wrapList("* %s"%rst(m.line), 2))
+            numberUnassigned += 1
+        if numberUnassigned == 0:
+            Unassigned.append("  * (none)")
+        if numberUnassigned > 1:
+            ActionItemsPerson.extend(Unassigned)
+        ActionItemsPerson = "\n\n".join(ActionItemsPerson)
+
+        # People Attending
+        PeoplePresent = [ ]
+        # sort by number of lines spoken
+        for nick, count in self.iterNickCounts():
+            PeoplePresent.append('* %s (%s)'%(rst(nick), count))
+        PeoplePresent = "\n\n".join(PeoplePresent)
+
+        # Actual formatting and replacement
+        repl = self.replacements()
+        repl.update({'titleBlock':('='*len(repl['pageTitle'])),
+                     'MeetingItems':MeetingItems,
+                     'ActionItems': ActionItems,
+                     'ActionItemsPerson': ActionItemsPerson,
+                     'PeoplePresent':PeoplePresent,
+                     })
+        body = self.body
+        body = body%repl
+        body = replaceWRAP(body)
+        return body
+
+class HTMLfromReST(_BaseWriter):
+
+    def format(self, extension=None):
+        M = self.M
+        import docutils.core
+        rst = ReST(M).format(extension)
+        rstToHTML = docutils.core.publish_string(rst, writer_name='html',
+                             settings_overrides={'file_insertion_enabled': 0,
+                                                 'raw_enabled': 0,
+                                'output_encoding':self.M.config.output_codec})
+        return rstToHTML
+
+
+
+class Text(_BaseWriter):
+
+    def meetingItems(self):
+        M = self.M
+
+        # Agenda items
+        MeetingItems = [ ]
+        MeetingItems.append(self.heading('Meeting summary'))
+        haveTopic = None
+        for m in M.minutes:
+            item = "* "+m.text(M)
+            if m.itemtype == "TOPIC":
+                if haveTopic:
+                    MeetingItems.append("")
+                item = wrapList(item, 0)
+                haveTopic = True
+            else:
+                if haveTopic: item = wrapList(item, 2)
+                else:         item = wrapList(item, 0)
+            MeetingItems.append(item)
+        MeetingItems = '\n'.join(MeetingItems)
+        return MeetingItems
+
+    def actionItems(self):
+        M = self.M
+        # Action Items
+        ActionItems = [ ]
+        numActionItems = 0
+        ActionItems.append(self.heading('Action items'))
+        for m in M.minutes:
+            # The hack below is needed because of pickling problems
+            if m.itemtype != "ACTION": continue
+            #already escaped
+            ActionItems.append(wrapList("* %s"%text(m.line), indent=0))
+            numActionItems += 1
+        if numActionItems == 0:
+            ActionItems.append("* (none)")
+        ActionItems = "\n".join(ActionItems)
+
+    def actionItemsPerson(self):
+        M = self.M
+        # Action Items, by person (This could be made lots more efficient)
+        ActionItemsPerson = [ ]
+        ActionItemsPerson.append(self.heading('Action items, by person'))
+        numberAssigned = 0
+        for nick in sorted(M.attendees.keys(), key=lambda x: x.lower()):
+            nick_re = makeNickRE(nick)
+            headerPrinted = False
+            for m in M.minutes:
+                # The hack below is needed because of pickling problems
+                if m.itemtype != "ACTION": continue
+                if nick_re.search(m.line) is None: continue
+                if not headerPrinted:
+                    ActionItemsPerson.append("* %s"%text(nick))
+                    headerPrinted = True
+                ActionItemsPerson.append(wrapList("* %s"%text(m.line), 2))
+                numberAssigned += 1
+                m.assigned = True
+        # unassigned items:
+        Unassigned = [ ]
+        Unassigned.append("* **UNASSIGNED**")
+        numberUnassigned = 0
+        for m in M.minutes:
+            if m.itemtype != "ACTION": continue
+            if getattr(m, 'assigned', False): continue
+            Unassigned.append(wrapList("* %s"%text(m.line), 2))
+            numberUnassigned += 1
+        if numberUnassigned == 0:
+            Unassigned.append("  * (none)")
+        if numberUnassigned > 1:
+            ActionItemsPerson.extend(Unassigned)
+        ActionItemsPerson = "\n".join(ActionItemsPerson)
+
+        if numberAssigned == 0:
+            return None
+        else:
+            return ActionItemsPerson
+
+    def peoplePresent(self):
+        M = self.M
+        # People Attending
+        PeoplePresent = [ ]
+        PeoplePresent.append(self.heading('People present (lines said)'))
+        # sort by number of lines spoken
+        for nick, count in self.iterNickCounts():
+            PeoplePresent.append('* %s (%s)'%(text(nick), count))
+        PeoplePresent = "\n".join(PeoplePresent)
+        return PeoplePresent
+
+    def heading(self, name):
+        return '%s\n%s\n'%(name, '-'*len(name))
+
+
+    def format(self, extension=None):
+        """Return a plain text minutes summary."""
+        M = self.M
+
+        # Actual formatting and replacement
+        repl = self.replacements()
+        repl.update({'titleBlock':('='*len(repl['pageTitle'])),
+                     })
+
+
+        body = [ ]
+        body.append(textwrap.dedent("""\
+            %(titleBlock)s
+            %(pageTitle)s
+            %(titleBlock)s
+
+
+            sWRAPsMeeting started by %(owner)s at %(starttime)s
+            %(timeZone)s.  The full logs are available at
+            %(fullLogsFullURL)s .eWRAPe"""%repl))
+        body.append(self.meetingItems())
+        body.append(textwrap.dedent("""\
+            Meeting ended at %(endtime)s %(timeZone)s."""%repl))
+        body.append(self.actionItems())
+        body.append(self.actionItemsPerson())
+        body.append(self.peoplePresent())
+        body.append(textwrap.dedent("""\
+            Generated by `MeetBot`_%(MeetBotVersion)s"""%repl))
+        body = [ b for b in body if b is not None ]
+        body = "\n\n\n\n".join(body)
+        body = replaceWRAP(body)
+
+        return body
+
+
+class MediaWiki(_BaseWriter):
+    """Outputs MediaWiki formats.
+    """
+    def meetingItems(self):
+        M = self.M
+
+        # Agenda items
+        MeetingItems = [ ]
+        MeetingItems.append(self.heading('Meeting summary'))
+        haveTopic = None
+        for m in M.minutes:
+            item = "* "+m.mw(M)
+            if m.itemtype == "TOPIC":
+                if haveTopic:
+                    MeetingItems.append("") # line break
+                haveTopic = True
+            else:
+                if haveTopic: item = "*"+item
+            MeetingItems.append(item)
+        MeetingItems = '\n'.join(MeetingItems)
+        return MeetingItems
+
+    def actionItems(self):
+        M = self.M
+        # Action Items
+        ActionItems = [ ]
+        numActionItems = 0
+        ActionItems.append(self.heading('Action items'))
+        for m in M.minutes:
+            # The hack below is needed because of pickling problems
+            if m.itemtype != "ACTION": continue
+            #already escaped
+            ActionItems.append("* %s"%mw(m.line))
+            numActionItems += 1
+        if numActionItems == 0:
+            ActionItems.append("* (none)")
+        ActionItems = "\n".join(ActionItems)
+        return ActionItems
+
+    def actionItemsPerson(self):
+        M = self.M
+        # Action Items, by person (This could be made lots more efficient)
+        ActionItemsPerson = [ ]
+        ActionItemsPerson.append(self.heading('Action items, by person'))
+        numberAssigned = 0
+        for nick in sorted(M.attendees.keys(), key=lambda x: x.lower()):
+            nick_re = makeNickRE(nick)
+            headerPrinted = False
+            for m in M.minutes:
+                # The hack below is needed because of pickling problems
+                if m.itemtype != "ACTION": continue
+                if nick_re.search(m.line) is None: continue
+                if not headerPrinted:
+                    ActionItemsPerson.append("* %s"%mw(nick))
+                    headerPrinted = True
+                ActionItemsPerson.append("** %s"%mw(m.line))
+                numberAssigned += 1
+                m.assigned = True
+        # unassigned items:
+        Unassigned = [ ]
+        Unassigned.append("* **UNASSIGNED**")
+        numberUnassigned = 0
+        for m in M.minutes:
+            if m.itemtype != "ACTION": continue
+            if getattr(m, 'assigned', False): continue
+            Unassigned.append("** %s"%mw(m.line))
+            numberUnassigned += 1
+        if numberUnassigned == 0:
+            Unassigned.append("  * (none)")
+        if numberUnassigned > 1:
+            ActionItemsPerson.extend(Unassigned)
+        ActionItemsPerson = "\n".join(ActionItemsPerson)
+
+        if numberAssigned == 0:
+            return None
+        else:
+            return ActionItemsPerson
+
+    def peoplePresent(self):
+        M = self.M
+        # People Attending
+        PeoplePresent = [ ]
+        PeoplePresent.append(self.heading('People present (lines said)'))
+        # sort by number of lines spoken
+        for nick, count in self.iterNickCounts():
+            PeoplePresent.append('* %s (%s)'%(mw(nick), count))
+        PeoplePresent = "\n".join(PeoplePresent)
+        return PeoplePresent
+
+    def heading(self, name, level=1):
+        return '%s %s %s\n'%('='*(level+1), name, '='*(level+1))
+
+
+    body_start = textwrap.dedent("""\
+            %(pageTitleHeading)s
+
+            sWRAPsMeeting started by %(owner)s at %(starttime)s
+            %(timeZone)s.  The full logs are available at
+            %(fullLogsFullURL)s .eWRAPe""")
+    def format(self, extension=None, **kwargs):
+        """Return a MediaWiki formatted minutes summary."""
+        M = self.M
+
+        # Actual formatting and replacement
+        repl = self.replacements()
+        repl.update({'titleBlock':('='*len(repl['pageTitle'])),
+                     'pageTitleHeading':self.heading(repl['pageTitle'],level=0)
+                     })
+
+
+        body = [ ]
+        body.append(self.body_start%repl)
+        body.append(self.meetingItems())
+        body.append(textwrap.dedent("""\
+            Meeting ended at %(endtime)s %(timeZone)s."""%repl))
+        body.append(self.actionItems())
+        body.append(self.actionItemsPerson())
+        body.append(self.peoplePresent())
+        body.append(textwrap.dedent("""\
+            Generated by MeetBot%(MeetBotVersion)s (%(MeetBotInfoURL)s)"""%repl))
+        body = [ b for b in body if b is not None ]
+        body = "\n\n\n\n".join(body)
+        body = replaceWRAP(body)
+
+
+        # Do we want to upload?
+        if 'mwpath' in kwargs:
+            import mwclient
+            mwsite = kwargs['mwsite']
+            mwpath = kwargs['mwpath']
+            mwusername = kwargs.get('mwusername', None)
+            mwpassword = kwargs.get('mwpassword', '')
+            subpagename = os.path.basename(self.M.config.filename())
+            mwfullname = "%s/%s" % (mwpath, subpagename)
+            force_login = (mwusername != None)
+
+            site = mwclient.Site(mwsite, force_login=force_login)
+            if(force_login):
+                site.login(mwusername, mwpassword)
+            page = site.Pages[mwfullname]
+            some = page.edit()
+            page.save(body, summary="Meeting")
+
+
+        return body
+
+class PmWiki(MediaWiki, object):
+    def heading(self, name, level=1):
+        return '%s %s\n'%('!'*(level+1), name)
+    def replacements(self):
+        #repl = super(PmWiki, self).replacements(self) # fails, type checking
+        repl = MediaWiki.replacements.im_func(self)
+        repl['pageTitleHeading'] = self.heading(repl['pageTitle'],level=0)
+        return repl
+
+

diff --git a/bot/setup.py b/bot/setup.py
new file mode 100644
index 0000000..494705e
--- /dev/null
+++ b/bot/setup.py
@@ -0,0 +1,12 @@
+
+from distutils.core import setup
+setup(name='MeetBot',
+      description='IRC Meeting Helper',
+      version='0.1.4',
+      packages=['supybot.plugins.Meeting',
+                'ircmeeting'],
+      package_dir={'supybot.plugins.Meeting':'Meeting'},
+      package_data={'ircmeeting':['*.html', '*.txt', '*.css']},
+      author="Richard Darst",
+      author_email="rkd@zgib.net"
+      )

diff --git a/bot/tests/run_test.py b/bot/tests/run_test.py
new file mode 100644
index 0000000..213cd43
--- /dev/null
+++ b/bot/tests/run_test.py
@@ -0,0 +1,352 @@
+# Richard Darst, 2009
+
+import glob
+import os
+import re
+import shutil
+import sys
+import tempfile
+import unittest
+
+os.environ['MEETBOT_RUNNING_TESTS'] = '1'
+import ircmeeting.meeting as meeting
+import ircmeeting.writers as writers
+
+running_tests = True
+
+def process_meeting(contents, extraConfig={}, dontSave=True,
+                    filename='/dev/null'):
+    """Take a test script, return Meeting object of that meeting.
+
+    To access the results (a dict keyed by extensions), use M.save(),
+    with M being the return of this function.
+    """
+    return meeting.process_meeting(contents=contents,
+                                channel="#none",  filename=filename,
+                                dontSave=dontSave, safeMode=False,
+                                extraConfig=extraConfig)
+
+class MeetBotTest(unittest.TestCase):
+
+    def test_replay(self):
+        """Replay of a meeting, using 'meeting.py replay'.
+        """
+        old_argv = sys.argv[:]
+        sys.argv[1:] = ["replay", "test-script-1.log.txt"]
+        sys.path.insert(0, "../ircmeeting")
+        try:
+            gbls = {"__name__":"__main__",
+                    "__file__":"../ircmeeting/meeting.py"}
+            execfile("../ircmeeting/meeting.py", gbls)
+            assert "M" in gbls, "M object not in globals: did it run?"
+        finally:
+            del sys.path[0]
+            sys.argv[:] = old_argv
+
+    def test_supybottests(self):
+        """Test by sending input to supybot, check responses.
+
+        Uses the external supybot-test command.  Unfortunantly, that
+        doesn't have a useful status code, so I need to parse the
+        output.
+        """
+        os.symlink("../MeetBot", "MeetBot")
+        os.symlink("../ircmeeting", "ircmeeting")
+        sys.path.insert(0, ".")
+        try:
+            output = os.popen("supybot-test ./MeetBot 2>&1").read()
+            assert 'FAILED' not in output, "supybot-based tests failed."
+            assert '\nOK\n'     in output, "supybot-based tests failed."
+        finally:
+            os.unlink("MeetBot")
+            os.unlink("ircmeeting")
+            del sys.path[0]
+
+    trivial_contents = """
+    10:10:10 <x> #startmeeting
+    10:10:10 <x> blah
+    10:10:10 <x> #endmeeting
+    """
+
+    full_writer_map = {
+        '.log.txt':     writers.TextLog,
+        '.log.1.html':  writers.HTMLlog1,
+        '.log.html':    writers.HTMLlog2,
+        '.1.html':      writers.HTML1,
+        '.html':        writers.HTML2,
+        '.rst':         writers.ReST,
+        '.rst.html':    writers.HTMLfromReST,
+        '.txt':         writers.Text,
+        '.mw':          writers.MediaWiki,
+        '.pmw':         writers.PmWiki,
+        '.tmp.txt|template=+template.txt':   writers.Template,
+        '.tmp.html|template=+template.html': writers.Template,
+        }
+
+    def M_trivial(self, contents=None, extraConfig={}):
+        """Convenience wrapper to process_meeting.
+        """
+        if contents is None:
+            contents = self.trivial_contents
+        return process_meeting(contents=contents,
+                               extraConfig=extraConfig)
+
+    def test_script_1(self):
+        """Run test-script-1.log.txt through the processor.
+
+        - Check all writers
+        - Check actual file writing.
+        """
+        tmpdir = tempfile.mkdtemp(prefix='test-meetbot')
+        try:
+            process_meeting(contents=file('test-script-1.log.txt').read(),
+                            filename=os.path.join(tmpdir, 'meeting'),
+                            dontSave=False,
+                            extraConfig={'writer_map':self.full_writer_map,
+                                         })
+            # Test every extension in the full_writer_map to make sure
+            # it was written.
+            for extension in self.full_writer_map:
+                ext = re.search(r'^\.(.*?)($|\|)', extension).group(1)
+                files = glob.glob(os.path.join(tmpdir, 'meeting.'+ext))
+                assert len(files) > 0, \
+                       "Extension did not produce output: '%s'"%extension
+        finally:
+            shutil.rmtree(tmpdir)
+
+    #def test_script_3(self):
+    #   process_meeting(contents=file('test-script-3.log.txt').read(),
+    #                   extraConfig={'writer_map':self.full_writer_map})
+
+    all_commands_test_contents = """
+    10:10:10 <x> #startmeeting
+    10:10:10 <x> #topic h6k4orkac
+    10:10:10 <x> #info blaoulrao
+    10:10:10 <x> #idea alrkkcao4
+    10:10:10 <x> #help ntoircoa5
+    10:10:10 <x> #link http://bnatorkcao.net kroacaonteu
+    10:10:10 <x> http://jrotjkor.net krotroun
+    10:10:10 <x> #action xrceoukrc
+    10:10:10 <x> #nick okbtrokr
+
+    # Should not appear in non-log output
+    10:10:10 <x> #idea ckmorkont
+    10:10:10 <x> #undo
+
+    # Assert that chairs can change the topic, and non-chairs can't.
+    10:10:10 <x> #chair y
+    10:10:10 <y> #topic topic_doeschange
+    10:10:10 <z> #topic topic_doesntchange
+    10:10:10 <x> #unchair y
+    10:10:10 <y> #topic topic_doesnt2change
+
+    10:10:10 <x> #endmeeting
+    """
+    def test_contents_test2(self):
+        """Ensure that certain input lines do appear in the output.
+
+        This test ensures that the input to certain commands does
+        appear in the output.
+        """
+        M = process_meeting(contents=self.all_commands_test_contents,
+                            extraConfig={'writer_map':self.full_writer_map})
+        results = M.save()
+        for name, output in results.iteritems():
+            self.assert_('h6k4orkac' in output, "Topic failed for %s"%name)
+            self.assert_('blaoulrao' in output, "Info failed for %s"%name)
+            self.assert_('alrkkcao4' in output, "Idea failed for %s"%name)
+            self.assert_('ntoircoa5' in output, "Help failed for %s"%name)
+            self.assert_('http://bnatorkcao.net' in output,
+                                                  "Link(1) failed for %s"%name)
+            self.assert_('kroacaonteu' in output, "Link(2) failed for %s"%name)
+            self.assert_('http://jrotjkor.net' in output,
+                                        "Link detection(1) failed for %s"%name)
+            self.assert_('krotroun' in output,
+                                        "Link detection(2) failed for %s"%name)
+            self.assert_('xrceoukrc' in output, "Action failed for %s"%name)
+            self.assert_('okbtrokr' in output, "Nick failed for %s"%name)
+
+            # Things which should only appear or not appear in the
+            # notes (not the logs):
+            if 'log' not in name:
+                self.assert_( 'ckmorkont' not in output,
+                              "Undo failed for %s"%name)
+                self.assert_('topic_doeschange' in output,
+                             "Chair changing topic failed for %s"%name)
+                self.assert_('topic_doesntchange' not in output,
+                             "Non-chair not changing topic failed for %s"%name)
+                self.assert_('topic_doesnt2change' not in output,
+                            "Un-chaired was able to chang topic for %s"%name)
+
+    #def test_contents_test(self):
+    #    contents = open('test-script-3.log.txt').read()
+    #    M = process_meeting(contents=file('test-script-3.log.txt').read(),
+    #                        extraConfig={'writer_map':self.full_writer_map})
+    #    results = M.save()
+    #    for line in contents.split('\n'):
+    #        m = re.search(r'#(\w+)\s+(.*)', line)
+    #        if not m:
+    #            continue
+    #        type_ = m.group(1)
+    #        text = m.group(2)
+    #        text = re.sub('[^\w]+', '', text).lower()
+    #
+    #        m2 = re.search(t2, re.sub(r'[^\w\n]', '', results['.txt']))
+    #        import fitz.interactnow
+    #        print m.groups()
+
+    def test_actionNickMatching(self):
+        """Test properly detect nicknames in lines
+
+        This checks the 'Action items, per person' list to make sure
+        that the nick matching is limited to full words.  For example,
+        the nick 'jon' will no longer be assigned lines containing
+        'jonathan'.
+        """
+        script = """
+        20:13:50 <x> #startmeeting
+        20:13:50 <somenick>
+        20:13:50 <someone> #action say somenickLONG
+        20:13:50 <someone> #action say the somenicklong
+        20:13:50 <somenick> I should not have an item assisgned to me.
+        20:13:50 <somenicklong> I should have some things assigned to me.
+        20:13:50 <x> #endmeeting
+        """
+        M = process_meeting(script)
+        results = M.save()['.html']
+        # This regular expression is:
+        # \bsomenick\b   - the nick in a single word
+        # (?! \()        - without " (" following it... to not match
+        #                  the "People present" section.
+        assert not re.search(r'\bsomenick\b(?! \()',
+                         results, re.IGNORECASE), \
+                         "Nick full-word matching failed"
+
+    def test_urlMatching(self):
+        """Test properly detection of URLs in lines
+        """
+        script = """
+        20:13:50 <x> #startmeeting
+        20:13:50 <x> #link prefix http://site1.com suffix
+        20:13:50 <x> http://site2.com suffix
+        20:13:50 <x> ftp://ftpsite1.com suffix
+        20:13:50 <x> #link prefix ftp://ftpsite2.com suffix
+        20:13:50 <x> irc://ircsite1.com suffix
+        20:13:50 <x> mailto://a@mail.com suffix
+        20:13:50 <x> #endmeeting
+        """
+        M = process_meeting(script)
+        results = M.save()['.html']
+        assert re.search(r'prefix.*href.*http://site1.com.*suffix',
+                         results), "URL missing 1"
+        assert re.search(r'href.*http://site2.com.*suffix',
+                         results), "URL missing 2"
+        assert re.search(r'href.*ftp://ftpsite1.com.*suffix',
+                         results), "URL missing 3"
+        assert re.search(r'prefix.*href.*ftp://ftpsite2.com.*suffix',
+                         results), "URL missing 4"
+        assert re.search(r'href.*mailto://a@mail.com.*suffix',
+                         results), "URL missing 5"
+
+    def t_css(self):
+        """Runs all CSS-related tests.
+        """
+        self.test_css_embed()
+        self.test_css_noembed()
+        self.test_css_file_embed()
+        self.test_css_file()
+        self.test_css_none()
+    def test_css_embed(self):
+        extraConfig={ }
+        results = self.M_trivial(extraConfig={}).save()
+        self.assert_('<link rel="stylesheet" ' not in results['.html'])
+        self.assert_('body {'                      in results['.html'])
+        self.assert_('<link rel="stylesheet" ' not in results['.log.html'])
+        self.assert_('body {'                      in results['.log.html'])
+    def test_css_noembed(self):
+        extraConfig={'cssEmbed_minutes':False,
+                     'cssEmbed_log':False,}
+        M = self.M_trivial(extraConfig=extraConfig)
+        results = M.save()
+        self.assert_('<link rel="stylesheet" '     in results['.html'])
+        self.assert_('body {'                  not in results['.html'])
+        self.assert_('<link rel="stylesheet" '     in results['.log.html'])
+        self.assert_('body {'                  not in results['.log.html'])
+    def test_css_file(self):
+        tmpf = tempfile.NamedTemporaryFile()
+        magic_string = '546uorck6o45tuo6'
+        tmpf.write(magic_string)
+        tmpf.flush()
+        extraConfig={'cssFile_minutes':  tmpf.name,
+                     'cssFile_log':      tmpf.name,}
+        M = self.M_trivial(extraConfig=extraConfig)
+        results = M.save()
+        self.assert_('<link rel="stylesheet" ' not in results['.html'])
+        self.assert_(magic_string                  in results['.html'])
+        self.assert_('<link rel="stylesheet" ' not in results['.log.html'])
+        self.assert_(magic_string                  in results['.log.html'])
+    def test_css_file_embed(self):
+        tmpf = tempfile.NamedTemporaryFile()
+        magic_string = '546uorck6o45tuo6'
+        tmpf.write(magic_string)
+        tmpf.flush()
+        extraConfig={'cssFile_minutes':  tmpf.name,
+                     'cssFile_log':      tmpf.name,
+                     'cssEmbed_minutes': False,
+                     'cssEmbed_log':     False,}
+        M = self.M_trivial(extraConfig=extraConfig)
+        results = M.save()
+        self.assert_('<link rel="stylesheet" '     in results['.html'])
+        self.assert_(tmpf.name                     in results['.html'])
+        self.assert_('<link rel="stylesheet" '     in results['.log.html'])
+        self.assert_(tmpf.name                     in results['.log.html'])
+    def test_css_none(self):
+        tmpf = tempfile.NamedTemporaryFile()
+        magic_string = '546uorck6o45tuo6'
+        tmpf.write(magic_string)
+        tmpf.flush()
+        extraConfig={'cssFile_minutes':  'none',
+                     'cssFile_log':      'none',}
+        M = self.M_trivial(extraConfig=extraConfig)
+        results = M.save()
+        self.assert_('<link rel="stylesheet" ' not in results['.html'])
+        self.assert_('<style type="text/css" ' not in results['.html'])
+        self.assert_('<link rel="stylesheet" ' not in results['.log.html'])
+        self.assert_('<style type="text/css" ' not in results['.log.html'])
+
+    def test_filenamevars(self):
+        def getM(fnamepattern):
+            M = meeting.Meeting(channel='somechannel',
+                                network='somenetwork',
+                                owner='nobody',
+                     extraConfig={'filenamePattern':fnamepattern})
+            M.addline('nobody', '#startmeeting')
+            return M
+        # Test the %(channel)s and %(network)s commands in supybot.
+        M = getM('%(channel)s-%(network)s')
+        assert M.config.filename().endswith('somechannel-somenetwork'), \
+               "Filename not as expected: "+M.config.filename()
+        # Test dates in filenames
+        M = getM('%(channel)s-%%F')
+        import time
+        assert M.config.filename().endswith(time.strftime('somechannel-%F')),\
+               "Filename not as expected: "+M.config.filename()
+        # Test #meetingname in filenames
+        M = getM('%(channel)s-%(meetingname)s')
+        M.addline('nobody', '#meetingname blah1234')
+        assert M.config.filename().endswith('somechannel-blah1234'),\
+               "Filename not as expected: "+M.config.filename()
+
+
+if __name__ == '__main__':
+    os.chdir(os.path.join(os.path.dirname(__file__), '.'))
+    if len(sys.argv) <= 1:
+        unittest.main()
+    else:
+        for testname in sys.argv[1:]:
+            print testname
+            if hasattr(MeetBotTest, testname):
+                MeetBotTest(methodName=testname).debug()
+            else:
+                MeetBotTest(methodName='test_'+testname).debug()
+

diff --git a/bot/tests/test-script-1.log.txt b/bot/tests/test-script-1.log.txt
new file mode 100644
index 0000000..7f46176
--- /dev/null
+++ b/bot/tests/test-script-1.log.txt
@@ -0,0 +1,85 @@
+20:13:46 <MrBeige> #startmeeting
+
+20:13:50 <T-Rex> #info this command is just before the first topic
+
+20:13:50 <T-Rex> #topic Test of topics
+20:13:50 <T-Rex> #topic Second topic
+20:13:50 <T-Rex> #meetingtopic the meeting topic
+20:13:50 <T-Rex> #topic With áccents
+
+
+20:13:50 <MrBeige> #topic General command tests
+
+20:13:50 <MrBeige> #accepted we will include this new format if we so choose.
+20:13:50 <MrBeige> #rejected we will not include this new format.
+20:13:50 <MrBeige> #chair Utahraptor T-Rex not-here
+20:13:50 <MrBeige> #chair Utahraptor T-Rex
+20:13:50 <MrBeige> #nick someone-not-present
+20:13:50 <MrBeige> #chair áccents
+20:13:50 <MrBeige> #nick áccenẗs
+20:13:50 <MrBeige> #unchar not-here
+
+# all commands
+20:13:50 <MrBeige> #topic Test of all commands with different arguments
+20:13:50 <MrBeige> #topic
+20:13:50 <MrBeige> #idea
+20:13:50 <MrBeige> #info
+20:13:50 <MrBeige> #action
+20:13:50 <MrBeige> #agreed
+20:13:50 <MrBeige> #halp
+20:13:50 <MrBeige> #accepted
+20:13:50 <MrBeige> #rejected
+
+20:13:50 <MrBeige> #topic Commands with non-ascii
+20:13:50 <MrBeige> #topic    üáç€
+20:13:50 <MrBeige> #idea     üáç€
+20:13:50 <MrBeige> #info     üáç€
+20:13:50 <MrBeige> #action   üáç€
+20:13:50 <MrBeige> #agreed   üáç€
+20:13:50 <MrBeige> #halp     üáç€
+20:13:50 <MrBeige> #accepted üáç€
+20:13:50 <MrBeige> #rejected üáç€
+
+
+20:13:50 <MrBeige> #item blah
+20:13:50 <MrBeige> #idea blah
+20:13:50 <MrBeige> #action blah
+20:13:50 <Utahraptor> #agreed blah
+
+# escapes
+20:13:50 <MrBeige> #topic Escapes
+20:13:50 <Utahraptor> #nick <b>
+20:13:50 <Utahraptor> #nick **
+20:13:50 <Utahraptor> #idea blah_ blah_ ReST link reference...
+20:13:50 <ReST1_> #idea blah blah blah
+20:13:50 <ReST2_> this is some text
+20:13:50 <ReST2_> #idea under_score
+20:13:50 <Re_ST> #idea under_score
+20:13:50 <Re_ST> #idea under1_1score
+20:13:50 <Re_ST> #idea under1_score
+20:13:50 <Re_ST> #idea under_1score
+20:13:50 <Re_ST> #idea under-_score
+20:13:50 <Re_ST> #idea under_-score
+
+# links
+20:13:50 <MrBeige> #topic Links
+20:13:50 <Utahraptor> #link http://test<b>.zgib.net
+20:13:50 <Utahraptor> #link ftp://test<b>.zgib.net "
+20:13:50 <Utahraptor> #link mailto://a@bla"h.com
+20:13:50 <Utahraptor> #link http://test.zgib.net/&testpage
+20:13:50 <Utahraptor> #link prefix http://test.zgib.net/&testpage suffix
+20:13:50 <Utahraptor> #link prefix ftp://test.zg"ib.net/&testpage suffix
+20:13:50 <Utahraptor> #link prefix mailto://a@blah.com&testpage suffix
+20:13:50 <Utahraptor> #link prefix http://google.com/. suffix
+20:13:50 <Utahraptor> #link prefix (http://google.com/) suffix
+
+
+# accents
+20:13:50 <MrBeige> #topic Character sets
+20:13:50 <Üţáhraptõr> Nick with accents.
+20:13:50 <Üţáhraptõr> #idea Nick with accents.
+
+# actions in actions
+# 
+
+20:13:52 <MrBeige> #endmeeting

diff --git a/bot/tests/test-script-2.log.txt b/bot/tests/test-script-2.log.txt
new file mode 100644
index 0000000..0819953
--- /dev/null
+++ b/bot/tests/test-script-2.log.txt
@@ -0,0 +1,49 @@
+#startmeeting
+this is a test line
+hi
+blah
+#topic play with chairs some
+#chair Hydroxide
+#chair h01ger
+#unchair Hydroxide
+#topic test action items
+something to say
+#action MrBeige does something
+#action h01ger and MrBeige do something else
+#action NickThatIsntHere does something
+#action MrGreen acts awesome
+#nick MrGreen
+#topic test other commands
+#info no I won't
+#idea blah
+#link http://www.debian.org
+http://www.debian.org
+/me says hi
+#topic try to cause some problems
+evil code to mess up html <b><i><u>
+#info evil code to mess up html <b><i><u>
+#nick
+#nick 
+#chair
+#chair 
+#unchair
+#info
+#info 
+#idea
+#idea 
+#topic test removing item from the minutes (nothing should be here)
+#info this shouldn't appear in the minutes
+#undo
+#topic    üñìcöde stuff
+#chair    üñìcöde
+#unchair  üñìcöde
+#info     üñìcöde
+#idea     üñìcöde
+#help     üñìcöde
+#action   üñìcöde
+#agreed   üñìcöde
+#accepted üñìcöde
+#rejected üñìcöde
+#endmeeting
+
+



^ permalink raw reply related	[flat|nested] only message in thread

only message in thread, other threads:[~2011-06-05 20:38 UTC | newest]

Thread overview: (only message) (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2011-06-05 20:37 [gentoo-commits] proj/council-webapp:master commit in: bot/tests/, bot/MeetBot/, bot/ircmeeting/, bot/, bot/doc/ Petteri Räty

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox