meetbot/meeting.py

663 lines
26 KiB
Python

# 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 pygments
#
# Throw any overrides into meetingLocalConfig.py in this directory:
#
# Where to store files on disk
logFileDir = '/home/richard/meetbot/'
# The links to the logfiles are given this prefix
logUrlPrefix = 'http://rkd.zgib.net/meetbot/'
# 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 command name,
# second group is the rest of the line.
command_RE = re.compile(r'#([\w]+)[ \t]*(.*)')
# This is the help printed when a meeting starts
usefulCommands = "#action #agreed #halp #info #idea #link #topic"
# 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'
def html(text):
"""Escape bad sequences (in HTML) in user-generated lines."""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def enc(text): return text.encode('utf-8', 'replace')
def dec(text): return text.decode('utf-8', 'replace')
# Set the timezone, using the variable above
os.environ['TZ'] = timeZone
time.tzset()
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: ]*)\]? *\* *([^ ]+) *(.*)')
# load custom local configurations
try:
import meetingLocalConfig
meetingLocalConfig = reload(meetingLocalConfig)
from meetingLocalConfig import *
except ImportError:
pass
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.reply("Meeting started %s %s. The chair is %s."%\
(time.asctime(time_), timeZone, self.owner))
self.reply(("Information about MeetBot at %s , Useful Commands: %s.")%\
(MeetBotInfoURL, usefulCommands))
self.starttime = time_
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.save()
self.reply("Meeting ended %s %s. Information about MeetBot at %s ."%\
(time.asctime(time_), timeZone, MeetBotInfoURL))
self.reply("Minutes: "+self.minutesFilename(url=True))
self.reply("Log: "+self.logFilename(url=True))
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 = Topic(nick=nick, line=line, **kwargs)
self.minutes.append(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.save()
def do_agreed(self, nick, **kwargs):
"""Add aggreement to the minutes - chairs only."""
if not self.isChair(nick): return
m = Agreed(nick, **kwargs)
self.minutes.append(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 = Accepted(nick, **kwargs)
self.minutes.append(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 = Rejected(nick, **kwargs)
self.minutes.append(m)
do_rejected = 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 = html(chair.strip())
if not chair: continue
if chair not in self.chairs:
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 = html(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(" ", "")
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 = Action(**kwargs)
self.minutes.append(m)
def do_info(self, **kwargs):
"""Add informational item to the minutes."""
m = Info(**kwargs)
self.minutes.append(m)
def do_idea(self, **kwargs):
"""Add informational item to the minutes."""
m = Idea(**kwargs)
self.minutes.append(m)
def do_halp(self, **kwargs):
"""Add call for halp to the minutes."""
m = Halp(**kwargs)
self.minutes.append(m)
do_help = do_halp
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(html(nick), lines=0)
def do_link(self, **kwargs):
"""Add informational item to the minutes."""
m = Link(**kwargs)
self.minutes.append(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):
self.owner = owner
self.channel = channel
self.currenttopic = ""
self.oldtopic = oldtopic
self.lines = [ ]
self.minutes = [ ]
self.attendees = { }
self.chairs = { }
self._writeRawLog = writeRawLog
self._meetingTopic = None
self._meetingname = ""
self._meetingIsOver = False
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(x)
else:
print "REPLY:", enc(x)
def topic(self, x):
"""Set the topic in the IRC channel."""
if hasattr(self, '_setTopic') and not self._lurk:
self._setTopic(x)
else:
print "TOPIC:", 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)
# 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.
"""
nick = html(nick)
self.addnick(nick)
line = line.strip(' \x01') # \x01 is present in ACTIONs
nick = dec(nick)
line = dec(line)
# 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)
# Handle any commands given in the line.
matchobj = 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 UrlProtocols:
self.do_link(nick=nick, line=line,
linenum=linenum, time_=time_)
def save(self):
"""Write all output files."""
if self._writeRawLog:
self.writeRawLog()
self.writeLogs()
self.writeMinutes()
def writeLogs(self):
"""Write pretty HTML logs."""
# pygments lexing setup:
# (pygments HTML-formatter handles HTML-escaping)
from pygments.lexers import IrcLogsLexer
from pygments.formatters import HtmlFormatter
import pygments.token as token
from pygments.lexer import bygroups
formatter = HtmlFormatter(encoding='utf-8', lineanchors='l',
full=True, style=pygmentizeStyle)
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(encoding='utf-8')
#from rkddp.interact import interact ; interact()
out = pygments.highlight("\n".join(self.lines), lexer, formatter)
# Do the writing...
f = file(self.logFilename(), 'w')
# We might want to restrict read-permissions of the files from
# the webserver.
if self._restrictlogs:
self.restrictPermissions(f)
f.write(out)
def writeMinutes(self):
"""Write the minutes summary."""
f = file(self.minutesFilename(), 'w')
data = [ ]
# We might want to restrict read-permissions of the files from
# the webserver.
if self._restrictlogs:
self.restrictPermissions(f)
if self._meetingTopic:
pageTitle = "%s: %s"%(self.channel, self._meetingTopic)
else:
pageTitle = "%s Meeting"%self.channel
# Header and things stored
data.append(
'''<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
<title>%s</title>
</head>
<body>
<h1>%s</h1>
Meeting started by %s at %s %s. (<a href="%s">full logs</a>)<br>
\n\n<table border=1>'''%(pageTitle, pageTitle, self.owner,
time.strftime("%H:%M:%S", self.starttime),
timeZone,
os.path.basename(self.logFilename())))
# Add all minute items to the table
for m in self.minutes:
data.append(m.html(self))
# End the log portion
data.append("""</table>
Meeting ended at %s %s. (<a href="%s">full logs</a>)"""%\
(time.strftime("%H:%M:%S", self.endtime), timeZone,
os.path.basename(self.logFilename())))
data.append("\n<br><br><br>")
# Action Items
data.append("<b>Action Items</b><ol>")
import meeting
for m in self.minutes:
# The hack below is needed because of pickling problems
if not isinstance(m, (Action, meeting.Action)): continue
data.append(" <li>%s</li>"%m.line) #already escaped
data.append("</ol>\n\n<br>")
# Action Items, by person (This could be made lots more efficient)
data.append("<b>Action Items, by person</b>\n<ol>")
for nick in sorted(self.attendees.keys(), key=lambda x: x.lower()):
headerPrinted = False
for m in self.minutes:
# The hack below is needed because of pickling problems
if not isinstance(m, (Action, meeting.Action)): continue
if m.line.find(nick) == -1: continue
if not headerPrinted:
data.append(" <li> %s <ol>"%nick)
headerPrinted = True
data.append(" <li>%s</li>"%m.line) # already escaped
m.assigned = True
if headerPrinted:
data.append(" </ol></li>")
# unassigned items:
data.append(" <li><b>UNASSIGNED</b><ol>")
numberUnassigned = 0
for m in self.minutes:
if not isinstance(m, (Action, meeting.Action)): continue
if getattr(m, 'assigned', False): continue
data.append(" <li>%s</li>"%m.line) # already escaped
numberUnassigned += 1
if numberUnassigned == 0: data.append(" <li>(none)</li>")
data.append(' </ol>\n</li>')
# clean-up
data.append("</ol>\n\n<br>")
# People Attending
data.append("""<b>People Present (lines said):</b><ol>""")
# sort by number of lines spoken
nicks = [ (n,c) for (n,c) in self.attendees.iteritems() ]
nicks.sort(key=lambda x: x[1], reverse=True)
for nick in nicks:
data.append(' <li>%s (%s)</li>'%(nick[0], nick[1]))
data.append("</ol>\n\n<br>")
data.append("""Generated by <a href="%s">MeetBot</a>."""%
MeetBotInfoURL)
data.append("</body></html>")
f.write(enc("\n".join(data)))
def writeRawLog(self):
"""Write raw text logs."""
f = file(self.logFilename(raw=True), 'w')
if self._restrictlogs:
self.restrictPermissions(f)
f.write(enc("\n".join(self.lines)))
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, '_filename', None):
return self._filename
# names useful for pathname formatting.
# Certain test channels always get the same name - don't need
# file prolifiration for them
if self.channel in specialChannels:
# mask global!!
pattern = specialChannelFilenamePattern
else:
pattern = filenamePattern
channel = self.channel.strip('# ').lower()
if self._meetingname:
meetingname = self._meetingname
else:
meetingname = channel
path = pattern%locals()
path = time.strftime(path, self.starttime)
# If we want the URL name, append URL prefix and return
if url:
return os.path.join(logUrlPrefix, path)
path = os.path.join(logFileDir, path)
return path
def logFilename(self, url=False, raw=False):
"""Name of the meeting logfile"""
extension = '.log.html'
if raw:
extension = '.log.txt'
fname = self.filename(url=url)+extension
dirname = os.path.dirname(fname)
if not url and dirname and not os.access(dirname, os.F_OK):
os.makedirs(dirname)
return fname
def minutesFilename(self, url=False):
"""Name of the meeting minutes file"""
fname = self.filename(url=url)+'.html'
dirname = os.path.dirname(fname)
if not url and dirname and not os.access(dirname, os.F_OK):
os.makedirs(dirname)
return fname
def restrictPermissions(self, f):
"""Remove the permissions given in the variable RestrictPerm."""
f.flush()
newmode = os.stat(f.name).st_mode & (~RestrictPerm)
os.chmod(f.name, newmode)
#
# These are objects which we can add to the meeting minutes. Mainly
# they exist to aid in HTML-formatting.
#
class BaseItem(object):
def get_replacements(self):
replacements = { }
for name in dir(self):
if name[0] == "_": continue
replacements[name] = getattr(self, name)
return replacements
def makeRSTref(self, M):
rstref = "%s-%s"%(self.nick, self.time)
M.rst_urls.append(".. _%s: %s"%(rstref, self.link+"#"+self.anchor))
return rstref
@property
def anchor(self):
return 'l-'+str(self.linenum)
class Topic(BaseItem):
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 html(self, M):
self.link = os.path.basename(M.logFilename())
self.topic = html(self.topic)
return """<tr><td><a href='%(link)s#%(anchor)s'>%(time)s</a></td>
<th colspan=3>Topic: %(topic)s</th>
</tr>"""%self.get_replacements()
def rst(self, M):
self.link = os.path.basename(M.logFilename())
self.topic = html(self.topic)
self.rstref = self.makeRSTref(M)
return """**%(topic)s** (`%(rstref)s`_)"""%self.get_replacements()
class GenericItem(BaseItem):
itemtype = ''
start = ''
end = ''
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 html(self, M):
self.link = os.path.basename(M.logFilename())
self.line = html(self.line)
return """<tr><td><a href='%(link)s#%(anchor)s'>%(time)s</a></td>
<td>%(itemtype)s</td><td>%(nick)s</td><td>%(start)s%(line)s%(end)s</td>
</tr>"""%self.get_replacements()
def rst(self, M):
self.link = os.path.basename(M.logFilename())
self.line = html(self.line)
self.rstref = self.makeRSTref(M)
return """*%(itemtype)s*: %(line)s (%(rstref)s_)"""%self.get_replacements()
class Info(GenericItem):
itemtype = 'INFO'
class Idea(GenericItem):
itemtype = 'IDEA'
class Agreed(GenericItem):
itemtype = 'AGREED'
class Action(GenericItem):
itemtype = 'ACTION'
class Halp(GenericItem):
itemtype = 'HALP'
class Accepted(GenericItem):
itemtype = 'ACCEPTED'
start = '<font color="green">'
end = '</font>'
class Rejected(GenericItem):
itemtype = 'REJECTED'
start = '<font color="red">'
end = '</font>'
class Link(BaseItem):
itemtype = 'LINK'
def __init__(self, nick, line, linenum, time_):
self.nick = nick ; self.linenum = linenum
self.time = time.strftime("%H:%M:%S", time_)
self.url, self.line = (line+' ').split(' ', 1)
# URL-sanitization
self.url_readable = html(self.url) # readable line version
self.url = self.url.replace('"', "%22")
# readable line satitization:
self.line = html(self.line.strip())
def html(self, M):
self.link = os.path.basename(M.logFilename())
return """<tr><td><a href='%(link)s#%(anchor)s'>%(time)s</a></td>
<td>%(itemtype)s</td><td>%(nick)s</td><td><a href="%(url)s">%(url_readable)s</a> %(line)s</td>
</tr>"""%self.get_replacements()
def rst(self, M):
self.link = os.path.basename(M.logFilename())
self.rstref = self.makeRSTref(M)
return """*%(itemtype)s*: %(url)s %(line)s (%(rstref)s_)"""%self.get_replacements()
# 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!