Updating the webpage
[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                 callData = {
250                                 'outgoingNumber': outgoingNumber,
251                                 'forwardingNumber': self._callbackNumber,
252                                 'subscriberNumber': subscriberNumber or 'undefined',
253                                 'phoneType': str(phoneType),
254                                 'remember': '1',
255                 }
256                 _moduleLogger.info("%r" % callData)
257
258                 page = self._get_page_with_token(
259                         self._callUrl,
260                         callData,
261                 )
262                 _moduleLogger.info(page)
263                 self._parse_with_validation(page)
264                 return True
265
266         def cancel(self, outgoingNumber=None):
267                 """
268                 Cancels a call matching outgoing and forwarding numbers (if given). 
269                 Will raise an error if no matching call is being placed
270                 """
271                 page = self._get_page_with_token(
272                         self._callCancelURL,
273                         {
274                         'outgoingNumber': outgoingNumber or 'undefined',
275                         'forwardingNumber': self._callbackNumber or 'undefined',
276                         'cancelType': 'C2C',
277                         },
278                 )
279                 self._parse_with_validation(page)
280
281         def send_sms(self, phoneNumber, message):
282                 phoneNumber = self._send_validation(phoneNumber)
283                 page = self._get_page_with_token(
284                         self._sendSmsURL,
285                         {
286                                 'phoneNumber': phoneNumber,
287                                 'text': message
288                         },
289                 )
290                 self._parse_with_validation(page)
291
292         def search(self, query):
293                 """
294                 Search your Google Voice Account history for calls, voicemails, and sms
295                 Returns ``Folder`` instance containting matching messages
296                 """
297                 page = self._get_page(
298                         self._XML_SEARCH_URL,
299                         {"q": query},
300                 )
301                 json, html = extract_payload(page)
302                 return json
303
304         def get_feed(self, feed):
305                 actualFeed = "_XML_%s_URL" % feed.upper()
306                 feedUrl = getattr(self, actualFeed)
307
308                 page = self._get_page(feedUrl)
309                 json, html = extract_payload(page)
310
311                 return json
312
313         def download(self, messageId, adir):
314                 """
315                 Download a voicemail or recorded call MP3 matching the given ``msg``
316                 which can either be a ``Message`` instance, or a SHA1 identifier. 
317                 Saves files to ``adir`` (defaults to current directory). 
318                 Message hashes can be found in ``self.voicemail().messages`` for example. 
319                 Returns location of saved file.
320                 """
321                 page = self._get_page(self._downloadVoicemailURL, {"id": messageId})
322                 fn = os.path.join(adir, '%s.mp3' % messageId)
323                 with open(fn, 'wb') as fo:
324                         fo.write(page)
325                 return fn
326
327         def is_valid_syntax(self, number):
328                 """
329                 @returns If This number be called ( syntax validation only )
330                 """
331                 return self._validateRe.match(number) is not None
332
333         def get_account_number(self):
334                 """
335                 @returns The GoogleVoice phone number
336                 """
337                 return self._accountNum
338
339         def get_callback_numbers(self):
340                 """
341                 @returns a dictionary mapping call back numbers to descriptions
342                 @note These results are cached for 30 minutes.
343                 """
344                 if not self.is_authed():
345                         return {}
346                 return self._callbackNumbers
347
348         def set_callback_number(self, callbacknumber):
349                 """
350                 Set the number that GoogleVoice calls
351                 @param callbacknumber should be a proper 10 digit number
352                 """
353                 self._callbackNumber = callbacknumber
354                 return True
355
356         def get_callback_number(self):
357                 """
358                 @returns Current callback number or None
359                 """
360                 return self._callbackNumber
361
362         def get_recent(self):
363                 """
364                 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
365                 """
366                 for action, url in (
367                         ("Received", self._XML_RECEIVED_URL),
368                         ("Missed", self._XML_MISSED_URL),
369                         ("Placed", self._XML_PLACED_URL),
370                 ):
371                         flatXml = self._get_page(url)
372
373                         allRecentHtml = self._grab_html(flatXml)
374                         allRecentData = self._parse_voicemail(allRecentHtml)
375                         for recentCallData in allRecentData:
376                                 recentCallData["action"] = action
377                                 yield recentCallData
378
379         def get_contacts(self):
380                 """
381                 @returns Iterable of (contact id, contact name)
382                 """
383                 contactsPagesUrls = [self._contactsURL]
384                 for contactsPageUrl in contactsPagesUrls:
385                         contactsPage = self._get_page(contactsPageUrl)
386                         for contact_match in self._contactsRe.finditer(contactsPage):
387                                 contactId = contact_match.group(1)
388                                 contactName = saxutils.unescape(contact_match.group(2))
389                                 contact = contactId, contactName
390                                 yield contact
391
392                         next_match = self._contactsNextRe.match(contactsPage)
393                         if next_match is not None:
394                                 newContactsPageUrl = self._contactsURL + next_match.group(1)
395                                 contactsPagesUrls.append(newContactsPageUrl)
396
397         def get_contact_details(self, contactId):
398                 """
399                 @returns Iterable of (Phone Type, Phone Number)
400                 """
401                 detailPage = self._get_page(self._contactDetailURL + '/' + contactId)
402
403                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
404                         phoneNumber = detail_match.group(1)
405                         phoneType = saxutils.unescape(detail_match.group(2))
406                         yield (phoneType, phoneNumber)
407
408         def get_messages(self):
409                 voicemailPage = self._get_page(self._XML_VOICEMAIL_URL)
410                 voicemailHtml = self._grab_html(voicemailPage)
411                 voicemailJson = self._grab_json(voicemailPage)
412                 parsedVoicemail = self._parse_voicemail(voicemailHtml)
413                 voicemails = self._merge_messages(parsedVoicemail, voicemailJson)
414                 decoratedVoicemails = self._decorate_voicemail(voicemails)
415
416                 smsPage = self._get_page(self._XML_SMS_URL)
417                 smsHtml = self._grab_html(smsPage)
418                 smsJson = self._grab_json(smsPage)
419                 parsedSms = self._parse_sms(smsHtml)
420                 smss = self._merge_messages(parsedSms, smsJson)
421                 decoratedSms = self._decorate_sms(smss)
422
423                 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
424                 return allMessages
425
426         def _grab_json(self, flatXml):
427                 xmlTree = ElementTree.fromstring(flatXml)
428                 jsonElement = xmlTree.getchildren()[0]
429                 flatJson = jsonElement.text
430                 jsonTree = parse_json(flatJson)
431                 return jsonTree
432
433         def _grab_html(self, flatXml):
434                 xmlTree = ElementTree.fromstring(flatXml)
435                 htmlElement = xmlTree.getchildren()[1]
436                 flatHtml = htmlElement.text
437                 return flatHtml
438
439         def _grab_account_info(self, page):
440                 tokenGroup = self._tokenRe.search(page)
441                 if tokenGroup is None:
442                         raise RuntimeError("Could not extract authentication token from GoogleVoice")
443                 self._token = tokenGroup.group(1)
444
445                 anGroup = self._accountNumRe.search(page)
446                 if anGroup is not None:
447                         self._accountNum = anGroup.group(1)
448                 else:
449                         _moduleLogger.debug("Could not extract account number from GoogleVoice")
450
451                 self._callbackNumbers = {}
452                 for match in self._callbackRe.finditer(page):
453                         callbackNumber = match.group(2)
454                         callbackName = match.group(1)
455                         self._callbackNumbers[callbackNumber] = callbackName
456                 if len(self._callbackNumbers) == 0:
457                         _moduleLogger.debug("Could not extract callback numbers from GoogleVoice (the troublesome page follows):\n%s" % page)
458
459         def _send_validation(self, number):
460                 if not self.is_valid_syntax(number):
461                         raise ValueError('Number is not valid: "%s"' % number)
462                 elif not self.is_authed():
463                         raise RuntimeError("Not Authenticated")
464
465                 if len(number) == 11 and number[0] == 1:
466                         # Strip leading 1 from 11 digit dialing
467                         number = number[1:]
468                 return number
469
470         @staticmethod
471         def _interpret_voicemail_regex(group):
472                 quality, content, number = group.group(2), group.group(3), group.group(4)
473                 if quality is not None and content is not None:
474                         return quality, content
475                 elif number is not None:
476                         return "high", number
477
478         def _parse_voicemail(self, voicemailHtml):
479                 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
480                 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
481                         exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
482                         exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
483                         exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
484                         relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
485                         relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
486                         locationGroup = self._voicemailLocationRegex.search(messageHtml)
487                         location = locationGroup.group(1).strip() if locationGroup else ""
488
489                         nameGroup = self._voicemailNameRegex.search(messageHtml)
490                         name = nameGroup.group(1).strip() if nameGroup else ""
491                         numberGroup = self._voicemailNumberRegex.search(messageHtml)
492                         number = numberGroup.group(1).strip() if numberGroup else ""
493                         prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
494                         prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
495                         contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
496                         contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
497
498                         messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
499                         messageParts = (
500                                 self._interpret_voicemail_regex(group)
501                                 for group in messageGroups
502                         ) if messageGroups else ()
503
504                         yield {
505                                 "id": messageId.strip(),
506                                 "contactId": contactId,
507                                 "name": name,
508                                 "time": exactTime,
509                                 "relTime": relativeTime,
510                                 "prettyNumber": prettyNumber,
511                                 "number": number,
512                                 "location": location,
513                                 "messageParts": messageParts,
514                                 "type": "Voicemail",
515                         }
516
517         def _decorate_voicemail(self, parsedVoicemails):
518                 messagePartFormat = {
519                         "med1": "<i>%s</i>",
520                         "med2": "%s",
521                         "high": "<b>%s</b>",
522                 }
523                 for voicemailData in parsedVoicemails:
524                         message = " ".join((
525                                 messagePartFormat[quality] % part
526                                 for (quality, part) in voicemailData["messageParts"]
527                         )).strip()
528                         if not message:
529                                 message = "No Transcription"
530                         whoFrom = voicemailData["name"]
531                         when = voicemailData["time"]
532                         voicemailData["messageParts"] = ((whoFrom, message, when), )
533                         yield voicemailData
534
535         def _parse_sms(self, smsHtml):
536                 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
537                 for messageId, messageHtml in itergroup(splitSms[1:], 2):
538                         exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
539                         exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
540                         exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
541                         relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
542                         relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
543
544                         nameGroup = self._voicemailNameRegex.search(messageHtml)
545                         name = nameGroup.group(1).strip() if nameGroup else ""
546                         numberGroup = self._voicemailNumberRegex.search(messageHtml)
547                         number = numberGroup.group(1).strip() if numberGroup else ""
548                         prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
549                         prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
550                         contactIdGroup = self._messagesContactIDRegex.search(messageHtml)
551                         contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
552
553                         fromGroups = self._smsFromRegex.finditer(messageHtml)
554                         fromParts = (group.group(1).strip() for group in fromGroups)
555                         textGroups = self._smsTextRegex.finditer(messageHtml)
556                         textParts = (group.group(1).strip() for group in textGroups)
557                         timeGroups = self._smsTimeRegex.finditer(messageHtml)
558                         timeParts = (group.group(1).strip() for group in timeGroups)
559
560                         messageParts = itertools.izip(fromParts, textParts, timeParts)
561
562                         yield {
563                                 "id": messageId.strip(),
564                                 "contactId": contactId,
565                                 "name": name,
566                                 "time": exactTime,
567                                 "relTime": relativeTime,
568                                 "prettyNumber": prettyNumber,
569                                 "number": number,
570                                 "location": "",
571                                 "messageParts": messageParts,
572                                 "type": "Texts",
573                         }
574
575         def _decorate_sms(self, parsedTexts):
576                 return parsedTexts
577
578         @staticmethod
579         def _merge_messages(parsedMessages, json):
580                 for message in parsedMessages:
581                         id = message["id"]
582                         jsonItem = json["messages"][id]
583                         message["isRead"] = jsonItem["isRead"]
584                         message["isSpam"] = jsonItem["isSpam"]
585                         message["isTrash"] = jsonItem["isTrash"]
586                         message["isArchived"] = "inbox" not in jsonItem["labels"]
587                         yield message
588
589         def _get_page(self, url, data = None, refererUrl = None):
590                 headers = {}
591                 if refererUrl is not None:
592                         headers["Referer"] = refererUrl
593
594                 encodedData = urllib.urlencode(data) if data is not None else None
595
596                 try:
597                         page = self._browser.download(url, encodedData, None, headers)
598                 except urllib2.URLError, e:
599                         _moduleLogger.error("Translating error: %s" % str(e))
600                         raise NetworkError("%s is not accesible" % url)
601
602                 return page
603
604         def _get_page_with_token(self, url, data = None, refererUrl = None):
605                 if data is None:
606                         data = {}
607                 data['_rnr_se'] = self._token
608
609                 page = self._get_page(url, data, refererUrl)
610
611                 return page
612
613         def _parse_with_validation(self, page):
614                 json = parse_json(page)
615                 validate_response(json)
616                 return json
617
618
619 def itergroup(iterator, count, padValue = None):
620         """
621         Iterate in groups of 'count' values. If there
622         aren't enough values, the last result is padded with
623         None.
624
625         >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
626         ...     print tuple(val)
627         (1, 2, 3)
628         (4, 5, 6)
629         >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
630         ...     print list(val)
631         [1, 2, 3]
632         [4, 5, 6]
633         >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3):
634         ...     print tuple(val)
635         (1, 2, 3)
636         (4, 5, 6)
637         (7, None, None)
638         >>> for val in itergroup("123456", 3):
639         ...     print tuple(val)
640         ('1', '2', '3')
641         ('4', '5', '6')
642         >>> for val in itergroup("123456", 3):
643         ...     print repr("".join(val))
644         '123'
645         '456'
646         """
647         paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1))
648         nIterators = (paddedIterator, ) * count
649         return itertools.izip(*nIterators)
650
651
652 def safe_eval(s):
653         _TRUE_REGEX = re.compile("true")
654         _FALSE_REGEX = re.compile("false")
655         s = _TRUE_REGEX.sub("True", s)
656         s = _FALSE_REGEX.sub("False", s)
657         return eval(s, {}, {})
658
659
660 def _fake_parse_json(flattened):
661         return safe_eval(flattened)
662
663
664 def _actual_parse_json(flattened):
665         return simplejson.loads(flattened)
666
667
668 if simplejson is None:
669         parse_json = _fake_parse_json
670 else:
671         parse_json = _actual_parse_json
672
673
674 def extract_payload(flatXml):
675         xmlTree = ElementTree.fromstring(flatXml)
676
677         jsonElement = xmlTree.getchildren()[0]
678         flatJson = jsonElement.text
679         jsonTree = parse_json(flatJson)
680
681         htmlElement = xmlTree.getchildren()[1]
682         flatHtml = htmlElement.text
683
684         return jsonTree, flatHtml
685
686
687 def validate_response(response):
688         """
689         Validates that the JSON response is A-OK
690         """
691         try:
692                 assert 'ok' in response and response['ok']
693         except AssertionError:
694                 raise RuntimeError('There was a problem with GV: %s' % response)
695
696
697 def guess_phone_type(number):
698         if number.startswith("747") or number.startswith("1747"):
699                 return GVoiceBackend.PHONE_TYPE_GIZMO
700         else:
701                 return GVoiceBackend.PHONE_TYPE_MOBILE
702
703
704 def set_sane_callback(backend):
705         """
706         Try to set a sane default callback number on these preferences
707         1) 1747 numbers ( Gizmo )
708         2) anything with gizmo in the name
709         3) anything with computer in the name
710         4) the first value
711         """
712         numbers = backend.get_callback_numbers()
713
714         priorityOrderedCriteria = [
715                 ("1747", None),
716                 (None, "gizmo"),
717                 (None, "computer"),
718                 (None, "sip"),
719                 (None, None),
720         ]
721
722         for numberCriteria, descriptionCriteria in priorityOrderedCriteria:
723                 for number, description in numbers.iteritems():
724                         if numberCriteria is not None and re.compile(numberCriteria).match(number) is None:
725                                 continue
726                         if descriptionCriteria is not None and re.compile(descriptionCriteria).match(description) is None:
727                                 continue
728                         backend.set_callback_number(number)
729                         return
730
731
732 def sort_messages(allMessages):
733         sortableAllMessages = [
734                 (message["time"], message)
735                 for message in allMessages
736         ]
737         sortableAllMessages.sort(reverse=True)
738         return (
739                 message
740                 for (exactTime, message) in sortableAllMessages
741         )
742
743
744 def decorate_recent(recentCallData):
745         """
746         @returns (personsName, phoneNumber, date, action)
747         """
748         contactId = recentCallData["contactId"]
749         if recentCallData["name"]:
750                 header = recentCallData["name"]
751         elif recentCallData["prettyNumber"]:
752                 header = recentCallData["prettyNumber"]
753         elif recentCallData["location"]:
754                 header = recentCallData["location"]
755         else:
756                 header = "Unknown"
757
758         number = recentCallData["number"]
759         relTime = recentCallData["relTime"]
760         action = recentCallData["action"]
761         return contactId, header, number, relTime, action
762
763
764 def decorate_message(messageData):
765         contactId = messageData["contactId"]
766         exactTime = messageData["time"]
767         if messageData["name"]:
768                 header = messageData["name"]
769         elif messageData["prettyNumber"]:
770                 header = messageData["prettyNumber"]
771         else:
772                 header = "Unknown"
773         number = messageData["number"]
774         relativeTime = messageData["relTime"]
775
776         messageParts = list(messageData["messageParts"])
777         if len(messageParts) == 0:
778                 messages = ("No Transcription", )
779         elif len(messageParts) == 1:
780                 messages = (messageParts[0][1], )
781         else:
782                 messages = [
783                         "<b>%s</b>: %s" % (messagePart[0], messagePart[1])
784                         for messagePart in messageParts
785                 ]
786
787         decoratedResults = contactId, header, number, relativeTime, messages
788         return decoratedResults
789
790
791 def test_backend(username, password):
792         backend = GVoiceBackend()
793         print "Authenticated: ", backend.is_authed()
794         if not backend.is_authed():
795                 print "Login?: ", backend.login(username, password)
796         print "Authenticated: ", backend.is_authed()
797         print "Is Dnd: ", backend.is_dnd()
798         #print "Setting Dnd", backend.set_dnd(True)
799         #print "Is Dnd: ", backend.is_dnd()
800         #print "Setting Dnd", backend.set_dnd(False)
801         #print "Is Dnd: ", backend.is_dnd()
802
803         #print "Token: ", backend._token
804         #print "Account: ", backend.get_account_number()
805         #print "Callback: ", backend.get_callback_number()
806         #print "All Callback: ",
807         #import pprint
808         #pprint.pprint(backend.get_callback_numbers())
809
810         #print "Recent: "
811         #for data in backend.get_recent():
812         #       pprint.pprint(data)
813         #for data in sort_messages(backend.get_recent()):
814         #       pprint.pprint(decorate_recent(data))
815         #pprint.pprint(list(backend.get_recent()))
816
817         #print "Contacts: ",
818         #for contact in backend.get_contacts():
819         #       print contact
820         #       pprint.pprint(list(backend.get_contact_details(contact[0])))
821
822         #print "Messages: ",
823         #for message in backend.get_messages():
824         #       pprint.pprint(message)
825         #for message in sort_messages(backend.get_messages()):
826         #       pprint.pprint(decorate_message(message))
827
828         return backend
829
830
831 def grab_debug_info(username, password):
832         cookieFile = os.path.join(".", "raw_cookies.txt")
833         try:
834                 os.remove(cookieFile)
835         except OSError:
836                 pass
837
838         backend = GVoiceBackend(cookieFile)
839         browser = backend._browser
840
841         _TEST_WEBPAGES = [
842                 ("forward", backend._forwardURL),
843                 ("token", backend._tokenURL),
844                 ("login", backend._loginURL),
845                 ("isdnd", backend._isDndURL),
846                 ("contacts", backend._contactsURL),
847
848                 ("account", backend._XML_ACCOUNT_URL),
849                 ("voicemail", backend._XML_VOICEMAIL_URL),
850                 ("sms", backend._XML_SMS_URL),
851
852                 ("recent", backend._XML_RECENT_URL),
853                 ("placed", backend._XML_PLACED_URL),
854                 ("recieved", backend._XML_RECEIVED_URL),
855                 ("missed", backend._XML_MISSED_URL),
856         ]
857
858         # Get Pages
859         print "Grabbing pre-login pages"
860         for name, url in _TEST_WEBPAGES:
861                 try:
862                         page = browser.download(url)
863                 except StandardError, e:
864                         print e.message
865                         continue
866                 print "\tWriting to file"
867                 with open("not_loggedin_%s.txt" % name, "w") as f:
868                         f.write(page)
869
870         # Login
871         print "Attempting login"
872         galxToken = backend._get_token()
873         loginSuccessOrFailurePage = backend._login(username, password, galxToken)
874         with open("loggingin.txt", "w") as f:
875                 print "\tWriting to file"
876                 f.write(loginSuccessOrFailurePage)
877         try:
878                 backend._grab_account_info(loginSuccessOrFailurePage)
879         except Exception:
880                 # Retry in case the redirect failed
881                 # luckily is_authed does everything we need for a retry
882                 loggedIn = backend.is_authed(True)
883                 if not loggedIn:
884                         raise
885
886         # Get Pages
887         print "Grabbing post-login pages"
888         for name, url in _TEST_WEBPAGES:
889                 try:
890                         page = browser.download(url)
891                 except StandardError, e:
892                         print e.message
893                         continue
894                 print "\tWriting to file"
895                 with open("loggedin_%s.txt" % name, "w") as f:
896                         f.write(page)
897
898         # Cookies
899         browser.cookies.save()
900         print "\tWriting cookies to file"
901         with open("cookies.txt", "w") as f:
902                 f.writelines(
903                         "%s: %s\n" % (c.name, c.value)
904                         for c in browser.cookies
905                 )
906
907
908 if __name__ == "__main__":
909         import sys
910         logging.basicConfig(level=logging.DEBUG)
911         if True:
912                 grab_debug_info(sys.argv[1], sys.argv[2])
913         else:
914                 test_backend(sys.argv[1], sys.argv[2])