Bugfix: bad job recognizing PM, so if you got a message in the morning and then at...
[theonering] / src / gvoice / backend.py
1 #!/usr/bin/python
2
3 """
4 DialCentral - Front end for Google's GoogleVoice service.
5 Copyright (C) 2008  Eric Warnke ericew AT gmail DOT com
6
7 This library is free software; you can redistribute it and/or
8 modify it under the terms of the GNU Lesser General Public
9 License as published by the Free Software Foundation; either
10 version 2.1 of the License, or (at your option) any later version.
11
12 This library is distributed in the hope that it will be useful,
13 but WITHOUT ANY WARRANTY; without even the implied warranty of
14 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
15 Lesser General Public License for more details.
16
17 You should have received a copy of the GNU Lesser General Public
18 License along with this library; if not, write to the Free Software
19 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
20
21 Google Voice backend code
22
23 Resources
24         http://thatsmith.com/2009/03/google-voice-addon-for-firefox/
25         http://posttopic.com/topic/google-voice-add-on-development
26 """
27
28 from __future__ import with_statement
29
30 import os
31 import re
32 import urllib
33 import urllib2
34 import time
35 import datetime
36 import itertools
37 import logging
38 import inspect
39
40 from xml.etree import ElementTree
41
42 try:
43         import simplejson as _simplejson
44         simplejson = _simplejson
45 except ImportError:
46         simplejson = None
47
48 import browser_emu
49
50
51 _moduleLogger = logging.getLogger(__name__)
52
53
54 class NetworkError(RuntimeError):
55         pass
56
57
58 class MessageText(object):
59
60         ACCURACY_LOW = "med1"
61         ACCURACY_MEDIUM = "med2"
62         ACCURACY_HIGH = "high"
63
64         def __init__(self):
65                 self.accuracy = None
66                 self.text = None
67
68         def __str__(self):
69                 return self.text
70
71         def to_dict(self):
72                 return to_dict(self)
73
74         def __eq__(self, other):
75                 return self.accuracy == other.accuracy and self.text == other.text
76
77
78 class Message(object):
79
80         def __init__(self):
81                 self.whoFrom = None
82                 self.body = None
83                 self.when = None
84
85         def __str__(self):
86                 return "%s (%s): %s" % (
87                         self.whoFrom,
88                         self.when,
89                         "".join(str(part) for part in self.body)
90                 )
91
92         def to_dict(self):
93                 selfDict = to_dict(self)
94                 selfDict["body"] = [text.to_dict() for text in self.body] if self.body is not None else None
95                 return selfDict
96
97         def __eq__(self, other):
98                 return self.whoFrom == other.whoFrom and self.when == other.when and self.body == other.body
99
100
101 class Conversation(object):
102
103         TYPE_VOICEMAIL = "Voicemail"
104         TYPE_SMS = "SMS"
105
106         def __init__(self):
107                 self.type = None
108                 self.id = None
109                 self.contactId = None
110                 self.name = None
111                 self.location = None
112                 self.prettyNumber = None
113                 self.number = None
114
115                 self.time = None
116                 self.relTime = None
117                 self.messages = None
118                 self.isRead = None
119                 self.isSpam = None
120                 self.isTrash = None
121                 self.isArchived = None
122
123         def __cmp__(self, other):
124                 cmpValue = cmp(self.contactId, other.contactId)
125                 if cmpValue != 0:
126                         return cmpValue
127
128                 cmpValue = cmp(self.time, other.time)
129                 if cmpValue != 0:
130                         return cmpValue
131
132                 cmpValue = cmp(self.id, other.id)
133                 if cmpValue != 0:
134                         return cmpValue
135
136         def to_dict(self):
137                 selfDict = to_dict(self)
138                 selfDict["messages"] = [message.to_dict() for message in self.messages] if self.messages is not None else None
139                 return selfDict
140
141
142 class GVoiceBackend(object):
143         """
144         This class encapsulates all of the knowledge necessary to interact with the GoogleVoice servers
145         the functions include login, setting up a callback number, and initalting a callback
146         """
147
148         PHONE_TYPE_HOME = 1
149         PHONE_TYPE_MOBILE = 2
150         PHONE_TYPE_WORK = 3
151         PHONE_TYPE_GIZMO = 7
152
153         def __init__(self, cookieFile = None):
154                 # Important items in this function are the setup of the browser emulation and cookie file
155                 self._browser = browser_emu.MozillaEmulator(1)
156                 self._loadedFromCookies = self._browser.load_cookies(cookieFile)
157
158                 self._token = ""
159                 self._accountNum = ""
160                 self._lastAuthed = 0.0
161                 self._callbackNumber = ""
162                 self._callbackNumbers = {}
163
164                 # Suprisingly, moving all of these from class to self sped up startup time
165
166                 self._validateRe = re.compile("^\+?[0-9]{10,}$")
167
168                 self._loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
169
170                 SECURE_URL_BASE = "https://www.google.com/voice/"
171                 SECURE_MOBILE_URL_BASE = SECURE_URL_BASE + "mobile/"
172                 self._forwardURL = SECURE_MOBILE_URL_BASE + "phones"
173                 self._tokenURL = SECURE_URL_BASE + "m"
174                 self._callUrl = SECURE_URL_BASE + "call/connect"
175                 self._callCancelURL = SECURE_URL_BASE + "call/cancel"
176                 self._sendSmsURL = SECURE_URL_BASE + "sms/send"
177
178                 self._isDndURL = "https://www.google.com/voice/m/donotdisturb"
179                 self._isDndRe = re.compile(r"""<input.*?id="doNotDisturb".*?checked="(.*?)"\s*/>""")
180                 self._setDndURL = "https://www.google.com/voice/m/savednd"
181
182                 self._downloadVoicemailURL = SECURE_URL_BASE + "media/send_voicemail/"
183                 self._markAsReadURL = SECURE_URL_BASE + "m/mark"
184                 self._archiveMessageURL = SECURE_URL_BASE + "m/archive"
185
186                 self._XML_SEARCH_URL = SECURE_URL_BASE + "inbox/search/"
187                 self._XML_ACCOUNT_URL = SECURE_URL_BASE + "contacts/"
188                 # HACK really this redirects to the main pge and we are grabbing some javascript
189                 self._XML_CONTACTS_URL = "http://www.google.com/voice/inbox/search/contact"
190                 self._XML_RECENT_URL = SECURE_URL_BASE + "inbox/recent/"
191
192                 self.XML_FEEDS = (
193                         'inbox', 'starred', 'all', 'spam', 'trash', 'voicemail', 'sms',
194                         'recorded', 'placed', 'received', 'missed'
195                 )
196                 self._XML_INBOX_URL = SECURE_URL_BASE + "inbox/recent/inbox"
197                 self._XML_STARRED_URL = SECURE_URL_BASE + "inbox/recent/starred"
198                 self._XML_ALL_URL = SECURE_URL_BASE + "inbox/recent/all"
199                 self._XML_SPAM_URL = SECURE_URL_BASE + "inbox/recent/spam"
200                 self._XML_TRASH_URL = SECURE_URL_BASE + "inbox/recent/trash"
201                 self._XML_VOICEMAIL_URL = SECURE_URL_BASE + "inbox/recent/voicemail/"
202                 self._XML_SMS_URL = SECURE_URL_BASE + "inbox/recent/sms/"
203                 self._XML_RECORDED_URL = SECURE_URL_BASE + "inbox/recent/recorded/"
204                 self._XML_PLACED_URL = SECURE_URL_BASE + "inbox/recent/placed/"
205                 self._XML_RECEIVED_URL = SECURE_URL_BASE + "inbox/recent/received/"
206                 self._XML_MISSED_URL = SECURE_URL_BASE + "inbox/recent/missed/"
207
208                 self._galxRe = re.compile(r"""<input.*?name="GALX".*?value="(.*?)".*?/>""", re.MULTILINE | re.DOTALL)
209                 self._tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
210                 self._accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
211                 self._callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
212
213                 self._contactsBodyRe = re.compile(r"""gcData\s*=\s*({.*?});""", re.MULTILINE | re.DOTALL)
214                 self._seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
215                 self._exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
216                 self._relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
217                 self._voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
218                 self._voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
219                 self._prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
220                 self._voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
221                 self._messagesContactIDRegex = re.compile(r"""<a class=".*?gc-message-name-link.*?">.*?</a>\s*?<span .*?>(.*?)</span>""", re.MULTILINE)
222                 self._voicemailMessageRegex = re.compile(r"""(<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>|<a .*? class="gc-message-mni">(.*?)</a>)""", re.MULTILINE)
223                 self._smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
224                 self._smsTimeRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
225                 self._smsTextRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
226
227         def is_quick_login_possible(self):
228                 """
229                 @returns True then is_authed might be enough to login, else full login is required
230                 """
231                 return self._loadedFromCookies or 0.0 < self._lastAuthed
232
233         def is_authed(self, force = False):
234                 """
235                 Attempts to detect a current session
236                 @note Once logged in try not to reauth more than once a minute.
237                 @returns If authenticated
238                 @blocks
239                 """
240                 isRecentledAuthed = (time.time() - self._lastAuthed) < 120
241                 isPreviouslyAuthed = self._token is not None
242                 if isRecentledAuthed and isPreviouslyAuthed and not force:
243                         return True
244
245                 try:
246                         page = self._get_page(self._forwardURL)
247                         self._grab_account_info(page)
248                 except Exception, e:
249                         _moduleLogger.exception(str(e))
250                         return False
251
252                 self._browser.save_cookies()
253                 self._lastAuthed = time.time()
254                 return True
255
256         def _get_token(self):
257                 tokenPage = self._get_page(self._tokenURL)
258
259                 galxTokens = self._galxRe.search(tokenPage)
260                 if galxTokens is not None:
261                         galxToken = galxTokens.group(1)
262                 else:
263                         galxToken = ""
264                         _moduleLogger.debug("Could not grab GALX token")
265                 return galxToken
266
267         def _login(self, username, password, token):
268                 loginData = {
269                         'Email' : username,
270                         'Passwd' : password,
271                         'service': "grandcentral",
272                         "ltmpl": "mobile",
273                         "btmpl": "mobile",
274                         "PersistentCookie": "yes",
275                         "GALX": token,
276                         "continue": self._forwardURL,
277                 }
278
279                 loginSuccessOrFailurePage = self._get_page(self._loginURL, loginData)
280                 return loginSuccessOrFailurePage
281
282         def login(self, username, password):
283                 """
284                 Attempt to login to GoogleVoice
285                 @returns Whether login was successful or not
286                 @blocks
287                 """
288                 self.logout()
289                 galxToken = self._get_token()
290                 loginSuccessOrFailurePage = self._login(username, password, galxToken)
291
292                 try:
293                         self._grab_account_info(loginSuccessOrFailurePage)
294                 except Exception, e:
295                         # Retry in case the redirect failed
296                         # luckily is_authed does everything we need for a retry
297                         loggedIn = self.is_authed(True)
298                         if not loggedIn:
299                                 _moduleLogger.exception(str(e))
300                                 return False
301                         _moduleLogger.info("Redirection failed on initial login attempt, auto-corrected for this")
302
303                 self._browser.save_cookies()
304                 self._lastAuthed = time.time()
305                 return True
306
307         def shutdown(self):
308                 self._browser.save_cookies()
309                 self._token = None
310                 self._lastAuthed = 0.0
311
312         def logout(self):
313                 self._browser.clear_cookies()
314                 self._browser.save_cookies()
315                 self._token = None
316                 self._lastAuthed = 0.0
317
318         def is_dnd(self):
319                 """
320                 @blocks
321                 """
322                 isDndPage = self._get_page(self._isDndURL)
323
324                 dndGroup = self._isDndRe.search(isDndPage)
325                 if dndGroup is None:
326                         return False
327                 dndStatus = dndGroup.group(1)
328                 isDnd = True if dndStatus.strip().lower() == "true" else False
329                 return isDnd
330
331         def set_dnd(self, doNotDisturb):
332                 """
333                 @blocks
334                 """
335                 dndPostData = {
336                         "doNotDisturb": 1 if doNotDisturb else 0,
337                 }
338
339                 dndPage = self._get_page_with_token(self._setDndURL, dndPostData)
340
341         def call(self, outgoingNumber):
342                 """
343                 This is the main function responsible for initating the callback
344                 @blocks
345                 """
346                 outgoingNumber = self._send_validation(outgoingNumber)
347                 subscriberNumber = None
348                 phoneType = guess_phone_type(self._callbackNumber) # @todo Fix this hack
349
350                 callData = {
351                                 'outgoingNumber': outgoingNumber,
352                                 'forwardingNumber': self._callbackNumber,
353                                 'subscriberNumber': subscriberNumber or 'undefined',
354                                 'phoneType': str(phoneType),
355                                 'remember': '1',
356                 }
357                 _moduleLogger.info("%r" % callData)
358
359                 page = self._get_page_with_token(
360                         self._callUrl,
361                         callData,
362                 )
363                 self._parse_with_validation(page)
364                 return True
365
366         def cancel(self, outgoingNumber=None):
367                 """
368                 Cancels a call matching outgoing and forwarding numbers (if given). 
369                 Will raise an error if no matching call is being placed
370                 @blocks
371                 """
372                 page = self._get_page_with_token(
373                         self._callCancelURL,
374                         {
375                         'outgoingNumber': outgoingNumber or 'undefined',
376                         'forwardingNumber': self._callbackNumber or 'undefined',
377                         'cancelType': 'C2C',
378                         },
379                 )
380                 self._parse_with_validation(page)
381
382         def send_sms(self, phoneNumbers, message):
383                 """
384                 @blocks
385                 """
386                 validatedPhoneNumbers = [
387                         self._send_validation(phoneNumber)
388                         for phoneNumber in phoneNumbers
389                 ]
390                 flattenedPhoneNumbers = ",".join(validatedPhoneNumbers)
391                 page = self._get_page_with_token(
392                         self._sendSmsURL,
393                         {
394                                 'phoneNumber': flattenedPhoneNumbers,
395                                 'text': message
396                         },
397                 )
398                 self._parse_with_validation(page)
399
400         def search(self, query):
401                 """
402                 Search your Google Voice Account history for calls, voicemails, and sms
403                 Returns ``Folder`` instance containting matching messages
404                 @blocks
405                 """
406                 page = self._get_page(
407                         self._XML_SEARCH_URL,
408                         {"q": query},
409                 )
410                 json, html = extract_payload(page)
411                 return json
412
413         def get_feed(self, feed):
414                 """
415                 @blocks
416                 """
417                 actualFeed = "_XML_%s_URL" % feed.upper()
418                 feedUrl = getattr(self, actualFeed)
419
420                 page = self._get_page(feedUrl)
421                 json, html = extract_payload(page)
422
423                 return json
424
425         def download(self, messageId, adir):
426                 """
427                 Download a voicemail or recorded call MP3 matching the given ``msg``
428                 which can either be a ``Message`` instance, or a SHA1 identifier. 
429                 Saves files to ``adir`` (defaults to current directory). 
430                 Message hashes can be found in ``self.voicemail().messages`` for example. 
431                 @returns location of saved file.
432                 @blocks
433                 """
434                 page = self._get_page(self._downloadVoicemailURL, {"id": messageId})
435                 fn = os.path.join(adir, '%s.mp3' % messageId)
436                 with open(fn, 'wb') as fo:
437                         fo.write(page)
438                 return fn
439
440         def is_valid_syntax(self, number):
441                 """
442                 @returns If This number be called ( syntax validation only )
443                 """
444                 return self._validateRe.match(number) is not None
445
446         def get_account_number(self):
447                 """
448                 @returns The GoogleVoice phone number
449                 """
450                 return self._accountNum
451
452         def get_callback_numbers(self):
453                 """
454                 @returns a dictionary mapping call back numbers to descriptions
455                 @note These results are cached for 30 minutes.
456                 """
457                 if not self.is_authed():
458                         return {}
459                 return self._callbackNumbers
460
461         def set_callback_number(self, callbacknumber):
462                 """
463                 Set the number that GoogleVoice calls
464                 @param callbacknumber should be a proper 10 digit number
465                 """
466                 self._callbackNumber = callbacknumber
467                 _moduleLogger.info("Callback number changed: %r" % self._callbackNumber)
468                 return True
469
470         def get_callback_number(self):
471                 """
472                 @returns Current callback number or None
473                 """
474                 return self._callbackNumber
475
476         def get_recent(self):
477                 """
478                 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
479                 @blocks
480                 """
481                 recentPages = [
482                         (action, self._get_page(url))
483                         for action, url in (
484                                 ("Received", self._XML_RECEIVED_URL),
485                                 ("Missed", self._XML_MISSED_URL),
486                                 ("Placed", self._XML_PLACED_URL),
487                         )
488                 ]
489                 return self._parse_recent(recentPages)
490
491         def get_contacts(self):
492                 """
493                 @returns Iterable of (contact id, contact name)
494                 @blocks
495                 """
496                 page = self._get_page(self._XML_CONTACTS_URL)
497                 return self._process_contacts(page)
498
499         def get_voicemails(self):
500                 """
501                 @blocks
502                 """
503                 voicemailPage = self._get_page(self._XML_VOICEMAIL_URL)
504                 voicemailHtml = self._grab_html(voicemailPage)
505                 voicemailJson = self._grab_json(voicemailPage)
506                 parsedVoicemail = self._parse_voicemail(voicemailHtml)
507                 voicemails = self._merge_conversation_sources(parsedVoicemail, voicemailJson)
508                 return voicemails
509
510         def get_texts(self):
511                 """
512                 @blocks
513                 """
514                 smsPage = self._get_page(self._XML_SMS_URL)
515                 smsHtml = self._grab_html(smsPage)
516                 smsJson = self._grab_json(smsPage)
517                 parsedSms = self._parse_sms(smsHtml)
518                 smss = self._merge_conversation_sources(parsedSms, smsJson)
519                 return smss
520
521         def mark_message(self, messageId, asRead):
522                 """
523                 @blocks
524                 """
525                 postData = {
526                         "read": 1 if asRead else 0,
527                         "id": messageId,
528                 }
529
530                 markPage = self._get_page(self._markAsReadURL, postData)
531
532         def archive_message(self, messageId):
533                 """
534                 @blocks
535                 """
536                 postData = {
537                         "id": messageId,
538                 }
539
540                 markPage = self._get_page(self._archiveMessageURL, postData)
541
542         def _grab_json(self, flatXml):
543                 xmlTree = ElementTree.fromstring(flatXml)
544                 jsonElement = xmlTree.getchildren()[0]
545                 flatJson = jsonElement.text
546                 jsonTree = parse_json(flatJson)
547                 return jsonTree
548
549         def _grab_html(self, flatXml):
550                 xmlTree = ElementTree.fromstring(flatXml)
551                 htmlElement = xmlTree.getchildren()[1]
552                 flatHtml = htmlElement.text
553                 return flatHtml
554
555         def _grab_account_info(self, page):
556                 tokenGroup = self._tokenRe.search(page)
557                 if tokenGroup is None:
558                         raise RuntimeError("Could not extract authentication token from GoogleVoice")
559                 self._token = tokenGroup.group(1)
560
561                 anGroup = self._accountNumRe.search(page)
562                 if anGroup is not None:
563                         self._accountNum = anGroup.group(1)
564                 else:
565                         _moduleLogger.debug("Could not extract account number from GoogleVoice")
566
567                 self._callbackNumbers = {}
568                 for match in self._callbackRe.finditer(page):
569                         callbackNumber = match.group(2)
570                         callbackName = match.group(1)
571                         self._callbackNumbers[callbackNumber] = callbackName
572                 if len(self._callbackNumbers) == 0:
573                         _moduleLogger.debug("Could not extract callback numbers from GoogleVoice (the troublesome page follows):\n%s" % page)
574
575         def _send_validation(self, number):
576                 if not self.is_valid_syntax(number):
577                         raise ValueError('Number is not valid: "%s"' % number)
578                 elif not self.is_authed():
579                         raise RuntimeError("Not Authenticated")
580                 return number
581
582         def _parse_recent(self, recentPages):
583                 for action, flatXml in recentPages:
584                         allRecentHtml = self._grab_html(flatXml)
585                         allRecentData = self._parse_history(allRecentHtml)
586                         for recentCallData in allRecentData:
587                                 recentCallData["action"] = action
588                                 yield recentCallData
589
590         def _process_contacts(self, page):
591                 contactsBody = self._contactsBodyRe.search(page)
592                 if contactsBody is None:
593                         raise RuntimeError("Could not extract contact information")
594                 accountData = _fake_parse_json(contactsBody.group(1))
595                 for contactId, contactDetails in accountData["contacts"].iteritems():
596                         # A zero contact id is the catch all for unknown contacts
597                         if contactId != "0":
598                                 yield contactId, contactDetails
599
600         def _parse_history(self, historyHtml):
601                 splitVoicemail = self._seperateVoicemailsRegex.split(historyHtml)
602                 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
603                         exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
604                         exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
605                         exactTime = google_strptime(exactTime)
606                         relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
607                         relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
608                         locationGroup = self._voicemailLocationRegex.search(messageHtml)
609                         location = locationGroup.group(1).strip() if locationGroup else ""
610
611                         nameGroup = self._voicemailNameRegex.search(messageHtml)
612                         name = nameGroup.group(1).strip() if nameGroup else ""
613                         numberGroup = self._voicemailNumberRegex.search(messageHtml)
614                         number = numberGroup.group(1).strip() if numberGroup else ""
615                         prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
616                         prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
617                         contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
618                         contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
619
620                         yield {
621                                 "id": messageId.strip(),
622                                 "contactId": contactId,
623                                 "name": name,
624                                 "time": exactTime,
625                                 "relTime": relativeTime,
626                                 "prettyNumber": prettyNumber,
627                                 "number": number,
628                                 "location": location,
629                         }
630
631         @staticmethod
632         def _interpret_voicemail_regex(group):
633                 quality, content, number = group.group(2), group.group(3), group.group(4)
634                 text = MessageText()
635                 if quality is not None and content is not None:
636                         text.accuracy = quality
637                         text.text = content
638                         return text
639                 elif number is not None:
640                         text.accuracy = MessageText.ACCURACY_HIGH
641                         text.text = number
642                         return text
643
644         def _parse_voicemail(self, voicemailHtml):
645                 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
646                 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
647                         conv = Conversation()
648                         conv.type = Conversation.TYPE_VOICEMAIL
649                         conv.id = messageId.strip()
650
651                         exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
652                         exactTimeText = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
653                         conv.time = google_strptime(exactTimeText)
654                         relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
655                         conv.relTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
656                         locationGroup = self._voicemailLocationRegex.search(messageHtml)
657                         conv.location = locationGroup.group(1).strip() if locationGroup else ""
658
659                         nameGroup = self._voicemailNameRegex.search(messageHtml)
660                         conv.name = nameGroup.group(1).strip() if nameGroup else ""
661                         numberGroup = self._voicemailNumberRegex.search(messageHtml)
662                         conv.number = numberGroup.group(1).strip() if numberGroup else ""
663                         prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
664                         conv.prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
665                         contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
666                         conv.contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
667
668                         messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
669                         messageParts = [
670                                 self._interpret_voicemail_regex(group)
671                                 for group in messageGroups
672                         ] if messageGroups else ((MessageText.ACCURACY_LOW, "No Transcription"), )
673                         message = Message()
674                         message.body = messageParts
675                         message.whoFrom = conv.name
676                         message.when = conv.time.strftime("%I:%M %p")
677                         conv.messages = (message, )
678
679                         yield conv
680
681         @staticmethod
682         def _interpret_sms_message_parts(fromPart, textPart, timePart):
683                 text = MessageText()
684                 text.accuracy = MessageText.ACCURACY_MEDIUM
685                 text.text = textPart
686
687                 message = Message()
688                 message.body = (text, )
689                 message.whoFrom = fromPart
690                 message.when = timePart
691
692                 return message
693
694         def _parse_sms(self, smsHtml):
695                 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
696                 for messageId, messageHtml in itergroup(splitSms[1:], 2):
697                         conv = Conversation()
698                         conv.type = Conversation.TYPE_SMS
699                         conv.id = messageId.strip()
700
701                         exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
702                         exactTimeText = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
703                         conv.time = google_strptime(exactTimeText)
704                         relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
705                         conv.relTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
706                         conv.location = ""
707
708                         nameGroup = self._voicemailNameRegex.search(messageHtml)
709                         conv.name = nameGroup.group(1).strip() if nameGroup else ""
710                         numberGroup = self._voicemailNumberRegex.search(messageHtml)
711                         conv.number = numberGroup.group(1).strip() if numberGroup else ""
712                         prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
713                         conv.prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
714                         contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
715                         conv.contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
716
717                         fromGroups = self._smsFromRegex.finditer(messageHtml)
718                         fromParts = (group.group(1).strip() for group in fromGroups)
719                         textGroups = self._smsTextRegex.finditer(messageHtml)
720                         textParts = (group.group(1).strip() for group in textGroups)
721                         timeGroups = self._smsTimeRegex.finditer(messageHtml)
722                         timeParts = (group.group(1).strip() for group in timeGroups)
723
724                         messageParts = itertools.izip(fromParts, textParts, timeParts)
725                         messages = [self._interpret_sms_message_parts(*parts) for parts in messageParts]
726                         conv.messages = messages
727
728                         yield conv
729
730         @staticmethod
731         def _merge_conversation_sources(parsedMessages, json):
732                 for message in parsedMessages:
733                         jsonItem = json["messages"][message.id]
734                         message.isRead = jsonItem["isRead"]
735                         message.isSpam = jsonItem["isSpam"]
736                         message.isTrash = jsonItem["isTrash"]
737                         message.isArchived = "inbox" not in jsonItem["labels"]
738                         yield message
739
740         def _get_page(self, url, data = None, refererUrl = None):
741                 headers = {}
742                 if refererUrl is not None:
743                         headers["Referer"] = refererUrl
744
745                 encodedData = urllib.urlencode(data) if data is not None else None
746
747                 try:
748                         page = self._browser.download(url, encodedData, None, headers)
749                 except urllib2.URLError, e:
750                         _moduleLogger.error("Translating error: %s" % str(e))
751                         raise NetworkError("%s is not accesible" % url)
752
753                 return page
754
755         def _get_page_with_token(self, url, data = None, refererUrl = None):
756                 if data is None:
757                         data = {}
758                 data['_rnr_se'] = self._token
759
760                 page = self._get_page(url, data, refererUrl)
761
762                 return page
763
764         def _parse_with_validation(self, page):
765                 json = parse_json(page)
766                 validate_response(json)
767                 return json
768
769
770 def google_strptime(time):
771         """
772         Hack: Google always returns the time in the same locale.  Sadly if the
773         local system's locale is different, there isn't a way to perfectly handle
774         the time.  So instead we handle implement some time formatting
775         """
776         abbrevTime = time[:-3]
777         parsedTime = datetime.datetime.strptime(abbrevTime, "%m/%d/%y %I:%M")
778         if time.endswith("PM"):
779                 parsedTime += datetime.timedelta(hours=12)
780         return parsedTime
781
782
783 def itergroup(iterator, count, padValue = None):
784         """
785         Iterate in groups of 'count' values. If there
786         aren't enough values, the last result is padded with
787         None.
788
789         >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
790         ...     print tuple(val)
791         (1, 2, 3)
792         (4, 5, 6)
793         >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
794         ...     print list(val)
795         [1, 2, 3]
796         [4, 5, 6]
797         >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3):
798         ...     print tuple(val)
799         (1, 2, 3)
800         (4, 5, 6)
801         (7, None, None)
802         >>> for val in itergroup("123456", 3):
803         ...     print tuple(val)
804         ('1', '2', '3')
805         ('4', '5', '6')
806         >>> for val in itergroup("123456", 3):
807         ...     print repr("".join(val))
808         '123'
809         '456'
810         """
811         paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1))
812         nIterators = (paddedIterator, ) * count
813         return itertools.izip(*nIterators)
814
815
816 def safe_eval(s):
817         _TRUE_REGEX = re.compile("true")
818         _FALSE_REGEX = re.compile("false")
819         s = _TRUE_REGEX.sub("True", s)
820         s = _FALSE_REGEX.sub("False", s)
821         return eval(s, {}, {})
822
823
824 def _fake_parse_json(flattened):
825         return safe_eval(flattened)
826
827
828 def _actual_parse_json(flattened):
829         return simplejson.loads(flattened)
830
831
832 if simplejson is None:
833         parse_json = _fake_parse_json
834 else:
835         parse_json = _actual_parse_json
836
837
838 def extract_payload(flatXml):
839         xmlTree = ElementTree.fromstring(flatXml)
840
841         jsonElement = xmlTree.getchildren()[0]
842         flatJson = jsonElement.text
843         jsonTree = parse_json(flatJson)
844
845         htmlElement = xmlTree.getchildren()[1]
846         flatHtml = htmlElement.text
847
848         return jsonTree, flatHtml
849
850
851 def validate_response(response):
852         """
853         Validates that the JSON response is A-OK
854         """
855         try:
856                 assert 'ok' in response and response['ok']
857         except AssertionError:
858                 raise RuntimeError('There was a problem with GV: %s' % response)
859
860
861 def guess_phone_type(number):
862         if number.startswith("747") or number.startswith("1747") or number.startswith("+1747"):
863                 return GVoiceBackend.PHONE_TYPE_GIZMO
864         else:
865                 return GVoiceBackend.PHONE_TYPE_MOBILE
866
867
868 def get_sane_callback(backend):
869         """
870         Try to set a sane default callback number on these preferences
871         1) 1747 numbers ( Gizmo )
872         2) anything with gizmo in the name
873         3) anything with computer in the name
874         4) the first value
875         """
876         numbers = backend.get_callback_numbers()
877
878         priorityOrderedCriteria = [
879                 ("\+1747", None),
880                 ("1747", None),
881                 ("747", None),
882                 (None, "gizmo"),
883                 (None, "computer"),
884                 (None, "sip"),
885                 (None, None),
886         ]
887
888         for numberCriteria, descriptionCriteria in priorityOrderedCriteria:
889                 numberMatcher = None
890                 descriptionMatcher = None
891                 if numberCriteria is not None:
892                         numberMatcher = re.compile(numberCriteria)
893                 elif descriptionCriteria is not None:
894                         descriptionMatcher = re.compile(descriptionCriteria, re.I)
895
896                 for number, description in numbers.iteritems():
897                         if numberMatcher is not None and numberMatcher.match(number) is None:
898                                 continue
899                         if descriptionMatcher is not None and descriptionMatcher.match(description) is None:
900                                 continue
901                         return number
902
903
904 def set_sane_callback(backend):
905         """
906         Try to set a sane default callback number on these preferences
907         1) 1747 numbers ( Gizmo )
908         2) anything with gizmo in the name
909         3) anything with computer in the name
910         4) the first value
911         """
912         number = get_sane_callback(backend)
913         backend.set_callback_number(number)
914
915
916 def _is_not_special(name):
917         return not name.startswith("_") and name[0].lower() == name[0] and "_" not in name
918
919
920 def to_dict(obj):
921         members = inspect.getmembers(obj)
922         return dict((name, value) for (name, value) in members if _is_not_special(name))
923
924
925 def grab_debug_info(username, password):
926         cookieFile = os.path.join(".", "raw_cookies.txt")
927         try:
928                 os.remove(cookieFile)
929         except OSError:
930                 pass
931
932         backend = GVoiceBackend(cookieFile)
933         browser = backend._browser
934
935         _TEST_WEBPAGES = [
936                 ("forward", backend._forwardURL),
937                 ("token", backend._tokenURL),
938                 ("login", backend._loginURL),
939                 ("isdnd", backend._isDndURL),
940                 ("account", backend._XML_ACCOUNT_URL),
941                 ("contacts", backend._XML_CONTACTS_URL),
942
943                 ("voicemail", backend._XML_VOICEMAIL_URL),
944                 ("sms", backend._XML_SMS_URL),
945
946                 ("recent", backend._XML_RECENT_URL),
947                 ("placed", backend._XML_PLACED_URL),
948                 ("recieved", backend._XML_RECEIVED_URL),
949                 ("missed", backend._XML_MISSED_URL),
950         ]
951
952         # Get Pages
953         print "Grabbing pre-login pages"
954         for name, url in _TEST_WEBPAGES:
955                 try:
956                         page = browser.download(url)
957                 except StandardError, e:
958                         print e.message
959                         continue
960                 print "\tWriting to file"
961                 with open("not_loggedin_%s.txt" % name, "w") as f:
962                         f.write(page)
963
964         # Login
965         print "Attempting login"
966         galxToken = backend._get_token()
967         loginSuccessOrFailurePage = backend._login(username, password, galxToken)
968         with open("loggingin.txt", "w") as f:
969                 print "\tWriting to file"
970                 f.write(loginSuccessOrFailurePage)
971         try:
972                 backend._grab_account_info(loginSuccessOrFailurePage)
973         except Exception:
974                 # Retry in case the redirect failed
975                 # luckily is_authed does everything we need for a retry
976                 loggedIn = backend.is_authed(True)
977                 if not loggedIn:
978                         raise
979
980         # Get Pages
981         print "Grabbing post-login pages"
982         for name, url in _TEST_WEBPAGES:
983                 try:
984                         page = browser.download(url)
985                 except StandardError, e:
986                         print str(e)
987                         continue
988                 print "\tWriting to file"
989                 with open("loggedin_%s.txt" % name, "w") as f:
990                         f.write(page)
991
992         # Cookies
993         browser.save_cookies()
994         print "\tWriting cookies to file"
995         with open("cookies.txt", "w") as f:
996                 f.writelines(
997                         "%s: %s\n" % (c.name, c.value)
998                         for c in browser._cookies
999                 )
1000
1001
1002 def main():
1003         import sys
1004         logging.basicConfig(level=logging.DEBUG)
1005         args = sys.argv
1006         if 3 <= len(args):
1007                 username = args[1]
1008                 password = args[2]
1009
1010         grab_debug_info(username, password)
1011
1012
1013 if __name__ == "__main__":
1014         main()