Update email to tremily.
[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
36
37 # Configure alternative sendmail command in case smtplib is too
38 # annoying.  Set SENDMAIL to None to use smtplib.
39 SENDMAIL = None
40 #SENDMAIL = ['/usr/sbin/sendmail', '-t']
41 #SENDMAIL = ['/usr/bin/msmtp', '-t']
42
43
44 class IncomingEmailDispatcher (object):
45     """For reading reply messages.
46     """
47     def __init__(self, fifo_path=None, verbose=True):
48         self.verbose = verbose
49         self._cache = []
50         if fifo_path == None:
51             self.dir_path = tempfile.mkdtemp(suffix='.pyrisk')
52             self.fifo_path = os.path.join(self.dir_path, 'incoming')
53         else:
54             self.dir_path = None
55             self.fifo_path = os.path.abspath(fifo_path)
56         os.mkfifo(self.fifo_path)
57     def close(self):
58         os.remove(self.fifo_path)
59         if self.dir_path != None:
60             os.rmdir(self.dir_path)
61     def get(self, tag):
62         for msg_tag, msg in self._cache:
63             if msg_tag == tag:
64                 self._cache.remove(msg)
65                 return msg
66         msg = self._get_msg()
67         msg_tag = self._msg_tag(msg['Subject'])
68         while msg_tag != tag:
69             self._cache.append((msg_tag, msg))
70             msg = self._get_msg()
71             msg_tag = self._msg_tag(msg['Subject'])
72         if self.verbose == True:
73             print >> sys.stderr, msg
74         return msg
75     def _msg_tag(self, subject):
76         """ Return the tag portion of a message subject.
77
78         >>> ied = IncomingEmailDispatcher()
79         >>> ied._msg_tag('[TAG] Hi there')
80         u'[TAG]'
81         >>> ied._msg_tag('  [tg] Hi there')
82         u'[tg]'
83         >>> ied._msg_tag(' Re: [t] Hi there')
84         u'[t]'
85         >>> ied.close()
86         """
87         if subject == None:
88             return None
89         subject = subject.strip()
90         if subject.startswith('Re:'):
91             subject = subject[len('Re:'):]
92             subject = subject.strip()
93         args = subject.split(u']',1)
94         if len(args) < 1:
95             return None
96         return args[0]+u']'
97     def _get_msg(self):
98         # FIFO blocks on open until a writer also opens
99         self.fifo = open(self.fifo_path, 'r')
100         text = self.fifo.read()
101         self.fifo.close()
102         p = Parser()
103         return p.parsestr(text)
104
105 class OutgoingEmailDispatcher (object):
106     """For sending outgoing messages.
107     """
108     def __init__(self, return_address, return_name='PyRisk server',
109                  sendmail=None, verbose_execute=False,
110                  smtp_host=None, smtp_port=465,
111                  smtp_user=None, smtp_password=None):
112         self.return_address = return_address
113         self.return_name = return_name
114         self.sendmail = sendmail
115         if self.sendmail == None:
116             self.sendmail = SENDMAIL
117         self.verbose_execute = verbose_execute
118         self.smtp_host = smtp_host
119         self.smtp_port = smtp_port
120         self.smtp_user = smtp_user
121         self.smtp_password = smtp_password
122     def send(self, msg):
123         """Send an email Message instance on its merry way.
124         """
125         msg['From'] = formataddr((self.return_name, self.return_address))
126         msg['Reply-to'] = msg['From']
127         msg['Date'] = formatdate()
128         msg['Message-id'] = make_msgid()
129
130         if self.sendmail != None:
131             self._execute(self.sendmail, stdin=self._flatten(msg))
132             return None
133         s = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port)
134         s.connect()
135         s.login(self.smtp_user, self.smtp_password)
136         s.sendmail(from_addr=self._source_email(msg),
137                    to_addrs=self._target_emails(msg),
138                    msg=self._flatten(msg))
139         s.close()
140     def _execute(self, args, stdin=None, expect=(0,)):
141         """
142         Execute a command (allows us to drive gpg).
143         """
144         if self.verbose_execute == True:
145             print >> sys.stderr, '$ '+args
146         try:
147             p = subprocess.Popen(args,
148                                  stdin=subprocess.PIPE,
149                                  stdout=subprocess.PIPE,
150                                  stderr=subprocess.PIPE,
151                                  shell=False, close_fds=True)
152         except OSError, e:
153             strerror = '%s\nwhile executing %s' % (e.args[1], args)
154             raise Exception, strerror
155         output, error = p.communicate(input=stdin)
156         status = p.wait()
157         if self.verbose_execute == True:
158             print >> sys.stderr, '(status: %d)\n%s%s' % (status, output, error)
159         if status not in expect:
160             strerror = '%s\nwhile executing %s\n%s\n%d' % (args[1], args, error, status)
161             raise Exception, strerror
162         return status, output, error
163     def _source_email(self, msg, return_realname=False):
164         """
165         Search the header of an email Message instance to find the
166         sender's email address.
167         """
168         froms = msg.get_all('from', [])
169         from_tuples = getaddresses(froms) # [(realname, email_address), ...]
170         if return_realname == True:
171             return from_tuples[0] # (realname, email_address)
172         return from_tuples[0][1]  # email_address    
173     def _target_emails(self, msg):
174         """
175         Search the header of an email Message instance to find a
176         list of recipient's email addresses.
177         """
178         tos = msg.get_all('to', [])
179         ccs = msg.get_all('cc', [])
180         bccs = msg.get_all('bcc', [])
181         resent_tos = msg.get_all('resent-to', [])
182         resent_ccs = msg.get_all('resent-cc', [])
183         resent_bccs = msg.get_all('resent-bcc', [])
184         resent = resent_tos + resent_ccs + resent_bccs
185         if len(resent) > 0:
186             all_recipients = getaddresses(resent)
187         else:
188             all_recipients = getaddresses(tos + ccs + bccs)
189         return [addr[1] for addr in all_recipients]
190     def _flatten(self, msg, to_unicode=False):
191         """
192         Produce flat text output from an email Message instance.
193         """
194         assert msg != None
195         fp = StringIO()
196         g = Generator(fp, mangle_from_=False)
197         g.flatten(msg)
198         text = fp.getvalue()
199         if to_unicode == True:
200             encoding = msg.get_content_charset('utf-8')
201             text = unicode(text, encoding=encoding)
202         return text
203
204 def encodedMIMEText(body, encoding='us-ascii', disposition='inline', filename=None):
205     if encoding == 'us-ascii':
206         part = MIMEText(body)
207     else:
208         # Create the message ('plain' stands for Content-Type: text/plain)
209         part = MIMEText(body.encode(encoding), 'plain', encoding)
210     if filename == None:
211         part.add_header('Content-Disposition', disposition)
212     else:
213         part.add_header('Content-Disposition', disposition, filename=filename)
214     return part
215
216
217 class EmailPlayer (Player):
218     """Human Player with an email interface.
219
220     TODO: details on procmail setup
221     """
222     def __init__(self, name, address, incoming, outgoing, world_renderer=None):
223         Player.__init__(self, name)
224         self.address = address
225         self.outgoing = outgoing
226         self.incoming = incoming
227         self.world_renderer = world_renderer
228     def _tag(self):
229         return '[PyRisk %d]' % (id(self))
230     def _send_mail(self, world, log, subject, body):
231         msg = MIMEMultipart()
232         msg.attach(encodedMIMEText(body, filename='body'))
233         msg.attach(self._log_part(log))
234         msg.attach(self._world_part(world))
235         if self.world_renderer != None:
236             msg.attach(self._rendered_world_part(world, log))
237         msg['To'] = formataddr((self.name, self.address))
238         tag = self._tag()
239         msg['Subject'] = '%s %s' % (tag, subject)
240         self.outgoing.send(msg)
241         return tag
242     def _get_mail(self, tag):
243         msg = self.incoming.get(tag)
244         msg_charset = msg.get_content_charset('utf-8')
245         if msg.is_multipart():
246             for part in msg.walk():
247                 mime_type = part.get_content_type()
248                 if mime_type == 'text/plain':
249                     break
250             body = part.get_payload(decode=True)
251             charset = part.get_content_charset(msg_charset)
252         else:
253             body = msg.get_payload(decode=True)
254             charset = msg_charset
255             mime_type = msg.get_content_type()
256         if not mime_type.startswith('text/plain'):
257             raise PlayerError('Invalid MIME type %s (must be text/plain)'
258                               % mime_type)
259         body = unicode(body, charset) # convert text types to unicode
260         if len(body) == 0 or body[-1] != u'\n':
261             body += u'\n'
262         return body
263     def _world_part(self, world):
264         body = []
265         for continent in world:
266             body.append(str(continent))
267             for terr in continent:
268                 if terr.player == None:
269                     body.append('  %s\t%s' % (terr, terr.player))
270                 else:
271                     body.append('  %s\t%s\t%d' % (terr, terr.player, terr.armies))
272         return encodedMIMEText('\n'.join(body), filename='world')
273     def _rendered_world_part(self, world, log):
274         start_event = log[0]
275         players = start_event.players
276         body = self.world_renderer.render(world, players)
277         filename,subtype = \
278             self.world_renderer.filename_and_mime_image_type(world)
279         part = MIMEImage(body, subtype)
280         part.add_header('Content-Disposition', 'attachment', filename=filename)
281         return part
282     def _log_part(self, log):
283         return encodedMIMEText('\n'.join([str(e) for e in log]),
284                                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