diff options
author | Joachim Filip Ignacy Bartosik <jbartosik@gmail.com> | 2011-05-18 16:44:11 +0200 |
---|---|---|
committer | Joachim Filip Ignacy Bartosik <jbartosik@gmail.com> | 2011-05-24 19:03:00 +0200 |
commit | e8c39d513356e14b60813c54824a63a6ad516348 (patch) | |
tree | dba08b74ad8470b3d36d813d14e310e512acea57 | |
parent | Basic meeting participation tracing (diff) | |
download | council-webapp-e8c39d513356e14b60813c54824a63a6ad516348.tar.gz council-webapp-e8c39d513356e14b60813c54824a63a6ad516348.tar.bz2 council-webapp-e8c39d513356e14b60813c54824a63a6ad516348.zip |
MeetBot plugin from Debian
-rw-r--r-- | bot/MeetBot/__init__.py | 66 | ||||
-rw-r--r-- | bot/MeetBot/config.py | 53 | ||||
-rw-r--r-- | bot/MeetBot/plugin.py | 302 | ||||
-rw-r--r-- | bot/MeetBot/supybotconfig.py | 174 | ||||
-rw-r--r-- | bot/MeetBot/test.py | 82 | ||||
-rw-r--r-- | bot/README.txt | 113 | ||||
-rw-r--r-- | bot/doc/Manual.txt | 926 | ||||
-rw-r--r-- | bot/doc/meetingLocalConfig-example.py | 19 | ||||
-rw-r--r-- | bot/ircmeeting/__init__.py | 0 | ||||
-rw-r--r-- | bot/ircmeeting/css-log-default.css | 15 | ||||
-rw-r--r-- | bot/ircmeeting/css-minutes-default.css | 34 | ||||
-rw-r--r-- | bot/ircmeeting/items.py | 292 | ||||
-rw-r--r-- | bot/ircmeeting/meeting.py | 672 | ||||
-rw-r--r-- | bot/ircmeeting/template.html | 102 | ||||
-rw-r--r-- | bot/ircmeeting/template.txt | 55 | ||||
-rw-r--r-- | bot/ircmeeting/writers.py | 1197 | ||||
-rw-r--r-- | bot/setup.py | 12 | ||||
-rw-r--r-- | bot/tests/run_test.py | 352 | ||||
-rw-r--r-- | bot/tests/test-script-1.log.txt | 85 | ||||
-rw-r--r-- | bot/tests/test-script-2.log.txt | 49 |
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("&", "&").replace("<", "<").replace(">", ">") +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 + + |