aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoachim Filip Ignacy Bartosik <jbartosik@gmail.com>2011-05-18 16:44:11 +0200
committerJoachim Filip Ignacy Bartosik <jbartosik@gmail.com>2011-05-24 19:03:00 +0200
commite8c39d513356e14b60813c54824a63a6ad516348 (patch)
treedba08b74ad8470b3d36d813d14e310e512acea57
parentBasic meeting participation tracing (diff)
downloadcouncil-webapp-e8c39d513356e14b60813c54824a63a6ad516348.tar.gz
council-webapp-e8c39d513356e14b60813c54824a63a6ad516348.tar.bz2
council-webapp-e8c39d513356e14b60813c54824a63a6ad516348.zip
MeetBot plugin from Debian
-rw-r--r--bot/MeetBot/__init__.py66
-rw-r--r--bot/MeetBot/config.py53
-rw-r--r--bot/MeetBot/plugin.py302
-rw-r--r--bot/MeetBot/supybotconfig.py174
-rw-r--r--bot/MeetBot/test.py82
-rw-r--r--bot/README.txt113
-rw-r--r--bot/doc/Manual.txt926
-rw-r--r--bot/doc/meetingLocalConfig-example.py19
-rw-r--r--bot/ircmeeting/__init__.py0
-rw-r--r--bot/ircmeeting/css-log-default.css15
-rw-r--r--bot/ircmeeting/css-minutes-default.css34
-rw-r--r--bot/ircmeeting/items.py292
-rw-r--r--bot/ircmeeting/meeting.py672
-rw-r--r--bot/ircmeeting/template.html102
-rw-r--r--bot/ircmeeting/template.txt55
-rw-r--r--bot/ircmeeting/writers.py1197
-rw-r--r--bot/setup.py12
-rw-r--r--bot/tests/run_test.py352
-rw-r--r--bot/tests/test-script-1.log.txt85
-rw-r--r--bot/tests/test-script-2.log.txt49
20 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
--- /dev/null
+++ b/bot/ircmeeting/__init__.py
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
+
+