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