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