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
156 tokenPage = self._browser.download(self._tokenURL)
157 except urllib2.URLError, e:
158 _moduleLogger.exception("Translating error: %s" % str(e))
159 raise NetworkError("%s is not accesible" % self._loginURL)
160 galxTokens = self._galxRe.search(tokenPage)
161 if galxTokens is not None:
162 galxToken = galxTokens.group(1)
165 _moduleLogger.debug("Could not grab GALX token")
167 loginPostData = urllib.urlencode({
170 'service': "grandcentral",
173 "PersistentCookie": "yes",
175 "continue": self._forwardURL,
179 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
180 except urllib2.URLError, e:
181 _moduleLogger.exception("Translating error: %s" % str(e))
182 raise NetworkError("%s is not accesible" % self._loginURL)
185 self._grab_account_info(loginSuccessOrFailurePage)
187 _moduleLogger.exception(str(e))
190 self._browser.cookies.save()
191 self._lastAuthed = time.time()
195 self._lastAuthed = 0.0
196 self._browser.cookies.clear()
197 self._browser.cookies.save()
199 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
200 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
202 def dial(self, number):
204 This is the main function responsible for initating the callback
206 number = self._send_validation(number)
208 clickToCallData = urllib.urlencode({
210 "phone": self._callbackNumber,
211 "_rnr_se": self._token,
214 'Referer' : 'https://google.com/voice/m/callsms',
216 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
217 except urllib2.URLError, e:
218 _moduleLogger.exception("Translating error: %s" % str(e))
219 raise NetworkError("%s is not accesible" % self._clicktocallURL)
221 if self._gvDialingStrRe.search(callSuccessPage) is None:
222 raise RuntimeError("Google Voice returned an error")
226 _sendSmsURL = "https://www.google.com/voice/m/sendsms"
228 def send_sms(self, number, message):
229 number = self._send_validation(number)
231 smsData = urllib.urlencode({
234 "_rnr_se": self._token,
239 'Referer' : 'https://google.com/voice/m/sms',
241 smsSuccessPage = self._browser.download(self._sendSmsURL, smsData, None, otherData)
242 except urllib2.URLError, e:
243 _moduleLogger.exception("Translating error: %s" % str(e))
244 raise NetworkError("%s is not accesible" % self._sendSmsURL)
248 _validateRe = re.compile("^[0-9]{10,}$")
250 def is_valid_syntax(self, number):
252 @returns If This number be called ( syntax validation only )
254 return self._validateRe.match(number) is not None
256 def get_account_number(self):
258 @returns The GoogleVoice phone number
260 return self._accountNum
262 def get_callback_numbers(self):
264 @returns a dictionary mapping call back numbers to descriptions
265 @note These results are cached for 30 minutes.
267 if not self.is_authed():
269 return self._callbackNumbers
271 _setforwardURL = "https://www.google.com//voice/m/setphone"
273 def set_callback_number(self, callbacknumber):
275 Set the number that GoogleVoice calls
276 @param callbacknumber should be a proper 10 digit number
278 self._callbackNumber = callbacknumber
281 def get_callback_number(self):
283 @returns Current callback number or None
285 return self._callbackNumber
287 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
288 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
289 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
290 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
292 def get_recent(self):
294 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
297 ("Received", self._receivedCallsURL),
298 ("Missed", self._missedCallsURL),
299 ("Placed", self._placedCallsURL),
302 flatXml = self._browser.download(url)
303 except urllib2.URLError, e:
304 _moduleLogger.exception("Translating error: %s" % str(e))
305 raise NetworkError("%s is not accesible" % url)
307 allRecentHtml = self._grab_html(flatXml)
308 allRecentData = self._parse_voicemail(allRecentHtml)
309 for recentCallData in allRecentData:
310 recentCallData["action"] = action
313 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
314 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
315 _contactsURL = "https://www.google.com/voice/mobile/contacts"
317 def get_contacts(self):
319 @returns Iterable of (contact id, contact name)
321 contactsPagesUrls = [self._contactsURL]
322 for contactsPageUrl in contactsPagesUrls:
324 contactsPage = self._browser.download(contactsPageUrl)
325 except urllib2.URLError, e:
326 _moduleLogger.exception("Translating error: %s" % str(e))
327 raise NetworkError("%s is not accesible" % contactsPageUrl)
328 for contact_match in self._contactsRe.finditer(contactsPage):
329 contactId = contact_match.group(1)
330 contactName = saxutils.unescape(contact_match.group(2))
331 contact = contactId, contactName
334 next_match = self._contactsNextRe.match(contactsPage)
335 if next_match is not None:
336 newContactsPageUrl = self._contactsURL + next_match.group(1)
337 contactsPagesUrls.append(newContactsPageUrl)
339 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9+\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
340 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
342 def get_contact_details(self, contactId):
344 @returns Iterable of (Phone Type, Phone Number)
347 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
348 except urllib2.URLError, e:
349 _moduleLogger.exception("Translating error: %s" % str(e))
350 raise NetworkError("%s is not accesible" % self._contactDetailURL)
352 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
353 phoneNumber = detail_match.group(1)
354 phoneType = saxutils.unescape(detail_match.group(2))
355 yield (phoneType, phoneNumber)
357 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
358 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
360 def get_messages(self):
362 voicemailPage = self._browser.download(self._voicemailURL)
363 except urllib2.URLError, e:
364 _moduleLogger.exception("Translating error: %s" % str(e))
365 raise NetworkError("%s is not accesible" % self._voicemailURL)
366 voicemailHtml = self._grab_html(voicemailPage)
367 parsedVoicemail = self._parse_voicemail(voicemailHtml)
368 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
371 smsPage = self._browser.download(self._smsURL)
372 except urllib2.URLError, e:
373 _moduleLogger.exception("Translating error: %s" % str(e))
374 raise NetworkError("%s is not accesible" % self._smsURL)
375 smsHtml = self._grab_html(smsPage)
376 parsedSms = self._parse_sms(smsHtml)
377 decoratedSms = self._decorate_sms(parsedSms)
379 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
382 def clear_caches(self):
385 def get_addressbooks(self):
387 @returns Iterable of (Address Book Factory, Book Id, Book Name)
391 def open_addressbook(self, bookId):
395 def contact_source_short_name(contactId):
400 return "Google Voice"
402 def _grab_json(self, flatXml):
403 xmlTree = ElementTree.fromstring(flatXml)
404 jsonElement = xmlTree.getchildren()[0]
405 flatJson = jsonElement.text
406 jsonTree = parse_json(flatJson)
409 def _grab_html(self, flatXml):
410 xmlTree = ElementTree.fromstring(flatXml)
411 htmlElement = xmlTree.getchildren()[1]
412 flatHtml = htmlElement.text
415 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
416 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
417 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
418 _forwardURL = "https://www.google.com/voice/mobile/phones"
420 def _grab_account_info(self, page):
421 tokenGroup = self._tokenRe.search(page)
422 if tokenGroup is None:
423 raise RuntimeError("Could not extract authentication token from GoogleVoice")
424 self._token = tokenGroup.group(1)
426 anGroup = self._accountNumRe.search(page)
427 if anGroup is not None:
428 self._accountNum = anGroup.group(1)
430 _moduleLogger.debug("Could not extract account number from GoogleVoice")
432 self._callbackNumbers = {}
433 for match in self._callbackRe.finditer(page):
434 callbackNumber = match.group(2)
435 callbackName = match.group(1)
436 self._callbackNumbers[callbackNumber] = callbackName
437 if len(self._callbackNumbers) == 0:
438 _moduleLogger.debug("Could not extract callback numbers from GoogleVoice (the troublesome page follows):\n%s" % page)
440 def _send_validation(self, number):
441 if not self.is_valid_syntax(number):
442 raise ValueError('Number is not valid: "%s"' % number)
443 elif not self.is_authed():
444 raise RuntimeError("Not Authenticated")
446 if len(number) == 11 and number[0] == 1:
447 # Strip leading 1 from 11 digit dialing
451 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
452 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
453 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
454 _voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
455 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
456 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
457 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
458 _messagesContactID = re.compile(r"""<a class=".*?gc-message-name-link.*?">.*?</a>\s*?<span .*?>(.*?)</span>""", re.MULTILINE)
459 #_voicemailMessageRegex = re.compile(r"""<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
460 #_voicemailMessageRegex = re.compile(r"""<a .*? class="gc-message-mni">(.*?)</a>""", re.MULTILINE)
461 _voicemailMessageRegex = re.compile(r"""(<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>|<a .*? class="gc-message-mni">(.*?)</a>)""", re.MULTILINE)
464 def _interpret_voicemail_regex(group):
465 quality, content, number = group.group(2), group.group(3), group.group(4)
466 if quality is not None and content is not None:
467 return quality, content
468 elif number is not None:
469 return "high", number
471 def _parse_voicemail(self, voicemailHtml):
472 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
473 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
474 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
475 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
476 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
477 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
478 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
479 locationGroup = self._voicemailLocationRegex.search(messageHtml)
480 location = locationGroup.group(1).strip() if locationGroup else ""
482 nameGroup = self._voicemailNameRegex.search(messageHtml)
483 name = nameGroup.group(1).strip() if nameGroup else ""
484 numberGroup = self._voicemailNumberRegex.search(messageHtml)
485 number = numberGroup.group(1).strip() if numberGroup else ""
486 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
487 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
488 contactIdGroup = self._messagesContactID.search(messageHtml)
489 contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
491 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
493 self._interpret_voicemail_regex(group)
494 for group in messageGroups
495 ) if messageGroups else ()
498 "id": messageId.strip(),
499 "contactId": contactId,
502 "relTime": relativeTime,
503 "prettyNumber": prettyNumber,
505 "location": location,
506 "messageParts": messageParts,
509 def _decorate_voicemail(self, parsedVoicemails):
510 messagePartFormat = {
515 for voicemailData in parsedVoicemails:
517 messagePartFormat[quality] % part
518 for (quality, part) in voicemailData["messageParts"]
521 message = "No Transcription"
522 whoFrom = voicemailData["name"]
523 when = voicemailData["time"]
524 voicemailData["messageParts"] = ((whoFrom, message, when), )
527 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
528 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
529 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
531 def _parse_sms(self, smsHtml):
532 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
533 for messageId, messageHtml in itergroup(splitSms[1:], 2):
534 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
535 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
536 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
537 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
538 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
540 nameGroup = self._voicemailNameRegex.search(messageHtml)
541 name = nameGroup.group(1).strip() if nameGroup else ""
542 numberGroup = self._voicemailNumberRegex.search(messageHtml)
543 number = numberGroup.group(1).strip() if numberGroup else ""
544 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
545 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
546 contactIdGroup = self._messagesContactID.search(messageHtml)
547 contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
549 fromGroups = self._smsFromRegex.finditer(messageHtml)
550 fromParts = (group.group(1).strip() for group in fromGroups)
551 textGroups = self._smsTextRegex.finditer(messageHtml)
552 textParts = (group.group(1).strip() for group in textGroups)
553 timeGroups = self._smsTimeRegex.finditer(messageHtml)
554 timeParts = (group.group(1).strip() for group in timeGroups)
556 messageParts = itertools.izip(fromParts, textParts, timeParts)
559 "id": messageId.strip(),
560 "contactId": contactId,
563 "relTime": relativeTime,
564 "prettyNumber": prettyNumber,
567 "messageParts": messageParts,
570 def _decorate_sms(self, parsedTexts):
574 def set_sane_callback(backend):
576 Try to set a sane default callback number on these preferences
577 1) 1747 numbers ( Gizmo )
578 2) anything with gizmo in the name
579 3) anything with computer in the name
582 numbers = backend.get_callback_numbers()
584 priorityOrderedCriteria = [
592 for numberCriteria, descriptionCriteria in priorityOrderedCriteria:
593 for number, description in numbers.iteritems():
594 if numberCriteria is not None and re.compile(numberCriteria).match(number) is None:
596 if descriptionCriteria is not None and re.compile(descriptionCriteria).match(description) is None:
598 backend.set_callback_number(number)
602 def sort_messages(allMessages):
603 sortableAllMessages = [
604 (message["time"], message)
605 for message in allMessages
607 sortableAllMessages.sort(reverse=True)
610 for (exactTime, message) in sortableAllMessages
614 def decorate_recent(recentCallData):
616 @returns (personsName, phoneNumber, date, action)
618 contactId = recentCallData["contactId"]
619 if recentCallData["name"]:
620 header = recentCallData["name"]
621 elif recentCallData["prettyNumber"]:
622 header = recentCallData["prettyNumber"]
623 elif recentCallData["location"]:
624 header = recentCallData["location"]
628 number = recentCallData["number"]
629 relTime = recentCallData["relTime"]
630 action = recentCallData["action"]
631 return contactId, header, number, relTime, action
634 def decorate_message(messageData):
635 contactId = messageData["contactId"]
636 exactTime = messageData["time"]
637 if messageData["name"]:
638 header = messageData["name"]
639 elif messageData["prettyNumber"]:
640 header = messageData["prettyNumber"]
643 number = messageData["number"]
644 relativeTime = messageData["relTime"]
646 messageParts = list(messageData["messageParts"])
647 if len(messageParts) == 0:
648 messages = ("No Transcription", )
649 elif len(messageParts) == 1:
650 messages = (messageParts[0][1], )
653 "<b>%s</b>: %s" % (messagePart[0], messagePart[1])
654 for messagePart in messageParts
657 decoratedResults = contactId, header, number, relativeTime, messages
658 return decoratedResults
661 def test_backend(username, password):
663 print "Authenticated: ", backend.is_authed()
664 if not backend.is_authed():
665 print "Login?: ", backend.login(username, password)
666 print "Authenticated: ", backend.is_authed()
668 #print "Token: ", backend._token
669 #print "Account: ", backend.get_account_number()
670 #print "Callback: ", backend.get_callback_number()
671 #print "All Callback: ",
673 #pprint.pprint(backend.get_callback_numbers())
676 #for data in backend.get_recent():
677 # pprint.pprint(data)
678 #for data in sort_messages(backend.get_recent()):
679 # pprint.pprint(decorate_recent(data))
680 #pprint.pprint(list(backend.get_recent()))
683 #for contact in backend.get_contacts():
685 # pprint.pprint(list(backend.get_contact_details(contact[0])))
688 for message in backend.get_messages():
689 message["messageParts"] = list(message["messageParts"])
690 pprint.pprint(message)
691 #for message in sort_messages(backend.get_messages()):
692 # pprint.pprint(decorate_message(message))
697 if __name__ == "__main__":
699 logging.basicConfig(level=logging.DEBUG)
700 test_backend(sys.argv[1], sys.argv[2])