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