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