Merge lp:~stefanor/ibid/meetings into lp:~ibid-core/ibid/old-trunk-pack-0.92

Proposed by Stefano Rivera
Status: Merged
Approved by: Michael Gorven
Approved revision: 776
Merged at revision: 778
Proposed branch: lp:~stefanor/ibid/meetings
Merge into: lp:~ibid-core/ibid/old-trunk-pack-0.92
Diff against target: 572 lines (+530/-2)
4 files modified
ibid/plugins/meetings.py (+427/-0)
ibid/templates/meetings/minutes.html (+59/-0)
ibid/templates/meetings/minutes.txt (+34/-0)
scripts/ibid-plugin (+10/-2)
To merge this branch: bzr merge lp:~stefanor/ibid/meetings
Reviewer Review Type Date Requested Status
Michael Gorven Approve
Jonathan Hitchcock Approve
Review via email: mp+13953@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Jonathan Hitchcock (vhata) wrote :

It'll do for now.

review: Approve
Revision history for this message
Michael Gorven (mgorven) wrote :

+ permission = u'chairmeeting'
+ permission = u'meetingchair'

Why are those different?

+ time = IntOption('poll_time', u'Poll length', 5 * 60)

Might be useful to specify the duration in the start poll command.

lp:~stefanor/ibid/meetings updated
775. By Stefano Rivera

Consistent permission names

Revision history for this message
Stefano Rivera (stefanor) wrote :

> Why are those different?

Fixed in r775

> Might be useful to specify the duration in the start poll command.

Fixed in r776

lp:~stefanor/ibid/meetings updated
776. By Stefano Rivera

Specify poll end when starting poll

Revision history for this message
Michael Gorven (mgorven) wrote :

 review approve
 status approved

review: Approve
lp:~stefanor/ibid/meetings updated
777. By Stefano Rivera

