Finixhing merge
[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                 @blocks
240                 """
241                 isRecentledAuthed = (time.time() - self._lastAuthed) < 120
242                 isPreviouslyAuthed = self._token is not None
243                 if isRecentledAuthed and isPreviouslyAuthed and not force:
244                         return True
245
246                 try:
247                         page = self._get_page(self._forwardURL)
248                         self._grab_account_info(page)
249                 except Exception, e:
250                         _moduleLogger.exception(str(e))
251                         return False
252
253                 self._browser.save_cookies()
254                 self._lastAuthed = time.time()
255                 return True
256
257         def _get_token(self):
258                 tokenPage = self._get_page(self._tokenURL)
259
260                 galxTokens = self._galxRe.search(tokenPage)
261                 if galxTokens is not None:
262                         galxToken = galxTokens.group(1)
263                 else:
264                         galxToken = ""
265                         _moduleLogger.debug("Could not grab GALX token")
266                 return galxToken
267
268         def _login(self, username, password, token):
269                 loginData = {
270                         'Email' : username,
271                         'Passwd' : password,
272                         'service': "grandcentral",
273                         "ltmpl": "mobile",
274                         "btmpl": "mobile",
275                         "PersistentCookie": "yes",
276                         "GALX": token,
277                         "continue": self._forwardURL,
278                 }
279
280                 loginSuccessOrFailurePage = self._get_page(self._loginURL, loginData)
281                 return loginSuccessOrFailurePage
282
283         def login(self, username, password):
284                 """
285                 Attempt to login to GoogleVoice
286                 @returns Whether login was successful or not
287                 @blocks
288                 """
289                 self.logout()
290                 galxToken = self._get_token()
291                 loginSuccessOrFailurePage = self._login(username, password, galxToken)
292
293                 try:
294                         self._grab_account_info(loginSuccessOrFailurePage)
295                 except Exception, e:
296                         # Retry in case the redirect failed
297                         # luckily is_authed does everything we need for a retry
298                         loggedIn = self.is_authed(True)
299                         if not loggedIn:
300                                 _moduleLogger.exception(str(e))
301                                 return False
302                         _moduleLogger.info("Redirection failed on initial login attempt, auto-corrected for this")
303
304                 self._browser.save_cookies()
305                 self._lastAuthed = time.time()
306                 return True
307
308         def persist(self):
309                 self._browser.save_cookies()
310
311         def shutdown(self):
312                 self._browser.save_cookies()
313                 self._token = None
314                 self._lastAuthed = 0.0
315
316         def logout(self):
317                 self._browser.clear_cookies()
318                 self._browser.save_cookies()
319                 self._token = None
320                 self._lastAuthed = 0.0
321
322         def is_dnd(self):
323                 """
324                 @blocks
325                 """
326                 isDndPage = self._get_page(self._isDndURL)
327
328                 dndGroup = self._isDndRe.search(isDndPage)
329                 if dndGroup is None:
330                         return False
331                 dndStatus = dndGroup.group(1)
332                 isDnd = True if dndStatus.strip().lower() == "true" else False
333                 return isDnd
334
335         def set_dnd(self, doNotDisturb):
336                 """
337                 @blocks
338                 """
339                 dndPostData = {
340                         "doNotDisturb": 1 if doNotDisturb else 0,
341                 }
342
343                 dndPage = self._get_page_with_token(self._setDndURL, dndPostData)
344
345         def call(self, outgoingNumber):
346                 """
347                 This is the main function responsible for initating the callback
348                 @blocks
349                 """
350                 outgoingNumber = self._send_validation(outgoingNumber)
351                 subscriberNumber = None
352                 phoneType = guess_phone_type(self._callbackNumber) # @todo Fix this hack
353
354                 callData = {
355                                 'outgoingNumber': outgoingNumber,
356                                 'forwardingNumber': self._callbackNumber,
357                                 'subscriberNumber': subscriberNumber or 'undefined',
358                                 'phoneType': str(phoneType),
359                                 'remember': '1',
360                 }
361                 _moduleLogger.info("%r" % callData)
362
363                 page = self._get_page_with_token(
364                         self._callUrl,
365                         callData,
366                 )
367                 self._parse_with_validation(page)
368                 return True
369
370         def cancel(self, outgoingNumber=None):
371                 """
372                 Cancels a call matching outgoing and forwarding numbers (if given). 
373                 Will raise an error if no matching call is being placed
374                 @blocks
375                 """
376                 page = self._get_page_with_token(
377                         self._callCancelURL,
378                         {
379                         'outgoingNumber': outgoingNumber or 'undefined',
380                         'forwardingNumber': self._callbackNumber or 'undefined',
381                         'cancelType': 'C2C',
382                         },
383                 )
384                 self._parse_with_validation(page)
385
386         def send_sms(self, phoneNumbers, message):
387                 """
388                 @blocks
389                 """
390                 validatedPhoneNumbers = [
391                         self._send_validation(phoneNumber)
392                         for phoneNumber in phoneNumbers
393                 ]
394                 flattenedPhoneNumbers = ",".join(validatedPhoneNumbers)
395                 page = self._get_page_with_token(
396                         self._sendSmsURL,
397                         {
398                                 'phoneNumber': flattenedPhoneNumbers,
399                                 'text': message
400                         },
401                 )
402                 self._parse_with_validation(page)
403
404         def search(self, query):
405                 """
406                 Search your Google Voice Account history for calls, voicemails, and sms
407                 Returns ``Folder`` instance containting matching messages
408                 @blocks
409                 """
410                 page = self._get_page(
411                         self._XML_SEARCH_URL,
412                         {"q": query},
413                 )
414                 json, html = extract_payload(page)
415                 return json
416
417         def get_feed(self, feed):
418                 """
419                 @blocks
420                 """
421                 actualFeed = "_XML_%s_URL" % feed.upper()
422                 feedUrl = getattr(self, actualFeed)
423
424                 page = self._get_page(feedUrl)
425                 json, html = extract_payload(page)
426
427                 return json
428
429         def download(self, messageId, adir):
430                 """
431                 Download a voicemail or recorded call MP3 matching the given ``msg``
432                 which can either be a ``Message`` instance, or a SHA1 identifier. 
433                 Saves files to ``adir`` (defaults to current directory). 
434                 Message hashes can be found in ``self.voicemail().messages`` for example. 
435                 @returns location of saved file.
436                 @blocks
437                 """
438                 page = self._get_page(self._downloadVoicemailURL, {"id": messageId})
439                 fn = os.path.join(adir, '%s.mp3' % messageId)
440                 with open(fn, 'wb') as fo:
441                         fo.write(page)
442                 return fn
443
444         def is_valid_syntax(self, number):
445                 """
446                 @returns If This number be called ( syntax validation only )
447                 """
448                 return self._validateRe.match(number) is not None
449
450         def get_account_number(self):
451                 """
452                 @returns The GoogleVoice phone number
453                 """
454                 return self._accountNum
455
456         def get_callback_numbers(self):
457                 """
458                 @returns a dictionary mapping call back numbers to descriptions
459                 @note These results are cached for 30 minutes.
460                 """
461                 if not self.is_authed():
462                         return {}
463                 return self._callbackNumbers
464
465         def set_callback_number(self, callbacknumber):
466                 """
467                 Set the number that GoogleVoice calls
468                 @param callbacknumber should be a proper 10 digit number
469                 """
470                 self._callbackNumber = callbacknumber
471                 _moduleLogger.info("Callback number changed: %r" % self._callbackNumber)
472                 return True
473
474         def get_callback_number(self):
475                 """
476                 @returns Current callback number or None
477                 """
478                 return self._callbackNumber
479
480         def get_recent(self):
481                 """
482                 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
483                 @blocks
484                 """
485                 for action, url in (
486                         ("Received", self._XML_RECEIVED_URL),
487                         ("Missed", self._XML_MISSED_URL),
488                         ("Placed", self._XML_PLACED_URL),
489                 ):
490                         flatXml = self._get_page(url)
491
492                         allRecentHtml = self._grab_html(flatXml)
493                         allRecentData = self._parse_history(allRecentHtml)
494                         for recentCallData in allRecentData:
495                                 recentCallData["action"] = action
496                                 yield recentCallData
497
498         def get_contacts(self):
499                 """
500                 @returns Iterable of (contact id, contact name)
501                 @blocks
502                 """
503                 page = self._get_page(self._XML_CONTACTS_URL)
504                 contactsBody = self._contactsBodyRe.search(page)
505                 if contactsBody is None:
506                         raise RuntimeError("Could not extract contact information")
507                 accountData = _fake_parse_json(contactsBody.group(1))
508                 if accountData is None:
509                         return
510                 for contactId, contactDetails in accountData["contacts"].iteritems():
511                         # A zero contact id is the catch all for unknown contacts
512                         if contactId != "0":
513                                 if "name" in contactDetails:
514                                         contactDetails["name"] = unescape(contactDetails["name"])
515                                 yield contactId, contactDetails
516
517         def get_voicemails(self):
518                 """
519                 @blocks
520                 """
521                 voicemailPage = self._get_page(self._XML_VOICEMAIL_URL)
522                 voicemailHtml = self._grab_html(voicemailPage)
523                 voicemailJson = self._grab_json(voicemailPage)
524                 if voicemailJson is None:
525                         return ()
526                 parsedVoicemail = self._parse_voicemail(voicemailHtml)
527                 voicemails = self._merge_conversation_sources(parsedVoicemail, voicemailJson)
528                 return voicemails
529
530         def get_texts(self):
531                 """
532                 @blocks
533                 """
534                 smsPage = self._get_page(self._XML_SMS_URL)
535                 smsHtml = self._grab_html(smsPage)
536                 smsJson = self._grab_json(smsPage)
537                 if smsJson is None:
538                         return ()
539                 parsedSms = self._parse_sms(smsHtml)
540                 smss = self._merge_conversation_sources(parsedSms, smsJson)
541                 return smss
542
543         def mark_message(self, messageId, asRead):
544                 """
545                 @blocks
546                 """
547                 postData = {
548                         "read": 1 if asRead else 0,
549                         "id": messageId,
550                 }
551
552                 markPage = self._get_page(self._markAsReadURL, postData)
553
554         def archive_message(self, messageId):
555                 """
556                 @blocks
557                 """
558                 postData = {
559                         "id": messageId,
560                 }
561
562                 markPage = self._get_page(self._archiveMessageURL, postData)
563
564         def _grab_json(self, flatXml):
565                 xmlTree = ElementTree.fromstring(flatXml)
566                 jsonElement = xmlTree.getchildren()[0]
567                 flatJson = jsonElement.text
568                 jsonTree = parse_json(flatJson)
569                 return jsonTree
570
571         def _grab_html(self, flatXml):
572                 xmlTree = ElementTree.fromstring(flatXml)
573                 htmlElement = xmlTree.getchildren()[1]
574                 flatHtml = htmlElement.text
575                 return flatHtml
576
577         def _grab_account_info(self, page):
578                 tokenGroup = self._tokenRe.search(page)
579                 if tokenGroup is None:
580                         raise RuntimeError("Could not extract authentication token from GoogleVoice")
581                 self._token = tokenGroup.group(1)
582
583                 anGroup = self._accountNumRe.search(page)
584                 if anGroup is not None:
585                         self._accountNum = anGroup.group(1)
586                 else:
587                         _moduleLogger.debug("Could not extract account number from GoogleVoice")
588
589                 self._callbackNumbers = {}
590                 for match in self._callbackRe.finditer(page):
591                         callbackNumber = match.group(2)
592                         callbackName = match.group(1)
593                         self._callbackNumbers[callbackNumber] = callbackName
594                 if len(self._callbackNumbers) == 0:
595                         _moduleLogger.debug("Could not extract callback numbers from GoogleVoice (the troublesome page follows):\n%s" % page)
596
597         def _send_validation(self, number):
598                 if not self.is_valid_syntax(number):
599                         raise ValueError('Number is not valid: "%s"' % number)
600                 elif not self.is_authed():
601                         raise RuntimeError("Not Authenticated")
602                 return number
603
604         def _parse_history(self, historyHtml):
605                 splitVoicemail = self._seperateVoicemailsRegex.split(historyHtml)
606                 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
607                         exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
608                         exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
609                         exactTime = google_strptime(exactTime)
610                         relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
611                         relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
612                         locationGroup = self._voicemailLocationRegex.search(messageHtml)
613                         location = locationGroup.group(1).strip() if locationGroup else ""
614
615                         nameGroup = self._voicemailNameRegex.search(messageHtml)
616                         name = nameGroup.group(1).strip() if nameGroup else ""
617                         numberGroup = self._voicemailNumberRegex.search(messageHtml)
618                         number = numberGroup.group(1).strip() if numberGroup else ""
619                         prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
620                         prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
621                         contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
622                         contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
623
624                         yield {
625                                 "id": messageId.strip(),
626                                 "contactId": contactId,
627                                 "name": unescape(name),
628                                 "time": exactTime,
629                                 "relTime": relativeTime,
630                                 "prettyNumber": prettyNumber,
631                                 "number": number,
632                                 "location": unescape(location),
633                         }
634
635         @staticmethod
636         def _interpret_voicemail_regex(group):
637                 quality, content, number = group.group(2), group.group(3), group.group(4)
638                 text = MessageText()
639                 if quality is not None and content is not None:
640                         text.accuracy = quality
641                         text.text = unescape(content)
642                         return text
643                 elif number is not None:
644                         text.accuracy = MessageText.ACCURACY_HIGH
645                         text.text = number
646                         return text
647
648         def _parse_voicemail(self, voicemailHtml):
649                 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
650                 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
651                         conv = Conversation()
652                         conv.type = Conversation.TYPE_VOICEMAIL
653                         conv.id = messageId.strip()
654
655                         exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
656                         exactTimeText = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
657                         conv.time = google_strptime(exactTimeText)
658                         relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
659                         conv.relTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
660                         locationGroup = self._voicemailLocationRegex.search(messageHtml)
661                         conv.location = unescape(locationGroup.group(1).strip() if locationGroup else "")
662
663                         nameGroup = self._voicemailNameRegex.search(messageHtml)
664                         conv.name = unescape(nameGroup.group(1).strip() if nameGroup else "")
665                         numberGroup = self._voicemailNumberRegex.search(messageHtml)
666                         conv.number = numberGroup.group(1).strip() if numberGroup else ""
667                         prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
668                         conv.prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
669                         contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
670                         conv.contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
671
672                         messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
673                         messageParts = [
674                                 self._interpret_voicemail_regex(group)
675                                 for group in messageGroups
676                         ] if messageGroups else ((MessageText.ACCURACY_LOW, "No Transcription"), )
677                         message = Message()
678                         message.body = messageParts
679                         message.whoFrom = conv.name
680                         message.when = conv.time.strftime("%I:%M %p")
681                         conv.messages = (message, )
682
683                         yield conv
684
685         @staticmethod
686         def _interpret_sms_message_parts(fromPart, textPart, timePart):
687                 text = MessageText()
688                 text.accuracy = MessageText.ACCURACY_MEDIUM
689                 text.text = unescape(textPart)
690
691                 message = Message()
692                 message.body = (text, )
693                 message.whoFrom = fromPart
694                 message.when = timePart
695
696                 return message
697
698         def _parse_sms(self, smsHtml):
699                 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
700                 for messageId, messageHtml in itergroup(splitSms[1:], 2):
701                         conv = Conversation()
702                         conv.type = Conversation.TYPE_SMS
703                         conv.id = messageId.strip()
704
705                         exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
706                         exactTimeText = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
707                         conv.time = google_strptime(exactTimeText)
708                         relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
709                         conv.relTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
710                         conv.location = ""
711
712                         nameGroup = self._voicemailNameRegex.search(messageHtml)
713                         conv.name = unescape(nameGroup.group(1).strip() if nameGroup else "")
714                         numberGroup = self._voicemailNumberRegex.search(messageHtml)
715                         conv.number = numberGroup.group(1).strip() if numberGroup else ""
716                         prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
717                         conv.prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
718                         contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
719                         conv.contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
720
721                         fromGroups = self._smsFromRegex.finditer(messageHtml)
722                         fromParts = (group.group(1).strip() for group in fromGroups)
723                         textGroups = self._smsTextRegex.finditer(messageHtml)
724                         textParts = (group.group(1).strip() for group in textGroups)
725                         timeGroups = self._smsTimeRegex.finditer(messageHtml)
726                         timeParts = (group.group(1).strip() for group in timeGroups)
727
728                         messageParts = itertools.izip(fromParts, textParts, timeParts)
729                         messages = [self._interpret_sms_message_parts(*parts) for parts in messageParts]
730                         conv.messages = messages
731
732                         yield conv
733
734         @staticmethod
735         def _merge_conversation_sources(parsedMessages, json):
736                 for message in parsedMessages:
737                         jsonItem = json["messages"][message.id]
738                         message.isRead = jsonItem["isRead"]
739                         message.isSpam = jsonItem["isSpam"]
740                         message.isTrash = jsonItem["isTrash"]
741                         message.isArchived = "inbox" not in jsonItem["labels"]
742                         yield message
743
744         def _get_page(self, url, data = None, refererUrl = None):
745                 headers = {}
746                 if refererUrl is not None:
747                         headers["Referer"] = refererUrl
748
749                 encodedData = urllib.urlencode(data) if data is not None else None
750
751                 try:
752                         page = self._browser.download(url, encodedData, None, headers)
753                 except urllib2.URLError, e:
754                         _moduleLogger.error("Translating error: %s" % str(e))
755                         raise NetworkError("%s is not accesible" % url)
756
757                 return page
758
759         def _get_page_with_token(self, url, data = None, refererUrl = None):
760                 if data is None:
761                         data = {}
762                 data['_rnr_se'] = self._token
763
764                 page = self._get_page(url, data, refererUrl)
765
766                 return page
767
768         def _parse_with_validation(self, page):
769                 json = parse_json(page)
770                 validate_response(json)
771                 return json
772
773
774 _UNESCAPE_ENTITIES = {
775  "&quot;": '"',
776  "&nbsp;": " ",
777  "&#39;": "'",
778 }
779
780
781 def unescape(text):
782         plain = saxutils.unescape(text, _UNESCAPE_ENTITIES)
783         return plain
784
785
786 def google_strptime(time):
787         """
788         Hack: Google always returns the time in the same locale.  Sadly if the
789         local system's locale is different, there isn't a way to perfectly handle
790         the time.  So instead we handle implement some time formatting
791         """
792         abbrevTime = time[:-3]
793         parsedTime = datetime.datetime.strptime(abbrevTime, "%m/%d/%y %I:%M")
794         if time.endswith("PM"):
795                 parsedTime += datetime.timedelta(hours=12)
796         return parsedTime
797
798
799 def itergroup(iterator, count, padValue = None):
800         """
801         Iterate in groups of 'count' values. If there
802         aren't enough values, the last result is padded with
803         None.
804
805         >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
806         ...     print tuple(val)
807         (1, 2, 3)
808         (4, 5, 6)
809         >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
810         ...     print list(val)
811         [1, 2, 3]
812         [4, 5, 6]
813         >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3):
814         ...     print tuple(val)
815         (1, 2, 3)
816         (4, 5, 6)
817         (7, None, None)
818         >>> for val in itergroup("123456", 3):
819         ...     print tuple(val)
820         ('1', '2', '3')
821         ('4', '5', '6')
822         >>> for val in itergroup("123456", 3):
823         ...     print repr("".join(val))
824         '123'
825         '456'
826         """
827         paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1))
828         nIterators = (paddedIterator, ) * count
829         return itertools.izip(*nIterators)
830
831
832 def safe_eval(s):
833         _TRUE_REGEX = re.compile("true")
834         _FALSE_REGEX = re.compile("false")
835         _COMMENT_REGEX = re.compile("^\s+//.*$", re.M)
836         s = _TRUE_REGEX.sub("True", s)
837         s = _FALSE_REGEX.sub("False", s)
838         s = _COMMENT_REGEX.sub("#", s)
839         try:
840                 results = eval(s, {}, {})
841         except SyntaxError:
842                 _moduleLogger.exception("Oops")
843                 results = None
844         return results
845
846
847 def _fake_parse_json(flattened):
848         return safe_eval(flattened)
849
850
851 def _actual_parse_json(flattened):
852         return simplejson.loads(flattened)
853
854
855 if simplejson is None:
856         parse_json = _fake_parse_json
857 else:
858         parse_json = _actual_parse_json
859
860
861 def extract_payload(flatXml):
862         xmlTree = ElementTree.fromstring(flatXml)
863
864         jsonElement = xmlTree.getchildren()[0]
865         flatJson = jsonElement.text
866         jsonTree = parse_json(flatJson)
867
868         htmlElement = xmlTree.getchildren()[1]
869         flatHtml = htmlElement.text
870
871         return jsonTree, flatHtml
872
873
874 def validate_response(response):
875         """
876         Validates that the JSON response is A-OK
877         """
878         try:
879                 assert response is not None
880                 assert 'ok' in response
881                 assert response['ok']
882         except AssertionError:
883                 raise RuntimeError('There was a problem with GV: %s' % response)
884
885
886 def guess_phone_type(number):
887         if number.startswith("747") or number.startswith("1747") or number.startswith("+1747"):
888                 return GVoiceBackend.PHONE_TYPE_GIZMO
889         else:
890                 return GVoiceBackend.PHONE_TYPE_MOBILE
891
892
893 def get_sane_callback(backend):
894         """
895         Try to set a sane default callback number on these preferences
896         1) 1747 numbers ( Gizmo )
897         2) anything with gizmo in the name
898         3) anything with computer in the name
899         4) the first value
900         """
901         numbers = backend.get_callback_numbers()
902
903         priorityOrderedCriteria = [
904                 ("\+1747", None),
905                 ("1747", None),
906                 ("747", None),
907                 (None, "gizmo"),
908                 (None, "computer"),
909                 (None, "sip"),
910                 (None, None),
911         ]
912
913         for numberCriteria, descriptionCriteria in priorityOrderedCriteria:
914                 numberMatcher = None
915                 descriptionMatcher = None
916                 if numberCriteria is not None:
917                         numberMatcher = re.compile(numberCriteria)
918                 elif descriptionCriteria is not None:
919                         descriptionMatcher = re.compile(descriptionCriteria, re.I)
920
921                 for number, description in numbers.iteritems():
922                         if numberMatcher is not None and numberMatcher.match(number) is None:
923                                 continue
924                         if descriptionMatcher is not None and descriptionMatcher.match(description) is None:
925                                 continue
926                         return number
927
928
929 def set_sane_callback(backend):
930         """
931         Try to set a sane default callback number on these preferences
932         1) 1747 numbers ( Gizmo )
933         2) anything with gizmo in the name
934         3) anything with computer in the name
935         4) the first value
936         """
937         number = get_sane_callback(backend)
938         backend.set_callback_number(number)
939
940
941 def _is_not_special(name):
942         return not name.startswith("_") and name[0].lower() == name[0] and "_" not in name
943
944
945 def to_dict(obj):
946         members = inspect.getmembers(obj)
947         return dict((name, value) for (name, value) in members if _is_not_special(name))
948
949
950 def grab_debug_info(username, password):
951         cookieFile = os.path.join(".", "raw_cookies.txt")
952         try:
953                 os.remove(cookieFile)
954         except OSError:
955                 pass
956
957         backend = GVoiceBackend(cookieFile)
958         browser = backend._browser
959
960         _TEST_WEBPAGES = [
961                 ("forward", backend._forwardURL),
962                 ("token", backend._tokenURL),
963                 ("login", backend._loginURL),
964                 ("isdnd", backend._isDndURL),
965                 ("account", backend._XML_ACCOUNT_URL),
966                 ("contacts", backend._XML_CONTACTS_URL),
967
968                 ("voicemail", backend._XML_VOICEMAIL_URL),
969                 ("sms", backend._XML_SMS_URL),
970
971                 ("recent", backend._XML_RECENT_URL),
972                 ("placed", backend._XML_PLACED_URL),
973                 ("recieved", backend._XML_RECEIVED_URL),
974                 ("missed", backend._XML_MISSED_URL),
975         ]
976
977         # Get Pages
978         print "Grabbing pre-login pages"
979         for name, url in _TEST_WEBPAGES:
980                 try:
981                         page = browser.download(url)
982                 except StandardError, e:
983                         print e.message
984                         continue
985                 print "\tWriting to file"
986                 with open("not_loggedin_%s.txt" % name, "w") as f:
987                         f.write(page)
988
989         # Login
990         print "Attempting login"
991         galxToken = backend._get_token()
992         loginSuccessOrFailurePage = backend._login(username, password, galxToken)
993         with open("loggingin.txt", "w") as f:
994                 print "\tWriting to file"
995                 f.write(loginSuccessOrFailurePage)
996         try:
997                 backend._grab_account_info(loginSuccessOrFailurePage)
998         except Exception:
999                 # Retry in case the redirect failed
1000                 # luckily is_authed does everything we need for a retry
1001                 loggedIn = backend.is_authed(True)
1002                 if not loggedIn:
1003                         raise
1004
1005         # Get Pages
1006         print "Grabbing post-login pages"
1007         for name, url in _TEST_WEBPAGES:
1008                 try:
1009                         page = browser.download(url)
1010                 except StandardError, e:
1011                         print str(e)
1012                         continue
1013                 print "\tWriting to file"
1014                 with open("loggedin_%s.txt" % name, "w") as f:
1015                         f.write(page)
1016
1017         # Cookies
1018         browser.save_cookies()
1019         print "\tWriting cookies to file"
1020         with open("cookies.txt", "w") as f:
1021                 f.writelines(
1022                         "%s: %s\n" % (c.name, c.value)
1023                         for c in browser._cookies
1024                 )
1025
1026
1027 def main():
1028         import sys
1029         logging.basicConfig(level=logging.DEBUG)
1030         args = sys.argv
1031         if 3 <= len(args):
1032                 username = args[1]
1033                 password = args[2]
1034
1035         grab_debug_info(username, password)
1036
1037
1038 if __name__ == "__main__":
1039         main()