Added rendered world part to EmailPlayer notification
[pyrisk.git] / pyrisk / player / email.py
1 # Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
2 #
3 # This program is free software; you can redistribute it and/or modify
4 # it under the terms of the GNU General Public License as published by
5 # the Free Software Foundation; either version 2 of the License, or
6 # (at your option) any later version.
7 #
8 # This program is distributed in the hope that it will be useful,
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 # GNU General Public License for more details.
12 #
13 # You should have received a copy of the GNU General Public License along
14 # with this program; if not, write to the Free Software Foundation, Inc.,
15 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
16
17 """An email interface for players.
18 """
19
20 from __future__ import absolute_import
21 from cStringIO import StringIO
22 from email.generator import Generator
23 from email.parser import Parser
24 from email.mime.image import MIMEImage
25 from email.mime.multipart import MIMEMultipart
26 from email.mime.text import MIMEText
27 from email.utils import getaddresses, formataddr, formatdate, make_msgid
28 import os
29 import smtplib
30 import subprocess
31 import sys
32 import tempfile
33
34 from ..base import Player, PlayerError
35 from ..graphics import WorldRenderer
36
37
38 # Configure alternative sendmail command in case smtplib is too
39 # annoying.  Set SENDMAIL to None to use smtplib.
40 SENDMAIL = None
41 #SENDMAIL = ['/usr/sbin/sendmail', '-t']
42 #SENDMAIL = ['/usr/bin/msmtp', '-t']
43
44
45 class IncomingEmailDispatcher (object):
46     """For reading reply messages.
47     """
48     def __init__(self, fifo_path=None, verbose=True):
49         self.verbose = verbose
50         self._cache = []
51         if fifo_path == None:
52             self.dir_path = tempfile.mkdtemp(suffix='.pyrisk')
53             self.fifo_path = os.path.join(self.dir_path, 'incoming')
54         else:
55             self.dir_path = None
56             self.fifo_path = os.path.abspath(fifo_path)
57         os.mkfifo(self.fifo_path)
58     def close(self):
59         os.remove(self.fifo_path)
60         if self.dir_path != None:
61             os.rmdir(self.dir_path)
62     def get(self, tag):
63         for msg_tag, msg in self._cache:
64             if msg_tag == tag:
65                 self._cache.remove(msg)
66                 return msg
67         msg = self._get_msg()
68         msg_tag = self._msg_tag(msg['Subject'])
69         while msg_tag != tag:
70             self._cache.append((msg_tag, msg))
71             msg = self._get_msg()
72             msg_tag = self._msg_tag(msg['Subject'])
73         if self.verbose == True:
74             print >> sys.stderr, msg
75         return msg
76     def _msg_tag(self, subject):
77         """ Return the tag portion of a message subject.
78
79         >>> ied = IncomingEmailDispatcher()
80         >>> ied._msg_tag('[TAG] Hi there')
81         u'[TAG]'
82         >>> ied._msg_tag('  [tg] Hi there')
83         u'[tg]'
84         >>> ied._msg_tag(' Re: [t] Hi there')
85         u'[t]'
86         >>> ied.close()
87         """
88         if subject == None:
89             return None
90         subject = subject.strip()
91         if subject.startswith('Re:'):
92             subject = subject[len('Re:'):]
93             subject = subject.strip()
94         args = subject.split(u']',1)
95         if len(args) < 1:
96             return None
97         return args[0]+u']'
98     def _get_msg(self):
99         # FIFO blocks on open until a writer also opens
100         self.fifo = open(self.fifo_path, 'r')
101         text = self.fifo.read()
102         self.fifo.close()
103         p = Parser()
104         return p.parsestr(text)
105
106 class OutgoingEmailDispatcher (object):
107     """For sending outgoing messages.
108     """
109     def __init__(self, return_address, return_name='PyRisk server',
110                  sendmail=None, verbose_execute=False,
111                  smtp_host=None, smtp_port=465,
112                  smtp_user=None, smtp_password=None):
113         self.return_address = return_address
114         self.return_name = return_name
115         self.sendmail = sendmail
116         if self.sendmail == None:
117             self.sendmail = SENDMAIL
118         self.verbose_execute = verbose_execute
119         self.smtp_host = smtp_host
120         self.smtp_port = smtp_port
121         self.smtp_user = smtp_user
122         self.smtp_password = smtp_password
123     def send(self, msg):
124         """Send an email Message instance on its merry way.
125         """
126         msg['From'] = formataddr((self.return_name, self.return_address))
127         msg['Reply-to'] = msg['From']
128         msg['Date'] = formatdate()
129         msg['Message-id'] = make_msgid()
130
131         if self.sendmail != None:
132             self._execute(self.sendmail, stdin=self._flatten(msg))
133             return None
134         s = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port)
135         s.connect()
136         s.login(self.smtp_user, self.smtp_password)
137         s.sendmail(from_addr=self._source_email(msg),
138                    to_addrs=self._target_emails(msg),
139                    msg=self._flatten(msg))
140         s.close()
141     def _execute(self, args, stdin=None, expect=(0,)):
142         """
143         Execute a command (allows us to drive gpg).
144         """
145         if self.verbose_execute == True:
146             print >> sys.stderr, '$ '+args
147         try:
148             p = subprocess.Popen(args,
149                                  stdin=subprocess.PIPE,
150                                  stdout=subprocess.PIPE,
151                                  stderr=subprocess.PIPE,
152                                  shell=False, close_fds=True)
153         except OSError, e:
154             strerror = '%s\nwhile executing %s' % (e.args[1], args)
155             raise Exception, strerror
156         output, error = p.communicate(input=stdin)
157         status = p.wait()
158         if self.verbose_execute == True:
159             print >> sys.stderr, '(status: %d)\n%s%s' % (status, output, error)
160         if status not in expect:
161             strerror = '%s\nwhile executing %s\n%s\n%d' % (args[1], args, error, status)
162             raise Exception, strerror
163         return status, output, error
164     def _source_email(self, msg, return_realname=False):
165         """
166         Search the header of an email Message instance to find the
167         sender's email address.
168         """
169         froms = msg.get_all('from', [])
170         from_tuples = getaddresses(froms) # [(realname, email_address), ...]
171         if return_realname == True:
172             return from_tuples[0] # (realname, email_address)
173         return from_tuples[0][1]  # email_address    
174     def _target_emails(self, msg):
175         """
176         Search the header of an email Message instance to find a
177         list of recipient's email addresses.
178         """
179         tos = msg.get_all('to', [])
180         ccs = msg.get_all('cc', [])
181         bccs = msg.get_all('bcc', [])
182         resent_tos = msg.get_all('resent-to', [])
183         resent_ccs = msg.get_all('resent-cc', [])
184         resent_bccs = msg.get_all('resent-bcc', [])
185         resent = resent_tos + resent_ccs + resent_bccs
186         if len(resent) > 0:
187             all_recipients = getaddresses(resent)
188         else:
189             all_recipients = getaddresses(tos + ccs + bccs)
190         return [addr[1] for addr in all_recipients]
191     def _flatten(self, msg, to_unicode=False):
192         """
193         Produce flat text output from an email Message instance.
194         """
195         assert msg != None
196         fp = StringIO()
197         g = Generator(fp, mangle_from_=False)
198         g.flatten(msg)
199         text = fp.getvalue()
200         if to_unicode == True:
201             encoding = msg.get_content_charset('utf-8')
202             text = unicode(text, encoding=encoding)
203         return text
204
205 def encodedMIMEText(body, encoding='us-ascii', disposition='inline', filename=None):
206     if encoding == 'us-ascii':
207         part = MIMEText(body)
208     else:
209         # Create the message ('plain' stands for Content-Type: text/plain)
210         part = MIMEText(body.encode(encoding), 'plain', encoding)
211     if filename == None:
212         part.add_header('Content-Disposition', disposition)
213     else:
214         part.add_header('Content-Disposition', disposition, filename=filename)
215     return part
216
217
218 class EmailPlayer (Player):
219     """Human Player with an email interface.
220
221     TODO: details on procmail setup
222     """
223     def __init__(self, name, address, incoming, outgoing, world_renderer=None):
224         Player.__init__(self, name)
225         self.address = address
226         self.outgoing = outgoing
227         self.incoming = incoming
228         self.world_renderer = world_renderer
229     def _tag(self):
230         return '[PyRisk %d]' % (id(self))
231     def _send_mail(self, world, log, subject, body):
232         msg = MIMEMultipart()
233         msg.attach(encodedMIMEText(body, filename='body'))
234         msg.attach(self._log_part(log))
235         msg.attach(self._world_part(world))
236         if self.world_renderer != None:
237             msg.attach(self._rendered_world_part(world, log))
238         msg['To'] = formataddr((self.name, self.address))
239         tag = self._tag()
240         msg['Subject'] = '%s %s' % (tag, subject)
241         self.outgoing.send(msg)
242         return tag
243     def _get_mail(self, tag):
244         msg = self.incoming.get(tag)
245         msg_charset = msg.get_content_charset('utf-8')
246         if msg.is_multipart():
247             for part in msg.walk():
248                 mime_type = part.get_content_type()
249                 if mime_type == 'text/plain':
250                     break
251             body = part.get_payload(decode=True)
252             charset = part.get_content_charset(msg_charset)
253         else:
254             body = msg.get_payload(decode=True)
255             charset = msg_charset
256             mime_type = msg.get_content_type()
257         if not mime_type.startswith('text/plain'):
258             raise PlayerError('Invalid MIME type %s (must be text/plain)'
259                               % mime_type)
260         body = unicode(body, charset) # convert text types to unicode
261         if len(body) == 0 or body[-1] != u'\n':
262             body += u'\n'
263         return body
264     def _world_part(self, world):
265         body = []
266         for continent in world:
267             body.append(str(continent))
268             for terr in continent:
269                 if terr.player == None:
270                     body.append('  %s\t%s' % (terr, terr.player))
271                 else:
272                     body.append('  %s\t%s\t%d' % (terr, terr.player, terr.armies))
273         return encodedMIMEText('\n'.join(body), filename='world')
274     def _rendered_world_part(self, log, world):
275         start_event = log[0]
276         players = start_event.players
277         body = self.world_renderer.render(world, players)
278         filename,subtype = \
279             self.world_renderer.filename_and_mime_image_type(world)
280         part = MIMEImage(body, subtype)
281         part.add_header('Content-Disposition', 'attachment', filename)
282         return part
283     def _log_part(self, log):
284         return encodedMIMEText('\n'.join(log), filename='log')
285     def report(self, world, log):
286         """Send reports about death and game endings.
287
288         These events mark the end of contact and require no change in
289         player status or response, so they get a special command
290         seperate from the usual action family.  The action commands in
291         Player subclasses can notify the player (possibly by calling
292         report internally) if they feel so inclined.
293         
294         See also
295         --------
296         draw - another notification-only method
297         """
298         self._send_mail(world, log, 'Report: %s' % log[-1],
299                         Player.report(world, log))
300     def draw(self, world, log, cards=[]):
301         """Only called if you earned a new card (or cards).
302
303         See also
304         --------
305         report - another notification-only method
306         """
307         Player.draw(self, world, log, cards)
308         body = ['New cards:']
309         body.extend(['  %s' % c for c in cards])
310         body = ['Current Hand:']
311         for c in self.hand:
312             if c.territory != None and c.territory.player == self:
313                 body.append('  %s (owned)' % c)
314             else:
315                 body.append('  %s' % c)
316         self._send_mail(world, log, 'Drawing cards', '\n'.join(body))
317     def __select_territory(self, world, log, error=None):
318         """Return the selected territory's name.
319         """
320         body = [
321             'Reply with first line of the body of your email set',
322             'to the name (long or short, case insenitive) of the',
323             'territory you wish to occupy.  Available territories',
324             'are:']
325         for t in world.territories():
326             if t.player == None:
327                 body.append('  %s' % t)
328         if error != None:
329             body.insert(0, '')
330             body.insert(0, str(error))
331         tag = self._send_mail(world, log, 'Select territory', '\n'.join(body))
332         body = self._get_mail(tag)
333         name = body.splitlines()[0].strip()
334         return name
335     def play_cards(self, world, log, error=None,
336                    play_required=True):
337         """Decide whether or not to turn in a set of cards.
338
339         Return a list of cards to turn in or None.  If play_required
340         is True, you *must* play.
341         """
342         possibles = list(self.hand.possible())
343         if len(possibles) == 0:
344             return None
345         subject = 'Play cards'
346         if play_required == True:
347             subject += ' (required)'
348         body = [
349             'Reply with first line of the body of your email set',
350             'to the number of the set you wish to play (leave body',
351             'blank to pass).  Available sets are:']
352         for i,h in enumerate(possibles):
353             body.append('  %d: %s' % (i, h))
354         if error != None:
355             body.insert(0, '')
356             body.insert(0, str(error))
357         tag = self._send_mail(world, log, subject, '\n'.join(body))
358         body = self._get_mail(tag)
359         text = body.splitlines()[0].strip()
360         if text == '':
361             return None
362         return possibles[int(text)]
363     def place_armies(self, world, log, error=None,
364                      remaining=1, this_round=1):
365         """Both during setup and before each turn.
366
367         Return {territory_name: num_armies, ...}
368         """
369         subject = 'Place %d of %d armies' % (this_round, remaining)
370         body = [
371             'You can place %d armies this round (out of %d in'
372             % (this_round, remaining),
373             'this phase).',
374             '',
375             'Reply with first line(s) of the body of your email set',
376             'to "<number_of_armies> : <territory_name>" followed by',
377             'a blank line.  For example',
378             '  1 : gbr',
379             '  4 : indo',
380             '  ...',
381             ''
382             'Your current disposition is:']
383         for t in self.territories(world):
384             body.append('  %d : %s' % (t.armies, t))
385         if error != None:
386             body.insert(0, '')
387             body.insert(0, str(error))
388         tag = self._send_mail(world, log, subject, '\n'.join(body))
389         body = self._get_mail(tag)
390         placements = {}
391         for line in body.splitlines():
392             line = line.strip()
393             if len(line) == 0:
394                 break
395             if line.count(':') != 1:
396                 raise PlayerError('Invalid syntax "%s"' % line)
397             armies,terr_name = [x.strip() for x in line.split(':')]
398             placements[terr_name] = int(armies)
399         return placements
400     def attack_and_fortify(self, world, log, error=None,
401                            mode='attack'):
402         """Return list of (source, target, armies) tuples.  Place None
403         in the list to end this phase.
404         """
405         if mode == 'attack':
406             subject = 'Attack and fortify'
407             body = [
408                 'You can attack as many times as you like, and fortify',
409                 'once at the end of the round.  Reply with first line(s)',
410                 'of the body of your email set to',
411                 '  "<source_name> : <target_name> : <number_of_armies>',
412                 'When you are done attacking or in place of a',
413                 'fortification, insert the line "Pass".  For example',
414                 '  gbr : ice : 3',
415                 '  jap : chi : 3',
416                 '  jap : chi : 3',
417                 '  Pass',
418                 '  gbr : seu : 7',
419                 '  ',
420                 'or',
421                 '  jap : chi : 3',
422                 '  Pass',
423                 '  Pass',
424                 '  ']
425         else:
426             assert mode == 'fortify', mode
427             subject = 'Fortify'
428             body = [
429                 'You can fortify once.  Reply with first line of the',
430                 'body of your email set to',
431                 '  "<source_name> : <target_name> : <number_of_armies>',
432                 'Or, if you choose to pass, either a blank line or',
433                 '"Pass".  For example',
434                 '  gbr : seu : 7',
435                 'or',
436                 '  ',
437                 'or',
438                 '  Pass']
439         if error != None:
440             body.insert(0, '')
441             body.insert(0, str(error))
442         tag = self._send_mail(world, log, subject, '\n'.join(body))
443         body = self._get_mail(tag)
444         if mode == 'fortify':
445             return [self._parse_attack_or_fortify_line(
446                         body.splitlines()[0], mode)]
447         actions = []
448         pass_count = 0
449         for line in body.splitlines():
450             action = self._parse_attack_or_fortify_line(line, mode)
451             if action == None:
452                 pass_count += 1
453                 if pass_count == 2:
454                     break
455             actions.append(action)
456         return actions
457     def _parse_attack_or_fortify_line(self, line, mode):
458         line = line.strip()
459         if line.count(':') == 2:
460             fields = [x.strip() for x in line.split(':')]
461             fields[2] = int(fields[2])
462             return fields
463         elif line.lower() == 'pass' \
464                 or (mode == 'fortify' and len(line) == 0):
465             return None
466     def support_attack(self, world, log, error,
467                        source, target):
468         """Follow up on a conquest by moving additional armies.
469         """
470         subject = 'Support conquest of %s by %s' % (target, source)
471         body = [
472             'You can move up to %d of the %d armies remaining on'
473             % (source.armies - 1, source.armies),
474             '%s to %s following your conquest.'
475             % (source, target),
476             '',
477             'Reply with first line(s) of the body of your email set',
478             'to "<number_of_armies>", or leave the first line blank',
479             'to pass.']
480         if error != None:
481             body.insert(0, '')
482             body.insert(0, str(error))
483         tag = self._send_mail(world, log, subject, '\n'.join(body))
484         body = self._get_mail(tag)
485         text = body.splitlines()[0].strip()
486         if text == '':
487             return 0
488         return int(text)
489
490
491 def test():
492     import doctest
493     failures,tests = doctest.testmod(sys.modules[__name__])
494     return failures