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