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):
49 self.dir_path = tempfile.mkdtemp(suffix='.pyrisk')
50 self.fifo_path = os.path.join(self.dir_path, 'incoming')
53 self.fifo_path = os.path.abspath(fifo_path)
54 os.mkfifo(self.fifo_path)
56 os.remove(self.fifo_path)
57 if self.dir_path != None:
58 os.rmdir(self.dir_path)
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:
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'])
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')
86 subject = subject.strip()
87 if subject.startswith('Re:'):
88 subject = subject[len('Re:'):]
89 subject = subject.strip()
90 args = subject.split(u']',1)
95 text = self.fifo.read()
97 return p.parsestr(text)
99 class OutgoingEmailDispatcher (object):
100 """For sending outgoing messages.
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
117 """Send an email Message instance on its merry way.
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()
124 if self.sendmail != None:
125 execute(self.sendmail, stdin=self._flatten(msg))
127 s = smtplib.SMTP_SSL(self.smtp_host, self.smtp_port)
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))
134 def _execute(self, args, stdin=None, expect=(0,)):
136 Execute a command (allows us to drive gpg).
138 if self.verbose_execute == True:
139 print >> sys.stderr, '$ '+args
141 p = subprocess.Popen(args,
142 stdin=subprocess.PIPE,
143 stdout=subprocess.PIPE,
144 stderr=subprocess.PIPE,
145 shell=False, close_fds=True)
147 strerror = '%s\nwhile executing %s' % (e.args[1], args)
148 raise Exception, strerror
149 output, error = p.communicate(input=stdin)
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):
159 Search the header of an email Message instance to find the
160 sender's email address.
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):
169 Search the header of an email Message instance to find a
170 list of recipient's email addresses.
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
180 all_recipients = getaddresses(resent)
182 all_recipients = getaddresses(tos + ccs + bccs)
183 return [addr[1] for addr in all_recipients]
184 def _flatten(self, msg, to_unicode=False):
186 Produce flat text output from an email Message instance.
190 g = Generator(fp, mangle_from_=False)
193 if to_unicode == True:
194 encoding = msg.get_content_charset('utf-8')
195 text = unicode(text, encoding=encoding)
198 def encodedMIMEText(body, encoding='us-ascii', disposition='inline', filename=None):
199 if encoding == 'us-ascii':
200 part = MIMEText(body)
202 # Create the message ('plain' stands for Content-Type: text/plain)
203 part = MIMEText(body.encode(encoding), 'plain', encoding)
205 part.add_header('Content-Disposition', disposition)
207 part.add_header('Content-Disposition', disposition, filename=filename)
211 class EmailPlayer (Player):
212 """Human Player with an email interface.
214 TODO: details on procmail setup
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
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))
230 msg['Subject'] = '%s %s' % (tag, subject)
231 self.outgoing.send(msg)
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)'
243 body = unicode(body, charset) # convert text types to unicode
244 if len(body) == 0 or body[-1] != u'\n':
247 def _world_part(self, world):
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))
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.
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.
270 draw - another notification-only method
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).
279 report - another notification-only method
281 Player.draw(self, world, log, cards)
282 body = ['New cards:']
283 body.extend([' %s' % c for c in cards])
284 body = ['Current Hand:']
286 if c.territory != None and c.territory.player == self:
287 body.append(' %s (owned)' % c)
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.
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',
299 for t in world.territories():
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()
306 def play_cards(self, world, log, play_required=True):
307 """Decide whether or not to turn in a set of cards.
309 Return a list of cards to turn in or None. If play_required
310 is True, you *must* play.
312 possibles = list(self.hand.possible())
313 if len(possibles) == 0:
315 subject = 'Play cards'
316 if play_required == True:
317 subject += ' (required)'
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()
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.
333 Return {territory_name: num_armies, ...}
335 subject = 'Place %d of %d armies' % (this_round, remaining)
337 'You can place %d armies this round (out of %d in'
338 % (this_round, remaining),
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',
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)
354 for line in body.splitlines():
358 armies,terr_name = [x.strip() for x in line.split(':')]
359 placements[terr_name] = int(armies)
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.
366 subject = 'Attack and fortify'
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',
386 assert mode == 'fortify', mode
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',
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)]
406 for line in body.splitlines():
407 action = self._parse_attack_or_fortify_line(line, mode)
412 actions.append(action)
414 def _parse_attack_or_fortify_line(self, line, mode):
416 if line.count(':') == 2:
417 fields = [x.strip() for x in line.split(':')]
418 fields[2] = int(fields[2])
420 elif line.lower() == 'pass' \
421 or (mode == 'fortify' and len(line) == 0):
423 def support_attack(self, world, log, source, target):
424 """Follow up on a conquest by moving additional armies.
426 subject = 'Support conquest of %s by %s' % (target, source)
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.'
433 'Reply with first line(s) of the body of your email set',
434 'to "<number_of_armies>", or leave the first line blank',
436 tag = self._send_mail(world, log, subject, '\n'.join(body))
437 body = self._get_mail(tag)
438 text = body.splitlines()[0].strip()
446 failures,tests = doctest.testmod(sys.modules[__name__])