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.text import MIMEText
25 from email.mime.multipart import MIMEMultipart
26 from email.utils import getaddresses, formataddr, formatdate, make_msgid
33 from ..base import Player, PlayerError
36 # Configure alternative sendmail command in case smtplib is too
37 # annoying. Set SENDMAIL to None to use smtplib.
39 #SENDMAIL = ['/usr/sbin/sendmail', '-t']
40 #SENDMAIL = ['/usr/bin/msmtp', '-t']
43 class IncomingEmailDispatcher (object):
44 """For reading reply messages.
46 def __init__(self, fifo_path=None, verbose=True):
47 self.verbose = verbose
50 self.dir_path = tempfile.mkdtemp(suffix='.pyrisk')
51 self.fifo_path = os.path.join(self.dir_path, 'incoming')
54 self.fifo_path = os.path.abspath(fifo_path)
55 os.mkfifo(self.fifo_path)
57 os.remove(self.fifo_path)
58 if self.dir_path != None:
59 os.rmdir(self.dir_path)
61 for msg_tag, msg in self._cache:
63 self._cache.remove(msg)
66 msg_tag = self._msg_tag(msg['Subject'])
68 self._cache.append((msg_tag, msg))
70 msg_tag = self._msg_tag(msg['Subject'])
71 if self.verbose == True:
72 print >> sys.stderr, msg
74 def _msg_tag(self, subject):
75 """ Return the tag portion of a message subject.
77 >>> ied = IncomingEmailDispatcher()
78 >>> ied._msg_tag('[TAG] Hi there')
80 >>> ied._msg_tag(' [tg] Hi there')
82 >>> ied._msg_tag(' Re: [t] Hi there')
88 subject = subject.strip()
89 if subject.startswith('Re:'):
90 subject = subject[len('Re:'):]
91 subject = subject.strip()
92 args = subject.split(u']',1)
97 # FIFO blocks on open until a writer also opens
98 self.fifo = open(self.fifo_path, 'r')
99 text = self.fifo.read()
102 return p.parsestr(text)
104 class OutgoingEmailDispatcher (object):
105 """For sending outgoing messages.
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
122 """Send an email Message instance on its merry way.
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()
129 if self.sendmail != None:
130 self._execute(self.sendmail, stdin=self._flatten(msg))
132 s = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port)
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))
139 def _execute(self, args, stdin=None, expect=(0,)):
141 Execute a command (allows us to drive gpg).
143 if self.verbose_execute == True:
144 print >> sys.stderr, '$ '+args
146 p = subprocess.Popen(args,
147 stdin=subprocess.PIPE,
148 stdout=subprocess.PIPE,
149 stderr=subprocess.PIPE,
150 shell=False, close_fds=True)
152 strerror = '%s\nwhile executing %s' % (e.args[1], args)
153 raise Exception, strerror
154 output, error = p.communicate(input=stdin)
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):
164 Search the header of an email Message instance to find the
165 sender's email address.
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):
174 Search the header of an email Message instance to find a
175 list of recipient's email addresses.
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
185 all_recipients = getaddresses(resent)
187 all_recipients = getaddresses(tos + ccs + bccs)
188 return [addr[1] for addr in all_recipients]
189 def _flatten(self, msg, to_unicode=False):
191 Produce flat text output from an email Message instance.
195 g = Generator(fp, mangle_from_=False)
198 if to_unicode == True:
199 encoding = msg.get_content_charset('utf-8')
200 text = unicode(text, encoding=encoding)
203 def encodedMIMEText(body, encoding='us-ascii', disposition='inline', filename=None):
204 if encoding == 'us-ascii':
205 part = MIMEText(body)
207 # Create the message ('plain' stands for Content-Type: text/plain)
208 part = MIMEText(body.encode(encoding), 'plain', encoding)
210 part.add_header('Content-Disposition', disposition)
212 part.add_header('Content-Disposition', disposition, filename=filename)
216 class EmailPlayer (Player):
217 """Human Player with an email interface.
219 TODO: details on procmail setup
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
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))
235 msg['Subject'] = '%s %s' % (tag, subject)
236 self.outgoing.send(msg)
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':
246 body = part.get_payload(decode=True)
247 charset = part.get_content_charset(msg_charset)
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)'
255 body = unicode(body, charset) # convert text types to unicode
256 if len(body) == 0 or body[-1] != u'\n':
259 def _world_part(self, world):
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))
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.
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.
282 draw - another notification-only method
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).
291 report - another notification-only method
293 Player.draw(self, world, log, cards)
294 body = ['New cards:']
295 body.extend([' %s' % c for c in cards])
296 body = ['Current Hand:']
298 if c.territory != None and c.territory.player == self:
299 body.append(' %s (owned)' % c)
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.
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',
311 for t in world.territories():
313 body.append(' %s' % t)
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()
321 def play_cards(self, world, log, error=None,
323 """Decide whether or not to turn in a set of cards.
325 Return a list of cards to turn in or None. If play_required
326 is True, you *must* play.
328 possibles = list(self.hand.possible())
329 if len(possibles) == 0:
331 subject = 'Play cards'
332 if play_required == True:
333 subject += ' (required)'
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))
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()
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.
353 Return {territory_name: num_armies, ...}
355 subject = 'Place %d of %d armies' % (this_round, remaining)
357 'You can place %d armies this round (out of %d in'
358 % (this_round, remaining),
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',
368 'Your current disposition is:']
369 for t in self.territories(world):
370 body.append(' %d : %s' % (t.armies, t))
373 body.insert(0, str(error))
374 tag = self._send_mail(world, log, subject, '\n'.join(body))
375 body = self._get_mail(tag)
377 for line in body.splitlines():
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)
386 def attack_and_fortify(self, world, log, error=None,
388 """Return list of (source, target, armies) tuples. Place None
389 in the list to end this phase.
392 subject = 'Attack and fortify'
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',
412 assert mode == 'fortify', mode
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',
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)]
435 for line in body.splitlines():
436 action = self._parse_attack_or_fortify_line(line, mode)
441 actions.append(action)
443 def _parse_attack_or_fortify_line(self, line, mode):
445 if line.count(':') == 2:
446 fields = [x.strip() for x in line.split(':')]
447 fields[2] = int(fields[2])
449 elif line.lower() == 'pass' \
450 or (mode == 'fortify' and len(line) == 0):
452 def support_attack(self, world, log, error,
454 """Follow up on a conquest by moving additional armies.
456 subject = 'Support conquest of %s by %s' % (target, source)
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.'
463 'Reply with first line(s) of the body of your email set',
464 'to "<number_of_armies>", or leave the first line blank',
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()
479 failures,tests = doctest.testmod(sys.modules[__name__])