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
37 # Configure alternative sendmail command in case smtplib is too
38 # annoying. Set SENDMAIL to None to use smtplib.
40 #SENDMAIL = ['/usr/sbin/sendmail', '-t']
41 #SENDMAIL = ['/usr/bin/msmtp', '-t']
44 class IncomingEmailDispatcher (object):
45 """For reading reply messages.
47 def __init__(self, fifo_path=None, verbose=True):
48 self.verbose = verbose
51 self.dir_path = tempfile.mkdtemp(suffix='.pyrisk')
52 self.fifo_path = os.path.join(self.dir_path, 'incoming')
55 self.fifo_path = os.path.abspath(fifo_path)
56 os.mkfifo(self.fifo_path)
58 os.remove(self.fifo_path)
59 if self.dir_path != None:
60 os.rmdir(self.dir_path)
62 for msg_tag, msg in self._cache:
64 self._cache.remove(msg)
67 msg_tag = self._msg_tag(msg['Subject'])
69 self._cache.append((msg_tag, msg))
71 msg_tag = self._msg_tag(msg['Subject'])
72 if self.verbose == True:
73 print >> sys.stderr, msg
75 def _msg_tag(self, subject):
76 """ Return the tag portion of a message subject.
78 >>> ied = IncomingEmailDispatcher()
79 >>> ied._msg_tag('[TAG] Hi there')
81 >>> ied._msg_tag(' [tg] Hi there')
83 >>> ied._msg_tag(' Re: [t] Hi there')
89 subject = subject.strip()
90 if subject.startswith('Re:'):
91 subject = subject[len('Re:'):]
92 subject = subject.strip()
93 args = subject.split(u']',1)
98 # FIFO blocks on open until a writer also opens
99 self.fifo = open(self.fifo_path, 'r')
100 text = self.fifo.read()
103 return p.parsestr(text)
105 class OutgoingEmailDispatcher (object):
106 """For sending outgoing messages.
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
123 """Send an email Message instance on its merry way.
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()
130 if self.sendmail != None:
131 self._execute(self.sendmail, stdin=self._flatten(msg))
133 s = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port)
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))
140 def _execute(self, args, stdin=None, expect=(0,)):
142 Execute a command (allows us to drive gpg).
144 if self.verbose_execute == True:
145 print >> sys.stderr, '$ '+args
147 p = subprocess.Popen(args,
148 stdin=subprocess.PIPE,
149 stdout=subprocess.PIPE,
150 stderr=subprocess.PIPE,
151 shell=False, close_fds=True)
153 strerror = '%s\nwhile executing %s' % (e.args[1], args)
154 raise Exception, strerror
155 output, error = p.communicate(input=stdin)
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):
165 Search the header of an email Message instance to find the
166 sender's email address.
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):
175 Search the header of an email Message instance to find a
176 list of recipient's email addresses.
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
186 all_recipients = getaddresses(resent)
188 all_recipients = getaddresses(tos + ccs + bccs)
189 return [addr[1] for addr in all_recipients]
190 def _flatten(self, msg, to_unicode=False):
192 Produce flat text output from an email Message instance.
196 g = Generator(fp, mangle_from_=False)
199 if to_unicode == True:
200 encoding = msg.get_content_charset('utf-8')
201 text = unicode(text, encoding=encoding)
204 def encodedMIMEText(body, encoding='us-ascii', disposition='inline', filename=None):
205 if encoding == 'us-ascii':
206 part = MIMEText(body)
208 # Create the message ('plain' stands for Content-Type: text/plain)
209 part = MIMEText(body.encode(encoding), 'plain', encoding)
211 part.add_header('Content-Disposition', disposition)
213 part.add_header('Content-Disposition', disposition, filename=filename)
217 class EmailPlayer (Player):
218 """Human Player with an email interface.
220 TODO: details on procmail setup
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
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))
239 msg['Subject'] = '%s %s' % (tag, subject)
240 self.outgoing.send(msg)
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':
250 body = part.get_payload(decode=True)
251 charset = part.get_content_charset(msg_charset)
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)'
259 body = unicode(body, charset) # convert text types to unicode
260 if len(body) == 0 or body[-1] != u'\n':
263 def _world_part(self, world):
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))
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):
275 players = start_event.players
276 body = self.world_renderer.render(world, players)
278 self.world_renderer.filename_and_mime_image_type(world)
279 part = MIMEImage(body, subtype)
280 part.add_header('Content-Disposition', 'attachment', filename=filename)
282 def _log_part(self, log):
283 return encodedMIMEText('\n'.join([str(e) for e in 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__])