1-index polls

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'ibid/plugins/meetings.py'
2--- ibid/plugins/meetings.py 1970-01-01 00:00:00 +0000
3+++ ibid/plugins/meetings.py 2009-12-02 11:45:24 +0000
4@@ -0,0 +1,427 @@
5+from datetime import datetime, timedelta
6+import logging
7+from os import makedirs
8+from os.path import dirname, expanduser, join
9+import re
10+from urllib import quote
11+from xmlrpclib import ServerProxy
12+
13+from dateutil.parser import parse
14+from dateutil.tz import tzlocal, tzutc
15+from jinja import Environment, PackageLoader
16+
17+import ibid
18+from ibid.compat import json
19+from ibid.config import BoolOption, IntOption, Option
20+from ibid.plugins import Processor, match, authorise
21+from ibid.utils import format_date, plural
22+
23+help = {}
24+log = logging.getLogger('plugins.meetings')
25+
26+templates = Environment(loader=PackageLoader('ibid', 'templates'))
27+meetings = {}
28+
29+help['meeting'] = u'Take minutes of an IRC Meeting'
30+class Meeting(Processor):
31+ u"""
32+ (start | end) meeting [about <title>]
33+ I am <True Name>
34+ topic <topic>
35+ (agreed | idea | accepted | rejected) <statement>
36+ minutes so far
37+ meeting title is <title>
38+ """
39+ feature = 'meeting'
40+ permission = u'chairmeeting'
41+
42+ formats = Option('formats', u'Formats to log to. '
43+ u'Requires templates of the name meeting/minutes.format',
44+ ('json', 'txt', 'html'))
45+ logfile = Option('logfile', u'File name for meeting logs. '
46+ u'Can contain substitutions: source, channel, date, format',
47+ 'logs/meetings/%(source)s-%(channel)s-%(date)s.%(format)s')
48+ logurl = Option('logurl', u'Public URL for meeting logs. '
49+ u'Can contain substitutions: source, channel, date, format '
50+ u'If unset, will use a pastebin.',
51+ None)
52+ date_format = Option('date_format', 'Format to substitute %(date)s with',
53+ '%Y-%m-%d-%H-%M-%S')
54+
55+ @authorise
56+ @match(r'^start\s+meeting(?:\s+about\s+(.+))?$')
57+ def start_meeting(self, event, title):
58+ if not event.public:
59+ event.addresponse(u'Sorry, must be done in public')
60+ return
61+ if (event.source, event.channel) in meetings:
62+ event.addresponse(u'Sorry, meeting in progress.')
63+ return
64+ meeting = {
65+ 'starttime': event.time,
66+ 'convenor': event.sender['nick'],
67+ 'source': event.source,
68+ 'channel': ibid.sources[event.source]
69+ .logging_name(event.channel),
70+ 'title': title,
71+ 'attendees': {},
72+ 'minutes': [{
73+ 'time': event.time,
74+ 'type': 'started',
75+ 'subject': None,
76+ 'nick': event.sender['nick'],
77+ }],
78+ 'log': [],
79+ }
80+ meetings[(event.source, event.channel)] = meeting
81+
82+ event.addresponse(u'gets out his memo-pad and cracks his knuckles',
83+ action=True)
84+
85+ @match(r'^i\s+am\s+(.+)$')
86+ def ident(self, event, name):
87+ if not event.public or (event.source, event.channel) not in meetings:
88+ return
89+
90+ meeting = meetings[(event.source, event.channel)]
91+ meeting['attendees'][event.sender['nick']] = name
92+
93+ event.addresponse(True)
94+
95+ @authorise
96+ @match(r'^(topic|idea|agreed|accepted|rejected)\s+(.+)$')
97+ def identify(self, event, action, subject):
98+ if not event.public or (event.source, event.channel) not in meetings:
99+ return
100+
101+ action = action.lower()
102+
103+ meeting = meetings[(event.source, event.channel)]
104+ meeting['minutes'].append({
105+ 'time': event.time,
106+ 'type': action,
107+ 'subject': subject,
108+ 'nick': event.sender['nick'],
109+ })
110+
111+ if action == 'topic':
112+ message = u'Current Topic: %s'
113+ elif action == 'idea':
114+ message = u'Idea recorded: %s'
115+ elif action == 'agreed':
116+ message = u'Agreed: %s'
117+ elif action == 'accepted':
118+ message = u'Accepted: %s'
119+ elif action == 'rejected':
120+ message = u'Rejected: %s'
121+ event.addresponse(message, subject, address=False)
122+
123+ @authorise
124+ @match(r'^meeting\s+title\s+is\s+(.+)$')
125+ def set_title(self, event, title):
126+ if not event.public:
127+ event.addresponse(u'Sorry, must be done in public')
128+ return
129+ if (event.source, event.channel) not in meetings:
130+ event.addresponse(u'Sorry, no meeting in progress.')
131+ return
132+ meeting = meetings[(event.source, event.channel)]
133+ meeting['title'] = title
134+ event.addresponse(True)
135+
136+ @match(r'^minutes(?:\s+(?:so\s+far|please))?$')
137+ def write_minutes(self, event):
138+ if not event.public:
139+ event.addresponse(u'Sorry, must be done in public')
140+ return
141+ if (event.source, event.channel) not in meetings:
142+ event.addresponse(u'Sorry, no meeting in progress.')
143+ return
144+ meeting = meetings[(event.source, event.channel)]
145+ meeting['attendees'].update((e['nick'], None) for e in meeting['log']
146+ if e['nick'] not in meeting['attendees']
147+ and e['nick'] != ibid.config['botname'])
148+
149+ render_to = set()
150+ if self.logurl is None:
151+ render_to.add('txt')
152+ render_to.update(self.formats)
153+ minutes = {}
154+ for format in render_to:
155+ if format == 'json':
156+ class DTJSONEncoder(json.JSONEncoder):
157+ def default(self, o):
158+ if isinstance(o, datetime):
159+ return o.strftime('%Y-%m-%dT%H:%M:%SZ')
160+ return json.JSONEncoder.default(self, o)
161+ minutes[format] = json.dumps(meeting, cls=DTJSONEncoder,
162+ indent=2)
163+ else:
164+ template = templates.get_template('meetings/minutes.' + format)
165+ minutes[format] = template.render(meeting=meeting) \
166+ .encode('utf-8')
167+
168+ filename = self.logfile % {
169+ 'source': event.source.replace('/', '-'),
170+ 'channel': meeting['channel'].replace('/', '-'),
171+ 'date': meeting['starttime'].strftime(self.date_format),
172+ 'format': format,
173+ }
174+ filename = join(ibid.options['base'], expanduser(filename))
175+ try:
176+ makedirs(dirname(filename))
177+ except OSError, e:
178+ if e.errno != 17:
179+ raise e
180+ f = open(filename, 'w+')
181+ f.write(minutes[format])
182+ f.close()
183+
184+ if self.logurl is None:
185+ pastebin = ServerProxy('http://paste.pocoo.org/xmlrpc/',
186+ allow_none=True)
187+ id = pastebin.pastes.newPaste(None, minutes['txt'], None, '',
188+ 'text/plain', False)
189+
190+ url = u'http://paste.pocoo.org/show/' + id
191+ elif u'%(format)s' not in self.logurl:
192+ # Content Negotiation
193+ url = self.logurl % {
194+ 'source': quote(event.source.replace('/', '-')),
195+ 'channel': quote(meeting['channel'].replace('/', '-')),
196+ 'date': quote(meeting['starttime'].strftime(self.date_format)),
197+ }
198+ else:
199+ url = u' :: '.join(u'%s: %s' % (format, self.logurl % {
200+ 'source': quote(event.source.replace('/', '-')),
201+ 'channel': quote(meeting['channel'].replace('/', '-')),
202+ 'date': quote(meeting['starttime'].strftime(self.date_format)),
203+ 'format': quote(format),
204+ }) for format in self.formats)
205+
206+ event.addresponse(u'Minutes available at %s', url, address=False)
207+
208+ @authorise
209+ @match(r'^end\s+meeting$')
210+ def end_meeting(self, event):
211+ if not event.public:
212+ event.addresponse(u'Sorry, must be done in public')
213+ return
214+ if (event.source, event.channel) not in meetings:
215+ event.addresponse(u'Sorry, no meeting in progress.')
216+ return
217+ meeting = meetings[(event.source, event.channel)]
218+
219+ meeting['endtime'] = event.time
220+ meeting['log'].append({
221+ 'nick': event.sender['nick'],
222+ 'type': event.type,
223+ 'message': event.message['raw'],
224+ 'time': event.time,
225+ })
226+ meeting['minutes'].append({
227+ 'time': event.time,
228+ 'type': 'ended',
229+ 'subject': None,
230+ 'nick': event.sender['nick'],
231+ })
232+
233+ event.addresponse(u'Meeting Ended', address=False)
234+ self.write_minutes(event)
235+ del meetings[(event.source, event.channel)]
236+
237+class MeetingLogger(Processor):
238+ addressed = False
239+ processed = True
240+ priority = 1900
241+ feature = 'meeting'
242+
243+ def process(self, event):
244+ if 'channel' in event and 'source' in event \
245+ and (event.source, event.channel) in meetings:
246+ meeting = meetings[(event.source, event.channel)]
247+ message = event.message
248+ if isinstance(message, dict):
249+ message = message['raw']
250+ meeting['log'].append({
251+ 'nick': event.sender['nick'],
252+ 'type': event.type,
253+ 'message': message,
254+ 'time': event.time,
255+ })
256+ for response in event.responses:
257+ type = 'message'
258+ if response.get('action', False):
259+ type = 'action'
260+ elif response.get('notice', False):
261+ type = 'notice'
262+
263+ meeting['log'].append({
264+ 'nick': ibid.config['botname'],
265+ 'type': type,
266+ 'message': response['reply'],
267+ 'time': event.time,
268+ })
269+
270+help['poll'] = u'Does a quick poll of channel members'
271+class Poll(Processor):
272+ u"""
273+ [secret] poll on <topic> [until <time>] vote <option> [or <option>]...
274+ vote (<id> | <option>) [on <topic>]
275+ end poll
276+ """
277+ feature = 'poll'
278+ permission = u'chairmeeting'
279+
280+ polls = {}
281+
282+ date_utc = BoolOption('date_utc', u'Interpret poll end times as UTC', False)
283+ poll_time = IntOption('poll_time', u'Default poll length', 5 * 60)
284+
285+ @authorise
286+ @match(r'^(secret\s+)?(?:poll|ballot)\s+on\s+(.+?)\s+'
287+ r'(?:until\s+(.+?)\s+)?vote\s+(.+\sor\s.+)$')
288+ def start_poll(self, event, secret, topic, end, options):
289+ if not event.public:
290+ event.addresponse(u'Sorry, must be done in public')
291+ return
292+
293+ if (event.source, event.channel) in self.polls:
294+ event.addresponse(u'Sorry, poll on %s in progress.',
295+ self.polls[(event.source, event.channel)].topic)
296+ return
297+
298+ class PollContainer(object):
299+ pass
300+ poll = PollContainer()
301+ self.polls[(event.source, event.channel)] = poll
302+
303+ poll.secret = secret is not None
304+ if end is None:
305+ poll.end = event.time + timedelta(seconds=self.poll_time)
306+ else:
307+ poll.end = parse(end)
308+ if poll.end.tzinfo is None and not self.date_utc:
309+ poll.end = poll.end.replace(tzinfo=tzlocal())
310+ if poll.end.tzinfo is not None:
311+ poll.end = poll.end.astimezone(tzutc()).replace(tzinfo=None)
312+ if poll.end < event.time:
313+ event.addresponse(u"I can't end a poll in the past")
314+ return
315+
316+ poll.topic = topic
317+ poll.options = re.split(r'\s+or\s+', options)
318+ poll.lower_options = [o.lower() for o in poll.options]
319+ poll.votes = {}
320+
321+ event.addresponse(
322+ u'You heard that, voting has begun. '
323+ u'The polls close at %(end)s. '
324+ u'%(private)s'
325+ u'The Options Are:', {
326+ 'private': poll.secret
327+ and u'You may vote in public or private. '
328+ or u'',
329+ 'end': format_date(poll.end),
330+ }, address=False)
331+
332+ for i, o in enumerate(poll.options):
333+ event.addresponse(u'%(id)i: %(option)s', {
334+ 'id': i + 1,
335+ 'option': o,
336+ }, address=False)
337+
338+ delay = poll.end - event.time
339+ poll.delayed_call = ibid.dispatcher.call_later(
340+ delay.days * 86400 + delay.seconds,
341+ self.end_poll, event)
342+
343+ def locate_poll(self, event, selection, topic):
344+ "Attempt to find which poll the user is voting in"
345+ if event.public:
346+ if (event.source, event.channel) in self.polls:
347+ return self.polls[(event.source, event.channel)]
348+ else:
349+ if topic:
350+ polls = [p for p in self.polls.iteritems()
351+ if p.topic.lower() == topic.lower()]
352+ if len(polls) == 1:
353+ return polls[0]
354+ polls = [self.polls[p] for p in self.polls.iterkeys()
355+ if p[0] == event.source]
356+ if len(polls) == 1:
357+ return polls[0]
358+ elif len(polls) > 1:
359+ if not selection.isdigit():
360+ possibles = [p for p in polls
361+ if selection.lower() in p.lower_options]
362+ if len(possibles) == 1:
363+ return possibles[0]
364+ event.addresponse(u'Sorry, I have more than one poll open. '
365+ u'Please say "vote %s on <topic>"', selection)
366+ return
367+ event.addresponse(u'Sorry, no poll in progress')
368+
369+ @match(r'^vote\s+(?:for\s+)?(.+?)(?:\s+on\s+(.+))?$')
370+ def vote(self, event, selection, topic):
371+ poll = self.locate_poll(event, selection, topic)
372+ log.debug(u'Poll: %s', repr(poll))
373+ if poll is None:
374+ return
375+
376+ if selection.isdigit() and int(selection) > 0 \
377+ and int(selection) <= len(poll.options):
378+ selection = int(selection) - 1
379+ else:
380+ try:
381+ selection = poll.lower_options.index(selection)
382+ except ValueError:
383+ event.addresponse(
384+ u"Sorry, I don't know of such an option for %s",
385+ poll.topic)
386+ return
387+ poll.votes[event.identity] = selection
388+ if not event.public:
389+ event.addresponse(
390+ u'Your vote on %(topic)s has been registered as %(option)s', {
391+ 'topic': poll.topic,
392+ 'option': poll.options[selection],
393+ })
394+ else:
395+ event.processed = True
396+
397+ @match('^end\s+poll$')
398+ @authorise
399+ def end_poll(self, event):
400+ if not event.public:
401+ event.addresponse(u'Sorry, must be done in public')
402+ return
403+
404+ if (event.source, event.channel) not in self.polls:
405+ event.addresponse(u'Sorry, no poll in progress.')
406+ return
407+
408+ poll = self.polls.pop((event.source, event.channel))
409+ if poll.delayed_call.active():
410+ poll.delayed_call.cancel()
411+
412+ votes = [[poll.options[i], 0]
413+ for i in range(len(poll.options))]
414+ for vote in poll.votes.itervalues():
415+ votes[vote][1] += 1
416+ votes.sort(reverse=True, key=lambda x: x[1])
417+ event.addresponse(u'The polls are closed. Totals:', address=False)
418+
419+ position = (1, votes[0][1])
420+ for o, v in votes:
421+ if v < position[1]:
422+ position = (position[0] + 1, v)
423+ event.addresponse(
424+ u'%(position)i: %(option)s - %(votes)i %(word)s', {
425+ 'position': position[0],
426+ 'option': o,
427+ 'votes': v,
428+ 'word': plural(v, u'vote', u'votes'),
429+ }, address=False)
430+
431+# vi: set et sta sw=4 ts=4:
432
433=== added directory 'ibid/templates/meetings'
434=== added file 'ibid/templates/meetings/minutes.html'
435--- ibid/templates/meetings/minutes.html 1970-01-01 00:00:00 +0000
436+++ ibid/templates/meetings/minutes.html 2009-12-02 11:45:24 +0000
437@@ -0,0 +1,59 @@
438+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
439+<html>
440+<head>
441+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
442+ <title>Minutes: {{ meeting.title|e|default("Untitled") }}</title>
443+</head>
444+<body>
445+ <h1>Meeting about {{ meeting.title|e|default("something or the other") }}</h1>
446+ <div id="meetingmeta">
447+ Convened at {{ meeting.starttime }} by {{ meeting.convenor|e }}
448+ in {{ meeting.channel|e }} on {{ meeting.source|e }}
449+ </div>
450+
451+ <h2>Minutes</h2>
452+ <div id="minutes">
453+{%- for event in meeting.minutes %}
454+ <div class="line {{ event.type }}">
455+ <span class="time">[{{ event.time.strftime('%H:%M:%S') }}]</span>
456+ <span class="type">{{ event.type|e|upper }}{{ ':' if event.subject else '' }}</span>
457+{%- if event.subject %}
458+ <span class="subject">{{ event.subject|e }}</span>
459+{%- endif %}
460+ <span class="nick">({{ event.nick|e }})</span>
461+ </div>
462+{%- endfor %}
463+ </div>
464+
465+ <h2>Present</h2>
466+ <ul id="present">
467+{%- for nick, name in meeting.attendees.iteritems() %}
468+ <li>
469+{%- if name %}
470+ <span class="name">{{ name|e }}</span>
471+ <span class="nick hasname">({{ nick|e }})</span>
472+{%- else %}
473+ <span class="nick noname">{{ nick|e }}</span>
474+{%- endif %}
475+ </li>
476+{%- endfor %}
477+ </ul>
478+
479+ <h2>Raw Log</h2>
480+ <div id="log">
481+{%- for event in meeting.log %}
482+ <div class="line {{ event.type }}">
483+ <span class="time">[{{ event.time.strftime('%H:%M:%S') }}]</span>
484+{%- if event.type == 'message' %}
485+ <span class="nick">&lt;{{ event.nick|e }}&gt;</span>
486+{%- elif event.type == 'action' %}
487+ <span class="nick">* {{ event.nick|e }}</span>
488+{%- elif event.type == 'notice' %}
489+ <span class="nick">- {{ event.nick|e }}</span>
490+{%- endif %}
491+ <span class="message">{{ event.message|e }}</span>
492+ </div>
493+{%- endfor %}
494+ </div>
495+</body>
496+</html>
497
498=== added file 'ibid/templates/meetings/minutes.txt'
499--- ibid/templates/meetings/minutes.txt 1970-01-01 00:00:00 +0000
500+++ ibid/templates/meetings/minutes.txt 2009-12-02 11:45:24 +0000
501@@ -0,0 +1,34 @@
502+Minutes from Meeting about {{ meeting.title|default("something or the other") }}
503+Convened at {{ meeting.starttime }} by {{ meeting.convenor }}
504+in {{ meeting.channel }} on {{ meeting.source }}
505+
506+Minutes
507+=======
508+
509+{% for event in meeting.minutes -%}
510+[{{ event.time.strftime('%H:%M:%S') }}] {{ event.type | upper }}
511+{{- ': ' + event.subject if event.subject else '' }} ({{ event.nick }})
512+{% endfor %}
513+Present
514+=======
515+
516+{% for nick, name in meeting.attendees.iteritems() -%}
517+{%- if name -%}
518+* {{ name }} ({{ nick }})
519+{%- else -%}
520+* {{ nick }}
521+{%- endif %}
522+{% endfor %}
523+Raw Log
524+=======
525+
526+{% for event in meeting.log -%}
527+[{{ event.time.strftime('%H:%M:%S') }}] {# Preserve the space after the timestamp #}
528+{%- if event.type == 'message' -%}
529+<{{ event.nick }}> {{ event.message }}
530+{%- elif event.type == 'action' -%}
531+* {{ event.nick }} {{ event.message }}
532+{%- elif event.type == 'notice' -%}
533+- {{ event.nick }} {{ event.message }}
534+{%- endif %}
535+{% endfor %}
536
537=== modified file 'scripts/ibid-plugin'
538--- scripts/ibid-plugin 2009-10-26 11:08:37 +0000
539+++ scripts/ibid-plugin 2009-12-02 11:45:24 +0000
540@@ -26,6 +26,8 @@
541 help="Only load the specified plugins, not the common base plugins")
542 parser.add_option("-c", "--configured", dest="load_configured", action="store_true",
543 help="Load all all configured plugins")
544+parser.add_option("-p", "--public", dest="public", action="store_true", default=False,
545+ help="Make testchan public, it's private by default")
546
547 (options, args) = parser.parse_args()
548
549@@ -37,7 +39,13 @@
550 def auth_responses(event, permission):
551 return True
552
553-ibid.plugins.auth_responses = auth_responses
554+class FakeAuth(object):
555+ def authorise(self, event, permission):
556+ return True
557+
558+ibid.auth = FakeAuth()
559+
560+#ibid.plugins.auth_responses = auth_responses
561 ibid.options = {'base': '.'}
562
563 logging.basicConfig(level=logging.DEBUG)
564@@ -106,7 +114,7 @@
565 event.identity = identity_id
566 event.account = None
567 event.addressed = True
568- event.public = False
569+ event.public = options.public
570 event.channel = u"testchan"
571
572 try:

Subscribers

People subscribed via source and target branches