Improving the gvoice api
[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 from xml.sax import saxutils
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("gvoice.backend")
52
53
54 class NetworkError(RuntimeError):
55         pass
56
57
58 class GVoiceBackend(object):
59         """
60         This class encapsulates all of the knowledge necessary to interact with the GoogleVoice servers
61         the functions include login, setting up a callback number, and initalting a callback
62         """
63
64         PHONE_TYPE_HOME = 1
65         PHONE_TYPE_MOBILE = 2
66         PHONE_TYPE_WORK = 3
67         PHONE_TYPE_GIZMO = 7
68
69         def __init__(self, cookieFile = None):
70                 # Important items in this function are the setup of the browser emulation and cookie file
71                 self._browser = browser_emu.MozillaEmulator(1)
72                 self._loadedFromCookies = self._browser.load_cookies(cookieFile)
73
74                 self._token = ""
75                 self._accountNum = ""
76                 self._lastAuthed = 0.0
77                 self._callbackNumber = ""
78                 self._callbackNumbers = {}
79
80                 # Suprisingly, moving all of these from class to self sped up startup time
81
82                 self._validateRe = re.compile("^[0-9]{10,}$")
83
84                 self._loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
85
86                 SECURE_URL_BASE = "https://www.google.com/voice/"
87                 SECURE_MOBILE_URL_BASE = SECURE_URL_BASE + "mobile/"
88                 self._forwardURL = SECURE_MOBILE_URL_BASE + "phones"
89                 self._tokenURL = SECURE_URL_BASE + "m"
90                 self._callUrl = SECURE_URL_BASE + "call/connect"
91                 self._callCancelURL = SECURE_URL_BASE + "call/cancel"
92                 self._sendSmsURL = SECURE_URL_BASE + "sms/send"
93
94                 self._isDndURL = "https://www.google.com/voice/m/donotdisturb"
95                 self._isDndRe = re.compile(r"""<input.*?id="doNotDisturb".*?checked="(.*?)"\s*/>""")
96                 self._setDndURL = "https://www.google.com/voice/m/savednd"
97
98                 self._downloadVoicemailURL = SECURE_URL_BASE + "media/send_voicemail/"
99
100                 self._XML_SEARCH_URL = SECURE_URL_BASE + "inbox/search/"
101                 self._XML_ACCOUNT_URL = SECURE_URL_BASE + "inbox/contacts/"
102                 self._XML_RECENT_URL = SECURE_URL_BASE + "inbox/recent/"
103
104                 self.XML_FEEDS = (
105                         'inbox', 'starred', 'all', 'spam', 'trash', 'voicemail', 'sms',
106                         'recorded', 'placed', 'received', 'missed'
107                 )
108                 self._XML_INBOX_URL = SECURE_URL_BASE + "inbox/recent/inbox"
109                 self._XML_STARRED_URL = SECURE_URL_BASE + "inbox/recent/starred"
110                 self._XML_ALL_URL = SECURE_URL_BASE + "inbox/recent/all"
111                 self._XML_SPAM_URL = SECURE_URL_BASE + "inbox/recent/spam"
112                 self._XML_TRASH_URL = SECURE_URL_BASE + "inbox/recent/trash"
113                 self._XML_VOICEMAIL_URL = SECURE_URL_BASE + "inbox/recent/voicemail/"
114                 self._XML_SMS_URL = SECURE_URL_BASE + "inbox/recent/sms/"
115                 self._XML_RECORDED_URL = SECURE_URL_BASE + "inbox/recent/recorded/"
116                 self._XML_PLACED_URL = SECURE_URL_BASE + "inbox/recent/placed/"
117                 self._XML_RECEIVED_URL = SECURE_URL_BASE + "inbox/recent/received/"
118                 self._XML_MISSED_URL = SECURE_URL_BASE + "inbox/recent/missed/"
119
120                 self._contactsURL = SECURE_MOBILE_URL_BASE + "contacts"
121                 self._contactDetailURL = SECURE_MOBILE_URL_BASE + "contact"
122
123                 self._galxRe = re.compile(r"""<input.*?name="GALX".*?value="(.*?)".*?/>""", re.MULTILINE | re.DOTALL)
124                 self._tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
125                 self._accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
126                 self._callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
127
128                 self._contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
129                 self._contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
130                 self._contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9+\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
131
132                 self._seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
133                 self._exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
134                 self._relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
135                 self._voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
136                 self._voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
137                 self._prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
138                 self._voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
139                 self._messagesContactIDRegex = re.compile(r"""<a class=".*?gc-message-name-link.*?">.*?</a>\s*?<span .*?>(.*?)</span>""", re.MULTILINE)
140                 self._voicemailMessageRegex = re.compile(r"""(<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>|<a .*? class="gc-message-mni">(.*?)</a>)""", re.MULTILINE)
141                 self._smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
142                 self._smsTimeRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
143                 self._smsTextRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
144
145         def is_authed(self, force = False):
146                 """
147                 Attempts to detect a current session
148                 @note Once logged in try not to reauth more than once a minute.
149                 @returns If authenticated
150                 """
151                 isRecentledAuthed = (time.time() - self._lastAuthed) < 120
152                 isPreviouslyAuthed = self._token is not None
153                 if isRecentledAuthed and isPreviouslyAuthed and not force:
154                         return True
155
156                 try:
157                         page = self._get_page(self._forwardURL)
158                         self._grab_account_info(page)
159                 except Exception, e:
160                         _moduleLogger.exception(str(e))
161                         return False
162
163                 self._browser.save_cookies()
164                 self._lastAuthed = time.time()
165                 return True
166
167         def _get_token(self):
168                 tokenPage = self._get_page(self._tokenURL)
169
170                 galxTokens = self._galxRe.search(tokenPage)
171                 if galxTokens is not None:
172                         galxToken = galxTokens.group(1)
173                 else:
174                         galxToken = ""
175                         _moduleLogger.debug("Could not grab GALX token")
176                 return galxToken
177
178         def _login(self, username, password, token):
179                 loginData = {
180                         'Email' : username,
181                         'Passwd' : password,
182                         'service': "grandcentral",
183                         "ltmpl": "mobile",
184                         "btmpl": "mobile",
185                         "PersistentCookie": "yes",
186                         "GALX": token,
187                         "continue": self._forwardURL,
188                 }
189
190                 loginSuccessOrFailurePage = self._get_page(self._loginURL, loginData)
191                 return loginSuccessOrFailurePage
192
193         def login(self, username, password):
194                 """
195                 Attempt to login to GoogleVoice
196                 @returns Whether login was successful or not
197                 """
198                 self.logout()
199                 galxToken = self._get_token()
200                 loginSuccessOrFailurePage = self._login(username, password, galxToken)
201
202                 try:
203                         self._grab_account_info(loginSuccessOrFailurePage)
204                 except Exception, e:
205                         # Retry in case the redirect failed
206                         # luckily is_authed does everything we need for a retry
207                         loggedIn = self.is_authed(True)
208                         if not loggedIn:
209                                 _moduleLogger.exception(str(e))
210                                 return False
211                         _moduleLogger.info("Redirection failed on initial login attempt, auto-corrected for this")
212
213                 self._browser.save_cookies()
214                 self._lastAuthed = time.time()
215                 return True
216
217         def logout(self):
218                 self._browser.clear_cookies()
219                 self._browser.save_cookies()
220                 self._token = None
221                 self._lastAuthed = 0.0
222
223         def is_dnd(self):
224                 isDndPage = self._get_page(self._isDndURL)
225
226                 dndGroup = self._isDndRe.search(isDndPage)
227                 if dndGroup is None:
228                         return False
229                 dndStatus = dndGroup.group(1)
230                 isDnd = True if dndStatus.strip().lower() == "true" else False
231                 return isDnd
232
233         def set_dnd(self, doNotDisturb):
234                 dndPostData = {
235                         "doNotDisturb": 1 if doNotDisturb else 0,
236                         "_rnr_se": self._token,
237                 }
238
239                 dndPage = self._get_page(self._setDndURL, dndPostData)
240
241         def call(self, outgoingNumber):
242                 """
243                 This is the main function responsible for initating the callback
244                 """
245                 outgoingNumber = self._send_validation(outgoingNumber)
246                 subscriberNumber = None
247                 phoneType = guess_phone_type(self._callbackNumber) # @todo Fix this hack
248
249                 page = self._get_page_with_token(
250                         self._callUrl,
251                         {
252                                 'outgoingNumber': outgoingNumber,
253                                 'forwardingNumber': self._callbackNumber,
254                                 'subscriberNumber': subscriberNumber or 'undefined',
255                                 'phoneType': phoneType,
256                                 'remember': '1'
257                         },
258                 )
259                 self._parse_with_validation(page)
260                 return True
261
262         def cancel(self, outgoingNumber=None):
263                 """
264                 Cancels a call matching outgoing and forwarding numbers (if given). 
265                 Will raise an error if no matching call is being placed
266                 """
267                 page = self._get_page_with_token(
268                         self._callCancelURL,
269                         {
270                         'outgoingNumber': outgoingNumber or 'undefined',
271                         'forwardingNumber': self._callbackNumber or 'undefined',
272                         'cancelType': 'C2C',
273                         },
274                 )
275                 self._parse_with_validation(page)
276
277         def send_sms(self, phoneNumber, message):
278                 phoneNumber = self._send_validation(phoneNumber)
279                 page = self._get_page_with_token(
280                         self._sendSmsURL,
281                         {
282                                 'phoneNumber': phoneNumber,
283                                 'text': message
284                         },
285                 )
286                 self._parse_with_validation(page)
287
288         def search(self, query):
289                 """
290                 Search your Google Voice Account history for calls, voicemails, and sms
291                 Returns ``Folder`` instance containting matching messages
292                 """
293                 page = self._get_page(
294                         self._XML_SEARCH_URL,
295                         {"q": query},
296                 )
297                 json, html = extract_payload(page)
298                 return json
299
300         def get_feed(self, feed):
301                 actualFeed = "_XML_%s_URL" % feed.upper()
302                 feedUrl = getattr(self, actualFeed)
303
304                 page = self._get_page(feedUrl)
305                 json, html = extract_payload(page)
306
307                 return json
308
309         def download(self, messageId, adir):
310                 """
311                 Download a voicemail or recorded call MP3 matching the given ``msg``
312                 which can either be a ``Message`` instance, or a SHA1 identifier. 
313                 Saves files to ``adir`` (defaults to current directory). 
314                 Message hashes can be found in ``self.voicemail().messages`` for example. 
315                 Returns location of saved file.
316                 """
317                 page = self._get_page(self._downloadVoicemailURL, {"id": messageId})
318                 fn = os.path.join(adir, '%s.mp3' % messageId)
319                 with open(fn, 'wb') as fo:
320                         fo.write(page)
321                 return fn
322
323         def is_valid_syntax(self, number):
324                 """
325                 @returns If This number be called ( syntax validation only )
326                 """
327                 return self._validateRe.match(number) is not None
328
329         def get_account_number(self):
330                 """
331                 @returns The GoogleVoice phone number
332                 """
333                 return self._accountNum
334
335         def get_callback_numbers(self):
336                 """
337                 @returns a dictionary mapping call back numbers to descriptions
338                 @note These results are cached for 30 minutes.
339                 """
340                 if not self.is_authed():
341                         return {}
342                 return self._callbackNumbers
343
344         def set_callback_number(self, callbacknumber):
345                 """
346                 Set the number that GoogleVoice calls
347                 @param callbacknumber should be a proper 10 digit number
348                 """
349                 self._callbackNumber = callbacknumber
350                 return True
351
352         def get_callback_number(self):
353                 """
354                 @returns Current callback number or None
355                 """
356                 return self._callbackNumber
357
358         def get_recent(self):
359                 """
360                 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
361                 """
362                 for action, url in (
363                         ("Received", self._XML_RECEIVED_URL),
364                         ("Missed", self._XML_MISSED_URL),
365                         ("Placed", self._XML_PLACED_URL),
366                 ):
367                         flatXml = self._get_page(url)
368
369                         allRecentHtml = self._grab_html(flatXml)
370                         allRecentData = self._parse_voicemail(allRecentHtml)
371                         for recentCallData in allRecentData:
372                                 recentCallData["action"] = action
373                                 yield recentCallData
374
375         def get_contacts(self):
376                 """
377                 @returns Iterable of (contact id, contact name)
378                 """
379                 contactsPagesUrls = [self._contactsURL]
380                 for contactsPageUrl in contactsPagesUrls:
381                         contactsPage = self._get_page(contactsPageUrl)
382                         for contact_match in self._contactsRe.finditer(contactsPage):
383                                 contactId = contact_match.group(1)
384                                 contactName = saxutils.unescape(contact_match.group(2))
385                                 contact = contactId, contactName
386                                 yield contact
387
388                         next_match = self._contactsNextRe.match(contactsPage)
389                         if next_match is not None:
390                                 newContactsPageUrl = self._contactsURL + next_match.group(1)
391                                 contactsPagesUrls.append(newContactsPageUrl)
392
393         def get_contact_details(self, contactId):
394                 """
395                 @returns Iterable of (Phone Type, Phone Number)
396                 """
397                 detailPage = self._get_page(self._contactDetailURL + '/' + contactId)
398
399                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
400                         phoneNumber = detail_match.group(1)
401                         phoneType = saxutils.unescape(detail_match.group(2))
402                         yield (phoneType, phoneNumber)
403
404         def get_messages(self):
405                 voicemailPage = self._get_page(self._XML_VOICEMAIL_URL)
406                 voicemailHtml = self._grab_html(voicemailPage)
407                 voicemailJson = self._grab_json(voicemailPage)
408                 parsedVoicemail = self._parse_voicemail(voicemailHtml)
409                 voicemails = self._merge_messages(parsedVoicemail, voicemailJson)
410                 decoratedVoicemails = self._decorate_voicemail(voicemails)
411
412                 smsPage = self._get_page(self._XML_SMS_URL)
413                 smsHtml = self._grab_html(smsPage)
414                 smsJson = self._grab_json(smsPage)
415                 parsedSms = self._parse_sms(smsHtml)
416                 smss = self._merge_messages(parsedSms, smsJson)
417                 decoratedSms = self._decorate_sms(smss)
418
419                 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
420                 return allMessages
421
422         def _grab_json(self, flatXml):
423                 xmlTree = ElementTree.fromstring(flatXml)
424                 jsonElement = xmlTree.getchildren()[0]
425                 flatJson = jsonElement.text
426                 jsonTree = parse_json(flatJson)
427                 return jsonTree
428
429         def _grab_html(self, flatXml):
430                 xmlTree = ElementTree.fromstring(flatXml)
431                 htmlElement = xmlTree.getchildren()[1]
432                 flatHtml = htmlElement.text
433                 return flatHtml
434
435         def _grab_account_info(self, page):
436                 tokenGroup = self._tokenRe.search(page)
437                 if tokenGroup is None:
438                         raise RuntimeError("Could not extract authentication token from GoogleVoice")
439                 self._token = tokenGroup.group(1)
440
441                 anGroup = self._accountNumRe.search(page)
442                 if anGroup is not None:
443                         self._accountNum = anGroup.group(1)
444                 else:
445                         _moduleLogger.debug("Could not extract account number from GoogleVoice")
446
447                 self._callbackNumbers = {}
448                 for match in self._callbackRe.finditer(page):
449                         callbackNumber = match.group(2)
450                         callbackName = match.group(1)
451                         self._callbackNumbers[callbackNumber] = callbackName
452                 if len(self._callbackNumbers) == 0:
453                         _moduleLogger.debug("Could not extract callback numbers from GoogleVoice (the troublesome page follows):\n%s" % page)
454
455         def _send_validation(self, number):
456                 if not self.is_valid_syntax(number):
457                         raise ValueError('Number is not valid: "%s"' % number)
458                 elif not self.is_authed():
459                         raise RuntimeError("Not Authenticated")
460
461                 if len(number) == 11 and number[0] == 1:
462                         # Strip leading 1 from 11 digit dialing
463                         number = number[1:]
464                 return number
465
466         @staticmethod
467         def _interpret_voicemail_regex(group):
468                 quality, content, number = group.group(2), group.group(3), group.group(4)
469                 if quality is not None and content is not None:
470                         return quality, content
471                 elif number is not None:
472                         return "high", number
473
474         def _parse_voicemail(self, voicemailHtml):
475                 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
476                 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
477                         exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
478                         exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
479                         exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
480                         relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
481                         relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
482                         locationGroup = self._voicemailLocationRegex.search(messageHtml)
483                         location = locationGroup.group(1).strip() if locationGroup else ""
484
485                         nameGroup = self._voicemailNameRegex.search(messageHtml)
486                         name = nameGroup.group(1).strip() if nameGroup else ""
487                         numberGroup = self._voicemailNumberRegex.search(messageHtml)
488                         number = numberGroup.group(1).strip() if numberGroup else ""
489                         prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
490                         prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
491                         contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
492                         contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
493
494                         messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
495                         messageParts = (
496                                 self._interpret_voicemail_regex(group)
497                                 for group in messageGroups
498                         ) if messageGroups else ()
499
500                         yield {
501                                 "id": messageId.strip(),
502                                 "contactId": contactId,
503                                 "name": name,
504                                 "time": exactTime,
505                                 "relTime": relativeTime,
506                                 "prettyNumber": prettyNumber,
507                                 "number": number,
508                                 "location": location,
509                                 "messageParts": messageParts,
510                                 "type": "Voicemail",
511                         }
512
513         def _decorate_voicemail(self, parsedVoicemails):
514                 messagePartFormat = {
515                         "med1": "<i>%s</i>",
516                         "med2": "%s",
517                         "high": "<b>%s</b>",
518                 }
519                 for voicemailData in parsedVoicemails:
520                         message = " ".join((
521                                 messagePartFormat[quality] % part
522                                 for (quality, part) in voicemailData["messageParts"]
523                         )).strip()
524                         if not message:
525                                 message = "No Transcription"
526                         whoFrom = voicemailData["name"]
527                         when = voicemailData["time"]
528                         voicemailData["messageParts"] = ((whoFrom, message, when), )
529                         yield voicemailData
530
531         def _parse_sms(self, smsHtml):
532                 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
533                 for messageId, messageHtml in itergroup(splitSms[1:], 2):
534                         exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
535                         exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
536                         exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
537                         relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
538                         relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
539
540                         nameGroup = self._voicemailNameRegex.search(messageHtml)
541                         name = nameGroup.group(1).strip() if nameGroup else ""
542                         numberGroup = self._voicemailNumberRegex.search(messageHtml)
543                         number = numberGroup.group(1).strip() if numberGroup else ""
544                         prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
545                         prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
546                         contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
547                         contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
548
549                         fromGroups = self._smsFromRegex.finditer(messageHtml)
550                         fromParts = (group.group(1).strip() for group in fromGroups)
551                         textGroups = self._smsTextRegex.finditer(messageHtml)
552                         textParts = (group.group(1).strip() for group in textGroups)
553                         timeGroups = self._smsTimeRegex.finditer(messageHtml)
554                         timeParts = (group.group(1).strip() for group in timeGroups)
555
556                         messageParts = itertools.izip(fromParts, textParts, timeParts)
557
558                         yield {
559                                 "id": messageId.strip(),
560                                 "contactId": contactId,
561                                 "name": name,
562                                 "time": exactTime,
563                                 "relTime": relativeTime,
564                                 "prettyNumber": prettyNumber,
565                                 "number": number,
566                                 "location": "",
567                                 "messageParts": messageParts,
568                                 "type": "Texts",
569                         }
570
571         def _decorate_sms(self, parsedTexts):
572                 return parsedTexts
573
574         @staticmethod
575         def _merge_messages(parsedMessages, json):
576                 for message in parsedMessages:
577                         id = message["id"]
578                         jsonItem = json["messages"][id]
579                         message["isRead"] = jsonItem["isRead"]
580                         message["isSpam"] = jsonItem["isSpam"]
581                         message["isTrash"] = jsonItem["isTrash"]
582                         message["isArchived"] = "inbox" not in jsonItem["labels"]
583                         yield message
584
585         def _get_page(self, url, data = None, refererUrl = None):
586                 headers = {}
587                 if refererUrl is not None:
588                         headers["Referer"] = refererUrl
589
590                 encodedData = urllib.urlencode(data) if data is not None else None
591
592                 try:
593                         page = self._browser.download(url, encodedData, None, headers)
594                 except urllib2.URLError, e:
595                         _moduleLogger.error("Translating error: %s" % str(e))
596                         raise NetworkError("%s is not accesible" % url)
597
598                 return page
599
600         def _get_page_with_token(self, url, data = None, refererUrl = None):
601                 if data is None:
602                         data = {}
603                 data['_rnr_se'] = self._token
604
605                 page = self._get_page(url, data, refererUrl)
606
607                 return page
608
609         def _parse_with_validation(self, page):
610                 json, html = extract_payload(page)
611                 validate_response(json)
612                 return json, html
613
614
615 def itergroup(iterator, count, padValue = None):
616         """
617         Iterate in groups of 'count' values. If there
618         aren't enough values, the last result is padded with
619         None.
620
621         >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
622         ...     print tuple(val)
623         (1, 2, 3)
624         (4, 5, 6)
625         >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
626         ...     print list(val)
627         [1, 2, 3]
628         [4, 5, 6]
629         >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3):
630         ...     print tuple(val)
631         (1, 2, 3)
632         (4, 5, 6)
633         (7, None, None)
634         >>> for val in itergroup("123456", 3):
635         ...     print tuple(val)
636         ('1', '2', '3')
637         ('4', '5', '6')
638         >>> for val in itergroup("123456", 3):
639         ...     print repr("".join(val))
640         '123'
641         '456'
642         """
643         paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1))
644         nIterators = (paddedIterator, ) * count
645         return itertools.izip(*nIterators)
646
647
648 def safe_eval(s):
649         _TRUE_REGEX = re.compile("true")
650         _FALSE_REGEX = re.compile("false")
651         s = _TRUE_REGEX.sub("True", s)
652         s = _FALSE_REGEX.sub("False", s)
653         return eval(s, {}, {})
654
655
656 def _fake_parse_json(flattened):
657         return safe_eval(flattened)
658
659
660 def _actual_parse_json(flattened):
661         return simplejson.loads(flattened)
662
663
664 if simplejson is None:
665         parse_json = _fake_parse_json
666 else:
667         parse_json = _actual_parse_json
668
669
670 def extract_payload(flatXml):
671         xmlTree = ElementTree.fromstring(flatXml)
672
673         jsonElement = xmlTree.getchildren()[0]
674         flatJson = jsonElement.text
675         jsonTree = parse_json(flatJson)
676
677         htmlElement = xmlTree.getchildren()[1]
678         flatHtml = htmlElement.text
679
680         return jsonTree, flatHtml
681
682
683 def validate_response(response):
684         """
685         Validates that the JSON response is A-OK
686         """
687         try:
688                 assert 'ok' in response and response['ok']
689         except AssertionError:
690                 raise RuntimeError('There was a problem with GV: %s' % response)
691
692
693 def guess_phone_type(number):
694         if number.startswith("747") or number.startswith("1747"):
695                 return GVDialer.PHONE_TYPE_GIZMO
696         else:
697                 return GVDialer.PHONE_TYPE_MOBILE
698
699
700 def set_sane_callback(backend):
701         """
702         Try to set a sane default callback number on these preferences
703         1) 1747 numbers ( Gizmo )
704         2) anything with gizmo in the name
705         3) anything with computer in the name
706         4) the first value
707         """
708         numbers = backend.get_callback_numbers()
709
710         priorityOrderedCriteria = [
711                 ("1747", None),
712                 (None, "gizmo"),
713                 (None, "computer"),
714                 (None, "sip"),
715                 (None, None),
716         ]
717
718         for numberCriteria, descriptionCriteria in priorityOrderedCriteria:
719                 for number, description in numbers.iteritems():
720                         if numberCriteria is not None and re.compile(numberCriteria).match(number) is None:
721                                 continue
722                         if descriptionCriteria is not None and re.compile(descriptionCriteria).match(description) is None:
723                                 continue
724                         backend.set_callback_number(number)
725                         return
726
727
728 def sort_messages(allMessages):
729         sortableAllMessages = [
730                 (message["time"], message)
731                 for message in allMessages
732         ]
733         sortableAllMessages.sort(reverse=True)
734         return (
735                 message
736                 for (exactTime, message) in sortableAllMessages
737         )
738
739
740 def decorate_recent(recentCallData):
741         """
742         @returns (personsName, phoneNumber, date, action)
743         """
744         contactId = recentCallData["contactId"]
745         if recentCallData["name"]:
746                 header = recentCallData["name"]
747         elif recentCallData["prettyNumber"]:
748                 header = recentCallData["prettyNumber"]
749         elif recentCallData["location"]:
750                 header = recentCallData["location"]
751         else:
752                 header = "Unknown"
753
754         number = recentCallData["number"]
755         relTime = recentCallData["relTime"]
756         action = recentCallData["action"]
757         return contactId, header, number, relTime, action
758
759
760 def decorate_message(messageData):
761         contactId = messageData["contactId"]
762         exactTime = messageData["time"]
763         if messageData["name"]:
764                 header = messageData["name"]
765         elif messageData["prettyNumber"]:
766                 header = messageData["prettyNumber"]
767         else:
768                 header = "Unknown"
769         number = messageData["number"]
770         relativeTime = messageData["relTime"]
771
772         messageParts = list(messageData["messageParts"])
773         if len(messageParts) == 0:
774                 messages = ("No Transcription", )
775         elif len(messageParts) == 1:
776                 messages = (messageParts[0][1], )
777         else:
778                 messages = [
779                         "<b>%s</b>: %s" % (messagePart[0], messagePart[1])
780                         for messagePart in messageParts
781                 ]
782
783         decoratedResults = contactId, header, number, relativeTime, messages
784         return decoratedResults
785
786
787 def test_backend(username, password):
788         backend = GVoiceBackend()
789         print "Authenticated: ", backend.is_authed()
790         if not backend.is_authed():
791                 print "Login?: ", backend.login(username, password)
792         print "Authenticated: ", backend.is_authed()
793         print "Is Dnd: ", backend.is_dnd()
794         #print "Setting Dnd", backend.set_dnd(True)
795         #print "Is Dnd: ", backend.is_dnd()
796         #print "Setting Dnd", backend.set_dnd(False)
797         #print "Is Dnd: ", backend.is_dnd()
798
799         #print "Token: ", backend._token
800         #print "Account: ", backend.get_account_number()
801         #print "Callback: ", backend.get_callback_number()
802         #print "All Callback: ",
803         #import pprint
804         #pprint.pprint(backend.get_callback_numbers())
805
806         #print "Recent: "
807         #for data in backend.get_recent():
808         #       pprint.pprint(data)
809         #for data in sort_messages(backend.get_recent()):
810         #       pprint.pprint(decorate_recent(data))
811         #pprint.pprint(list(backend.get_recent()))
812
813         #print "Contacts: ",
814         #for contact in backend.get_contacts():
815         #       print contact
816         #       pprint.pprint(list(backend.get_contact_details(contact[0])))
817
818         #print "Messages: ",
819         #for message in backend.get_messages():
820         #       pprint.pprint(message)
821         #for message in sort_messages(backend.get_messages()):
822         #       pprint.pprint(decorate_message(message))
823
824         return backend
825
826
827 def grab_debug_info(username, password):
828         cookieFile = os.path.join(".", "raw_cookies.txt")
829         try:
830                 os.remove(cookieFile)
831         except OSError:
832                 pass
833
834         backend = GVoiceBackend(cookieFile)
835         browser = backend._browser
836
837         _TEST_WEBPAGES = [
838                 ("forward", backend._forwardURL),
839                 ("token", backend._tokenURL),
840                 ("login", backend._loginURL),
841                 ("isdnd", backend._isDndURL),
842                 ("contacts", backend._contactsURL),
843
844                 ("account", backend._XML_ACCOUNT_URL),
845                 ("voicemail", backend._XML_VOICEMAIL_URL),
846                 ("sms", backend._XML_SMS_URL),
847
848                 ("recent", backend._XML_RECENT_URL),
849                 ("placed", backend._XML_PLACED_URL),
850                 ("recieved", backend._XML_RECEIVED_URL),
851                 ("missed", backend._XML_MISSED_URL),
852         ]
853
854         # Get Pages
855         print "Grabbing pre-login pages"
856         for name, url in _TEST_WEBPAGES:
857                 try:
858                         page = browser.download(url)
859                 except StandardError, e:
860                         print e.message
861                         continue
862                 print "\tWriting to file"
863                 with open("not_loggedin_%s.txt" % name, "w") as f:
864                         f.write(page)
865
866         # Login
867         print "Attempting login"
868         galxToken = backend._get_token()
869         loginSuccessOrFailurePage = backend._login(username, password, galxToken)
870         with open("loggingin.txt", "w") as f:
871                 print "\tWriting to file"
872                 f.write(loginSuccessOrFailurePage)
873         try:
874                 backend._grab_account_info(loginSuccessOrFailurePage)
875         except Exception:
876                 # Retry in case the redirect failed
877                 # luckily is_authed does everything we need for a retry
878                 loggedIn = backend.is_authed(True)
879                 if not loggedIn:
880                         raise
881
882         # Get Pages
883         print "Grabbing post-login pages"
884         for name, url in _TEST_WEBPAGES:
885                 try:
886                         page = browser.download(url)
887                 except StandardError, e:
888                         print e.message
889                         continue
890                 print "\tWriting to file"
891                 with open("loggedin_%s.txt" % name, "w") as f:
892                         f.write(page)
893
894         # Cookies
895         browser.cookies.save()
896         print "\tWriting cookies to file"
897         with open("cookies.txt", "w") as f:
898                 f.writelines(
899                         "%s: %s\n" % (c.name, c.value)
900                         for c in browser.cookies
901                 )
902
903
904 if __name__ == "__main__":
905         import sys
906         logging.basicConfig(level=logging.DEBUG)
907         if True:
908                 grab_debug_info(sys.argv[1], sys.argv[2])
909         else:
910                 test_backend(sys.argv[1], sys.argv[2])