4 DialCentral - Front end for Google's GoogleVoice service.
5 Copyright (C) 2008 Eric Warnke ericew AT gmail DOT com
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.
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.
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
21 Google Voice backend code
24 http://thatsmith.com/2009/03/google-voice-addon-for-firefox/
25 http://posttopic.com/topic/google-voice-add-on-development
37 from xml.sax import saxutils
39 from xml.etree import ElementTree
49 _moduleLogger = logging.getLogger("gvoice.dialer")
50 _TRUE_REGEX = re.compile("true")
51 _FALSE_REGEX = re.compile("false")
55 s = _TRUE_REGEX.sub("True", s)
56 s = _FALSE_REGEX.sub("False", s)
57 return eval(s, {}, {})
60 if simplejson is None:
61 def parse_json(flattened):
62 return safe_eval(flattened)
64 def parse_json(flattened):
65 return simplejson.loads(flattened)
68 def itergroup(iterator, count, padValue = None):
70 Iterate in groups of 'count' values. If there
71 aren't enough values, the last result is padded with
74 >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
78 >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
82 >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3):
87 >>> for val in itergroup("123456", 3):
91 >>> for val in itergroup("123456", 3):
92 ... print repr("".join(val))
96 paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1))
97 nIterators = (paddedIterator, ) * count
98 return itertools.izip(*nIterators)
101 class NetworkError(RuntimeError):
105 class GVDialer(object):
107 This class encapsulates all of the knowledge necessary to interact with the GoogleVoice servers
108 the functions include login, setting up a callback number, and initalting a callback
111 def __init__(self, cookieFile = None):
112 # Important items in this function are the setup of the browser emulation and cookie file
113 self._browser = browser_emu.MozillaEmulator(1)
114 if cookieFile is None:
115 cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
116 self._browser.cookies.filename = cookieFile
117 if os.path.isfile(cookieFile):
118 self._browser.cookies.load()
121 self._accountNum = ""
122 self._lastAuthed = 0.0
123 self._callbackNumber = ""
124 self._callbackNumbers = {}
126 def is_authed(self, force = False):
128 Attempts to detect a current session
129 @note Once logged in try not to reauth more than once a minute.
130 @returns If authenticated
132 if (time.time() - self._lastAuthed) < 120 and not force:
136 page = self._browser.download(self._forwardURL)
137 self._grab_account_info(page)
139 _moduleLogger.exception(str(e))
142 self._browser.cookies.save()
143 self._lastAuthed = time.time()
146 _tokenURL = "http://www.google.com/voice/m"
147 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
148 _galxRe = re.compile(r"""<input.*?name="GALX".*?value="(.*?)".*?/>""", re.MULTILINE | re.DOTALL)
150 def login(self, username, password):
152 Attempt to login to GoogleVoice
153 @returns Whether login was successful or not
158 tokenPage = self._browser.download(self._tokenURL)
159 except urllib2.URLError, e:
160 _moduleLogger.exception("Translating error: %s" % str(e))
161 raise NetworkError("%s is not accesible" % self._loginURL)
162 galxTokens = self._galxRe.search(tokenPage)
163 if galxTokens is not None:
164 galxToken = galxTokens.group(1)
167 _moduleLogger.debug("Could not grab GALX token")
169 loginPostData = urllib.urlencode({
172 'service': "grandcentral",
175 "PersistentCookie": "yes",
177 "continue": self._forwardURL,
181 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
182 except urllib2.URLError, e:
183 _moduleLogger.exception("Translating error: %s" % str(e))
184 raise NetworkError("%s is not accesible" % self._loginURL)
187 self._grab_account_info(loginSuccessOrFailurePage)
189 _moduleLogger.exception(str(e))
192 self._browser.cookies.save()
193 self._lastAuthed = time.time()
197 self._lastAuthed = 0.0
198 self._browser.cookies.clear()
199 self._browser.cookies.save()
201 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
202 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
204 def dial(self, number):
206 This is the main function responsible for initating the callback
208 number = self._send_validation(number)
210 clickToCallData = urllib.urlencode({
212 "phone": self._callbackNumber,
213 "_rnr_se": self._token,
216 'Referer' : 'https://google.com/voice/m/callsms',
218 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
219 except urllib2.URLError, e:
220 _moduleLogger.exception("Translating error: %s" % str(e))
221 raise NetworkError("%s is not accesible" % self._clicktocallURL)
223 if self._gvDialingStrRe.search(callSuccessPage) is None:
224 raise RuntimeError("Google Voice returned an error")
228 _sendSmsURL = "https://www.google.com/voice/m/sendsms"
230 def send_sms(self, number, message):
231 number = self._send_validation(number)
233 smsData = urllib.urlencode({
236 "_rnr_se": self._token,
241 'Referer' : 'https://google.com/voice/m/sms',
243 smsSuccessPage = self._browser.download(self._sendSmsURL, smsData, None, otherData)
244 except urllib2.URLError, e:
245 _moduleLogger.exception("Translating error: %s" % str(e))
246 raise NetworkError("%s is not accesible" % self._sendSmsURL)
250 _validateRe = re.compile("^[0-9]{10,}$")
252 def is_valid_syntax(self, number):
254 @returns If This number be called ( syntax validation only )
256 return self._validateRe.match(number) is not None
258 def get_account_number(self):
260 @returns The GoogleVoice phone number
262 return self._accountNum
264 def get_callback_numbers(self):
266 @returns a dictionary mapping call back numbers to descriptions
267 @note These results are cached for 30 minutes.
269 if not self.is_authed():
271 return self._callbackNumbers
273 _setforwardURL = "https://www.google.com//voice/m/setphone"
275 def set_callback_number(self, callbacknumber):
277 Set the number that GoogleVoice calls
278 @param callbacknumber should be a proper 10 digit number
280 self._callbackNumber = callbacknumber
283 def get_callback_number(self):
285 @returns Current callback number or None
287 return self._callbackNumber
289 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
290 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
291 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
292 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
294 def get_recent(self):
296 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
299 ("Received", self._receivedCallsURL),
300 ("Missed", self._missedCallsURL),
301 ("Placed", self._placedCallsURL),
304 flatXml = self._browser.download(url)
305 except urllib2.URLError, e:
306 _moduleLogger.exception("Translating error: %s" % str(e))
307 raise NetworkError("%s is not accesible" % url)
309 allRecentHtml = self._grab_html(flatXml)
310 allRecentData = self._parse_voicemail(allRecentHtml)
311 for recentCallData in allRecentData:
312 recentCallData["action"] = action
315 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
316 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
317 _contactsURL = "https://www.google.com/voice/mobile/contacts"
319 def get_contacts(self):
321 @returns Iterable of (contact id, contact name)
323 contactsPagesUrls = [self._contactsURL]
324 for contactsPageUrl in contactsPagesUrls:
326 contactsPage = self._browser.download(contactsPageUrl)
327 except urllib2.URLError, e:
328 _moduleLogger.exception("Translating error: %s" % str(e))
329 raise NetworkError("%s is not accesible" % contactsPageUrl)
330 for contact_match in self._contactsRe.finditer(contactsPage):
331 contactId = contact_match.group(1)
332 contactName = saxutils.unescape(contact_match.group(2))
333 contact = contactId, contactName
336 next_match = self._contactsNextRe.match(contactsPage)
337 if next_match is not None:
338 newContactsPageUrl = self._contactsURL + next_match.group(1)
339 contactsPagesUrls.append(newContactsPageUrl)
341 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9+\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
342 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
344 def get_contact_details(self, contactId):
346 @returns Iterable of (Phone Type, Phone Number)
349 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
350 except urllib2.URLError, e:
351 _moduleLogger.exception("Translating error: %s" % str(e))
352 raise NetworkError("%s is not accesible" % self._contactDetailURL)
354 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
355 phoneNumber = detail_match.group(1)
356 phoneType = saxutils.unescape(detail_match.group(2))
357 yield (phoneType, phoneNumber)
359 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
360 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
362 def get_messages(self):
364 voicemailPage = self._browser.download(self._voicemailURL)
365 except urllib2.URLError, e:
366 _moduleLogger.exception("Translating error: %s" % str(e))
367 raise NetworkError("%s is not accesible" % self._voicemailURL)
368 voicemailHtml = self._grab_html(voicemailPage)
369 parsedVoicemail = self._parse_voicemail(voicemailHtml)
370 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
373 smsPage = self._browser.download(self._smsURL)
374 except urllib2.URLError, e:
375 _moduleLogger.exception("Translating error: %s" % str(e))
376 raise NetworkError("%s is not accesible" % self._smsURL)
377 smsHtml = self._grab_html(smsPage)
378 parsedSms = self._parse_sms(smsHtml)
379 decoratedSms = self._decorate_sms(parsedSms)
381 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
384 def clear_caches(self):
387 def get_addressbooks(self):
389 @returns Iterable of (Address Book Factory, Book Id, Book Name)
393 def open_addressbook(self, bookId):
397 def contact_source_short_name(contactId):
402 return "Google Voice"
404 def _grab_json(self, flatXml):
405 xmlTree = ElementTree.fromstring(flatXml)
406 jsonElement = xmlTree.getchildren()[0]
407 flatJson = jsonElement.text
408 jsonTree = parse_json(flatJson)
411 def _grab_html(self, flatXml):
412 xmlTree = ElementTree.fromstring(flatXml)
413 htmlElement = xmlTree.getchildren()[1]
414 flatHtml = htmlElement.text
417 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
418 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
419 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
420 _forwardURL = "https://www.google.com/voice/mobile/phones"
422 def _grab_account_info(self, page):
423 tokenGroup = self._tokenRe.search(page)
424 if tokenGroup is None:
425 raise RuntimeError("Could not extract authentication token from GoogleVoice")
426 self._token = tokenGroup.group(1)
428 anGroup = self._accountNumRe.search(page)
429 if anGroup is not None:
430 self._accountNum = anGroup.group(1)
432 _moduleLogger.debug("Could not extract account number from GoogleVoice")
434 self._callbackNumbers = {}
435 for match in self._callbackRe.finditer(page):
436 callbackNumber = match.group(2)
437 callbackName = match.group(1)
438 self._callbackNumbers[callbackNumber] = callbackName
439 if len(self._callbackNumbers) == 0:
440 _moduleLogger.debug("Could not extract callback numbers from GoogleVoice (the troublesome page follows):\n%s" % page)
442 def _send_validation(self, number):
443 if not self.is_valid_syntax(number):
444 raise ValueError('Number is not valid: "%s"' % number)
445 elif not self.is_authed():
446 raise RuntimeError("Not Authenticated")
448 if len(number) == 11 and number[0] == 1:
449 # Strip leading 1 from 11 digit dialing
453 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
454 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
455 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
456 _voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
457 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
458 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
459 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
460 _messagesContactID = re.compile(r"""<a class=".*?gc-message-name-link.*?">.*?</a>\s*?<span .*?>(.*?)</span>""", re.MULTILINE)
461 #_voicemailMessageRegex = re.compile(r"""<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
462 #_voicemailMessageRegex = re.compile(r"""<a .*? class="gc-message-mni">(.*?)</a>""", re.MULTILINE)
463 _voicemailMessageRegex = re.compile(r"""(<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>|<a .*? class="gc-message-mni">(.*?)</a>)""", re.MULTILINE)
466 def _interpret_voicemail_regex(group):
467 quality, content, number = group.group(2), group.group(3), group.group(4)
468 if quality is not None and content is not None:
469 return quality, content
470 elif number is not None:
471 return "high", number
473 def _parse_voicemail(self, voicemailHtml):
474 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
475 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
476 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
477 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
478 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
479 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
480 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
481 locationGroup = self._voicemailLocationRegex.search(messageHtml)
482 location = locationGroup.group(1).strip() if locationGroup else ""
484 nameGroup = self._voicemailNameRegex.search(messageHtml)
485 name = nameGroup.group(1).strip() if nameGroup else ""
486 numberGroup = self._voicemailNumberRegex.search(messageHtml)
487 number = numberGroup.group(1).strip() if numberGroup else ""
488 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
489 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
490 contactIdGroup = self._messagesContactID.search(messageHtml)
491 contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
493 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
495 self._interpret_voicemail_regex(group)
496 for group in messageGroups
497 ) if messageGroups else ()
500 "id": messageId.strip(),
501 "contactId": contactId,
504 "relTime": relativeTime,
505 "prettyNumber": prettyNumber,
507 "location": location,
508 "messageParts": messageParts,
511 def _decorate_voicemail(self, parsedVoicemails):
512 messagePartFormat = {
517 for voicemailData in parsedVoicemails:
519 messagePartFormat[quality] % part
520 for (quality, part) in voicemailData["messageParts"]
523 message = "No Transcription"
524 whoFrom = voicemailData["name"]
525 when = voicemailData["time"]
526 voicemailData["messageParts"] = ((whoFrom, message, when), )
529 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
530 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
531 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
533 def _parse_sms(self, smsHtml):
534 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
535 for messageId, messageHtml in itergroup(splitSms[1:], 2):
536 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
537 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
538 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
539 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
540 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
542 nameGroup = self._voicemailNameRegex.search(messageHtml)
543 name = nameGroup.group(1).strip() if nameGroup else ""
544 numberGroup = self._voicemailNumberRegex.search(messageHtml)
545 number = numberGroup.group(1).strip() if numberGroup else ""
546 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
547 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
548 contactIdGroup = self._messagesContactID.search(messageHtml)
549 contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
551 fromGroups = self._smsFromRegex.finditer(messageHtml)
552 fromParts = (group.group(1).strip() for group in fromGroups)
553 textGroups = self._smsTextRegex.finditer(messageHtml)
554 textParts = (group.group(1).strip() for group in textGroups)
555 timeGroups = self._smsTimeRegex.finditer(messageHtml)
556 timeParts = (group.group(1).strip() for group in timeGroups)
558 messageParts = itertools.izip(fromParts, textParts, timeParts)
561 "id": messageId.strip(),
562 "contactId": contactId,
565 "relTime": relativeTime,
566 "prettyNumber": prettyNumber,
569 "messageParts": messageParts,
572 def _decorate_sms(self, parsedTexts):
576 def set_sane_callback(backend):
578 Try to set a sane default callback number on these preferences
579 1) 1747 numbers ( Gizmo )
580 2) anything with gizmo in the name
581 3) anything with computer in the name
584 numbers = backend.get_callback_numbers()
586 priorityOrderedCriteria = [
594 for numberCriteria, descriptionCriteria in priorityOrderedCriteria:
595 for number, description in numbers.iteritems():
596 if numberCriteria is not None and re.compile(numberCriteria).match(number) is None:
598 if descriptionCriteria is not None and re.compile(descriptionCriteria).match(description) is None:
600 backend.set_callback_number(number)
604 def sort_messages(allMessages):
605 sortableAllMessages = [
606 (message["time"], message)
607 for message in allMessages
609 sortableAllMessages.sort(reverse=True)
612 for (exactTime, message) in sortableAllMessages
616 def decorate_recent(recentCallData):
618 @returns (personsName, phoneNumber, date, action)
620 contactId = recentCallData["contactId"]
621 if recentCallData["name"]:
622 header = recentCallData["name"]
623 elif recentCallData["prettyNumber"]:
624 header = recentCallData["prettyNumber"]
625 elif recentCallData["location"]:
626 header = recentCallData["location"]
630 number = recentCallData["number"]
631 relTime = recentCallData["relTime"]
632 action = recentCallData["action"]
633 return contactId, header, number, relTime, action
636 def decorate_message(messageData):
637 contactId = messageData["contactId"]
638 exactTime = messageData["time"]
639 if messageData["name"]:
640 header = messageData["name"]
641 elif messageData["prettyNumber"]:
642 header = messageData["prettyNumber"]
645 number = messageData["number"]
646 relativeTime = messageData["relTime"]
648 messageParts = list(messageData["messageParts"])
649 if len(messageParts) == 0:
650 messages = ("No Transcription", )
651 elif len(messageParts) == 1:
652 messages = (messageParts[0][1], )
655 "<b>%s</b>: %s" % (messagePart[0], messagePart[1])
656 for messagePart in messageParts
659 decoratedResults = contactId, header, number, relativeTime, messages
660 return decoratedResults
663 def test_backend(username, password):
665 print "Authenticated: ", backend.is_authed()
666 if not backend.is_authed():
667 print "Login?: ", backend.login(username, password)
668 print "Authenticated: ", backend.is_authed()
670 #print "Token: ", backend._token
671 #print "Account: ", backend.get_account_number()
672 #print "Callback: ", backend.get_callback_number()
673 #print "All Callback: ",
675 #pprint.pprint(backend.get_callback_numbers())
678 #for data in backend.get_recent():
679 # pprint.pprint(data)
680 #for data in sort_messages(backend.get_recent()):
681 # pprint.pprint(decorate_recent(data))
682 #pprint.pprint(list(backend.get_recent()))
685 #for contact in backend.get_contacts():
687 # pprint.pprint(list(backend.get_contact_details(contact[0])))
690 for message in backend.get_messages():
691 message["messageParts"] = list(message["messageParts"])
692 pprint.pprint(message)
693 #for message in sort_messages(backend.get_messages()):
694 # pprint.pprint(decorate_message(message))
699 if __name__ == "__main__":
701 logging.basicConfig(level=logging.DEBUG)
702 test_backend(sys.argv[1], sys.argv[2])