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