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