1 # Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
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.
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.
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.
17 """An email interface for players.
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
34 from ..base import Player, PlayerError
35 from ..graphics import WorldRenderer
38 # Configure alternative sendmail command in case smtplib is too
39 # annoying. Set SENDMAIL to None to use smtplib.
41 #SENDMAIL = ['/usr/sbin/sendmail', '-t']
42 #SENDMAIL = ['/usr/bin/msmtp', '-t']
45 class IncomingEmailDispatcher (object):
46 """For reading reply messages.
48 def __init__(self, fifo_path=None, verbose=True):
49 self.verbose = verbose
52 self.dir_path = tempfile.mkdtemp(suffix='.pyrisk')
53 self.fifo_path = os.path.join(self.dir_path, 'incoming')
56 self.fifo_path = os.path.abspath(fifo_path)
57 os.mkfifo(self.fifo_path)
59 os.remove(self.fifo_path)
60 if self.dir_path != None:
61 os.rmdir(self.dir_path)
63 for msg_tag, msg in self._cache:
65 self._cache.remove(msg)
68 msg_tag = self._msg_tag(msg['Subject'])
70 self._cache.append((msg_tag, msg))
72 msg_tag = self._msg_tag(msg['Subject'])
73 if self.verbose == True:
74 print >> sys.stderr, msg
76 def _msg_tag(self, subject):
77 """ Return the tag portion of a message subject.
79 >>> ied = IncomingEmailDispatcher()
80 >>> ied._msg_tag('[TAG] Hi there')
82 >>> ied._msg_tag(' [tg] Hi there')
84 >>> ied._msg_tag(' Re: [t] Hi there')
90 subject = subject.strip()
91 if subject.startswith('Re:'):
92 subject = subject[len('Re:'):]
93 subject = subject.strip()
94 args = subject.split(u']',1)
99 # FIFO blocks on open until a writer also opens
100 self.fifo = open(self.fifo_path, 'r')
101 text = self.fifo.read()
104 return p.parsestr(text)
106 class OutgoingEmailDispatcher (object):
107 """For sending outgoing messages.
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
124 """Send an email Message instance on its merry way.
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()
131 if self.sendmail != None:
132 self._execute(self.sendmail, stdin=self._flatten(msg))
134 s = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port)
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))
141 def _execute(self, args, stdin=None, expect=(0,)):
143 Execute a command (allows us to drive gpg).
145 if self.verbose_execute == True:
146 print >> sys.stderr, '$ '+args
148 p = subprocess.Popen(args,
149 stdin=subprocess.PIPE,
150 stdout=subprocess.PIPE,
151 stderr=subprocess.PIPE,
152 shell=False, close_fds=True)
154 strerror = '%s\nwhile executing %s' % (e.args[1], args)
155 raise Exception, strerror
156 output, error = p.communicate(input=stdin)
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):
166 Search the header of an email Message instance to find the
167 sender's email address.
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):
176 Search the header of an email Message instance to find a
177 list of recipient's email addresses.
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
187 all_recipients = getaddresses(resent)
189 all_recipients = getaddresses(tos + ccs + bccs)
190 return [addr[1] for addr in all_recipients]
191 def _flatten(self, msg, to_unicode=False):
193 Produce flat text output from an email Message instance.
197 g = Generator(fp, mangle_from_=False)
200 if to_unicode == True:
201 encoding = msg.get_content_charset('utf-8')
202 text = unicode(text, encoding=encoding)
205 def encodedMIMEText(body, encoding='us-ascii', disposition='inline', filename=None):
206 if encoding == 'us-ascii':
207 part = MIMEText(body)
209 # Create the message ('plain' stands for Content-Type: text/plain)
210 part = MIMEText(body.encode(encoding), 'plain', encoding)
212 part.add_header('Content-Disposition', disposition)
214 part.add_header('Content-Disposition', disposition, filename=filename)
218 class EmailPlayer (Player):
219 """Human Player with an email interface.
221 TODO: details on procmail setup
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
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))
240 msg['Subject'] = '%s %s' % (tag, subject)
241 self.outgoing.send(msg)
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':
251 body = part.get_payload(decode=True)
252 charset = part.get_content_charset(msg_charset)
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)'
260 body = unicode(body, charset) # convert text types to unicode
261 if len(body) == 0 or body[-1] != u'\n':
264 def _world_part(self, world):
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))
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):
276 players = start_event.players
277 body = self.world_renderer.render(world, players)
279 self.world_renderer.filename_and_mime_image_type(world)
280 part = MIMEImage(body, subtype)
281 part.add_header('Content-Disposition', 'attachment', filename)
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.
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.
296 draw - another notification-only method
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).
305 report - another notification-only method
307 Player.draw(self, world, log, cards)
308 body = ['New cards:']
309 body.extend([' %s' % c for c in cards])
310 body = ['Current Hand:']
312 if c.territory != None and c.territory.player == self:
313 body.append(' %s (owned)' % c)
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.
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',
325 for t in world.territories():
327 body.append(' %s' % t)
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()
335 def play_cards(self, world, log, error=None,
337 """Decide whether or not to turn in a set of cards.
339 Return a list of cards to turn in or None. If play_required
340 is True, you *must* play.
342 possibles = list(self.hand.possible())
343 if len(possibles) == 0:
345 subject = 'Play cards'
346 if play_required == True:
347 subject += ' (required)'
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))
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()
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.
367 Return {territory_name: num_armies, ...}
369 subject = 'Place %d of %d armies' % (this_round, remaining)
371 'You can place %d armies this round (out of %d in'
372 % (this_round, remaining),
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',
382 'Your current disposition is:']
383 for t in self.territories(world):
384 body.append(' %d : %s' % (t.armies, t))
387 body.insert(0, str(error))
388 tag = self._send_mail(world, log, subject, '\n'.join(body))
389 body = self._get_mail(tag)
391 for line in body.splitlines():
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)
400 def attack_and_fortify(self, world, log, error=None,
402 """Return list of (source, target, armies) tuples. Place None
403 in the list to end this phase.
406 subject = 'Attack and fortify'
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',
426 assert mode == 'fortify', mode
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',
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)]
449 for line in body.splitlines():
450 action = self._parse_attack_or_fortify_line(line, mode)
455 actions.append(action)
457 def _parse_attack_or_fortify_line(self, line, mode):
459 if line.count(':') == 2:
460 fields = [x.strip() for x in line.split(':')]
461 fields[2] = int(fields[2])
463 elif line.lower() == 'pass' \
464 or (mode == 'fortify' and len(line) == 0):
466 def support_attack(self, world, log, error,
468 """Follow up on a conquest by moving additional armies.
470 subject = 'Support conquest of %s by %s' % (target, source)
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.'
477 'Reply with first line(s) of the body of your email set',
478 'to "<number_of_armies>", or leave the first line blank',
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()
493 failures,tests = doctest.testmod(sys.modules[__name__])