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