Fixed up IncomingEmailDispatcher to work in real life ;).
[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 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):
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         tag = self._send_mail(world, log, 'Select territory', '\n'.join(body))
315         body = self._get_mail(tag)
316         name = body.splitlines()[0].strip()
317         return name
318     def play_cards(self, world, log, play_required=True):
319         """Decide whether or not to turn in a set of cards.
320
321         Return a list of cards to turn in or None.  If play_required
322         is True, you *must* play.
323         """
324         possibles = list(self.hand.possible())
325         if len(possibles) == 0:
326             return None
327         subject = 'Play cards'
328         if play_required == True:
329             subject += ' (required)'
330         body = [
331             'Reply with first line of the body of your email set',
332             'to the number of the set you wish to play (leave body',
333             'blank to pass).  Available sets are:']
334         for i,h in enumerate(possibles):
335             body.append('  %d: %s' % (i, h))
336         tag = self._send_mail(world, log, subject, '\n'.join(body))
337         body = self._get_mail(tag)
338         text = body.splitlines()[0].strip()
339         if text == '':
340             return None
341         return possibles[int(text)]
342     def place_armies(self, world, log, remaining=1, this_round=1):
343         """Both during setup and before each turn.
344
345         Return {territory_name: num_armies, ...}
346         """
347         subject = 'Place %d of %d armies' % (this_round, remaining)
348         body = [
349             'You can place %d armies this round (out of %d in'
350             % (this_round, remaining),
351             'this phase).',
352             '',
353             'Reply with first line(s) of the body of your email set',
354             'to "<number_of_armies> : <territory_name>" followed by',
355             'a blank line.  For example',
356             '  1 : gbr',
357             '  4 : indo',
358             '  ...',
359             ''
360             'Your current disposition is:']
361         for t in self.territories(world):
362             body.append('  %d : %s' % (t.armies, t))
363         tag = self._send_mail(world, log, subject, '\n'.join(body))
364         body = self._get_mail(tag)
365         placements = {}
366         for line in body.splitlines():
367             line = line.strip()
368             if len(line) == 0:
369                 break
370             armies,terr_name = [x.strip() for x in line.split(':')]
371             placements[terr_name] = int(armies)
372         return placements
373     def attack_and_fortify(self, world, log, mode='attack'):
374         """Return list of (source, target, armies) tuples.  Place None
375         in the list to end this phase.
376         """
377         if mode == 'attack':
378             subject = 'Attack and fortify'
379             body = [
380                 'You can attack as many times as you like, and fortify',
381                 'once at the end of the round.  Reply with first line(s)',
382                 'of the body of your email set to',
383                 '  "<source_name> : <target_name> : <number_of_armies>',
384                 'When you are done attacking or in place of a',
385                 'fortification, insert the line "Pass".  For example',
386                 '  gbr : ice : 3',
387                 '  jap : chi : 3',
388                 '  jap : chi : 3',
389                 '  Pass',
390                 '  gbr : seu : 7',
391                 '  ',
392                 'or',
393                 '  jap : chi : 3',
394                 '  Pass',
395                 '  Pass',
396                 '  ']
397         else:
398             assert mode == 'fortify', mode
399             subject = 'Fortify'
400             body = [
401                 'You can fortify once.  Reply with first line of the',
402                 'body of your email set to',
403                 '  "<source_name> : <target_name> : <number_of_armies>',
404                 'Or, if you choose to pass, either a blank line or',
405                 '"Pass".  For example',
406                 '  gbr : seu : 7',
407                 'or',
408                 '  ',
409                 'or',
410                 '  Pass']
411         tag = self._send_mail(world, log, subject, '\n'.join(body))
412         body = self._get_mail(tag)
413         if mode == 'fortify':
414             return [self._parse_attack_or_fortify_line(
415                         body.splitlines()[0], mode)]
416         actions = []
417         pass_count = 0
418         for line in body.splitlines():
419             action = self._parse_attack_or_fortify_line(line, mode)
420             if action == None:
421                 pass_count += 1
422                 if pass_count == 2:
423                     break
424             actions.append(action)
425         return actions
426     def _parse_attack_or_fortify_line(self, line, mode):
427         line = line.strip()
428         if line.count(':') == 2:
429             fields = [x.strip() for x in line.split(':')]
430             fields[2] = int(fields[2])
431             return fields
432         elif line.lower() == 'pass' \
433                 or (mode == 'fortify' and len(line) == 0):
434             return None
435     def support_attack(self, world, log, source, target):
436         """Follow up on a conquest by moving additional armies.
437         """
438         subject = 'Support conquest of %s by %s' % (target, source)
439         body = [
440             'You can move up to %d of the %d armies remaining on'
441             % (source.armies - 1, source.armies),
442             '%s to %s following your conquest.'
443             % (source, target),
444             '',
445             'Reply with first line(s) of the body of your email set',
446             'to "<number_of_armies>", or leave the first line blank',
447             'to pass.']
448         tag = self._send_mail(world, log, subject, '\n'.join(body))
449         body = self._get_mail(tag)
450         text = body.splitlines()[0].strip()
451         if text == '':
452             return 0
453         return int(text)
454
455
456 def test():
457     import doctest
458     failures,tests = doctest.testmod(sys.modules[__name__])
459     return failures