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 galxToken = galxTokens.group(1)
163 loginPostData = urllib.urlencode({
166 'service': "grandcentral",
169 "PersistentCookie": "yes",
171 "continue": self._forwardURL,
175 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
176 except urllib2.URLError, e:
177 _moduleLogger.exception("Translating error: %s" % str(e))
178 raise NetworkError("%s is not accesible" % self._loginURL)
181 self._grab_account_info(loginSuccessOrFailurePage)
183 _moduleLogger.exception(str(e))
186 self._browser.cookies.save()
187 self._lastAuthed = time.time()
191 self._lastAuthed = 0.0
192 self._browser.cookies.clear()
193 self._browser.cookies.save()
195 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
196 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
198 def dial(self, number):
200 This is the main function responsible for initating the callback
202 number = self._send_validation(number)
204 clickToCallData = urllib.urlencode({
206 "phone": self._callbackNumber,
207 "_rnr_se": self._token,
210 'Referer' : 'https://google.com/voice/m/callsms',
212 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
213 except urllib2.URLError, e:
214 _moduleLogger.exception("Translating error: %s" % str(e))
215 raise NetworkError("%s is not accesible" % self._clicktocallURL)
217 if self._gvDialingStrRe.search(callSuccessPage) is None:
218 raise RuntimeError("Google Voice returned an error")
222 _sendSmsURL = "https://www.google.com/voice/m/sendsms"
224 def send_sms(self, number, message):
225 number = self._send_validation(number)
227 smsData = urllib.urlencode({
230 "_rnr_se": self._token,
235 'Referer' : 'https://google.com/voice/m/sms',
237 smsSuccessPage = self._browser.download(self._sendSmsURL, smsData, None, otherData)
238 except urllib2.URLError, e:
239 _moduleLogger.exception("Translating error: %s" % str(e))
240 raise NetworkError("%s is not accesible" % self._sendSmsURL)
244 _validateRe = re.compile("^[0-9]{10,}$")
246 def is_valid_syntax(self, number):
248 @returns If This number be called ( syntax validation only )
250 return self._validateRe.match(number) is not None
252 def get_account_number(self):
254 @returns The GoogleVoice phone number
256 return self._accountNum
258 def get_callback_numbers(self):
260 @returns a dictionary mapping call back numbers to descriptions
261 @note These results are cached for 30 minutes.
263 if not self.is_authed():
265 return self._callbackNumbers
267 _setforwardURL = "https://www.google.com//voice/m/setphone"
269 def set_callback_number(self, callbacknumber):
271 Set the number that GoogleVoice calls
272 @param callbacknumber should be a proper 10 digit number
274 self._callbackNumber = callbacknumber
277 def get_callback_number(self):
279 @returns Current callback number or None
281 return self._callbackNumber
283 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
284 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
285 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
286 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
288 def get_recent(self):
290 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
293 ("Received", self._receivedCallsURL),
294 ("Missed", self._missedCallsURL),
295 ("Placed", self._placedCallsURL),
298 flatXml = self._browser.download(url)
299 except urllib2.URLError, e:
300 _moduleLogger.exception("Translating error: %s" % str(e))
301 raise NetworkError("%s is not accesible" % url)
303 allRecentHtml = self._grab_html(flatXml)
304 allRecentData = self._parse_voicemail(allRecentHtml)
305 for recentCallData in allRecentData:
306 recentCallData["action"] = action
309 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
310 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
311 _contactsURL = "https://www.google.com/voice/mobile/contacts"
313 def get_contacts(self):
315 @returns Iterable of (contact id, contact name)
317 contactsPagesUrls = [self._contactsURL]
318 for contactsPageUrl in contactsPagesUrls:
320 contactsPage = self._browser.download(contactsPageUrl)
321 except urllib2.URLError, e:
322 _moduleLogger.exception("Translating error: %s" % str(e))
323 raise NetworkError("%s is not accesible" % contactsPageUrl)
324 for contact_match in self._contactsRe.finditer(contactsPage):
325 contactId = contact_match.group(1)
326 contactName = saxutils.unescape(contact_match.group(2))
327 contact = contactId, contactName
330 next_match = self._contactsNextRe.match(contactsPage)
331 if next_match is not None:
332 newContactsPageUrl = self._contactsURL + next_match.group(1)
333 contactsPagesUrls.append(newContactsPageUrl)
335 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9+\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
336 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
338 def get_contact_details(self, contactId):
340 @returns Iterable of (Phone Type, Phone Number)
343 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
344 except urllib2.URLError, e:
345 _moduleLogger.exception("Translating error: %s" % str(e))
346 raise NetworkError("%s is not accesible" % self._contactDetailURL)
348 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
349 phoneNumber = detail_match.group(1)
350 phoneType = saxutils.unescape(detail_match.group(2))
351 yield (phoneType, phoneNumber)
353 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
354 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
356 def get_messages(self):
358 voicemailPage = self._browser.download(self._voicemailURL)
359 except urllib2.URLError, e:
360 _moduleLogger.exception("Translating error: %s" % str(e))
361 raise NetworkError("%s is not accesible" % self._voicemailURL)
362 voicemailHtml = self._grab_html(voicemailPage)
363 parsedVoicemail = self._parse_voicemail(voicemailHtml)
364 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
367 smsPage = self._browser.download(self._smsURL)
368 except urllib2.URLError, e:
369 _moduleLogger.exception("Translating error: %s" % str(e))
370 raise NetworkError("%s is not accesible" % self._smsURL)
371 smsHtml = self._grab_html(smsPage)
372 parsedSms = self._parse_sms(smsHtml)
373 decoratedSms = self._decorate_sms(parsedSms)
375 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
378 def clear_caches(self):
381 def get_addressbooks(self):
383 @returns Iterable of (Address Book Factory, Book Id, Book Name)
387 def open_addressbook(self, bookId):
391 def contact_source_short_name(contactId):
396 return "Google Voice"
398 def _grab_json(self, flatXml):
399 xmlTree = ElementTree.fromstring(flatXml)
400 jsonElement = xmlTree.getchildren()[0]
401 flatJson = jsonElement.text
402 jsonTree = parse_json(flatJson)
405 def _grab_html(self, flatXml):
406 xmlTree = ElementTree.fromstring(flatXml)
407 htmlElement = xmlTree.getchildren()[1]
408 flatHtml = htmlElement.text
411 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
412 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
413 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
414 _forwardURL = "https://www.google.com/voice/mobile/phones"
416 def _grab_account_info(self, page):
417 tokenGroup = self._tokenRe.search(page)
418 if tokenGroup is None:
419 raise RuntimeError("Could not extract authentication token from GoogleVoice")
420 self._token = tokenGroup.group(1)
422 anGroup = self._accountNumRe.search(page)
423 if anGroup is not None:
424 self._accountNum = anGroup.group(1)
426 _moduleLogger.debug("Could not extract account number from GoogleVoice")
428 self._callbackNumbers = {}
429 for match in self._callbackRe.finditer(page):
430 callbackNumber = match.group(2)
431 callbackName = match.group(1)
432 self._callbackNumbers[callbackNumber] = callbackName
434 def _send_validation(self, number):
435 if not self.is_valid_syntax(number):
436 raise ValueError('Number is not valid: "%s"' % number)
437 elif not self.is_authed():
438 raise RuntimeError("Not Authenticated")
440 if len(number) == 11 and number[0] == 1:
441 # Strip leading 1 from 11 digit dialing
445 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
446 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
447 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
448 _voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
449 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
450 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
451 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
452 _messagesContactID = re.compile(r"""<a class=".*?gc-message-name-link.*?">.*?</a>\s*?<span .*?>(.*?)</span>""", re.MULTILINE)
453 #_voicemailMessageRegex = re.compile(r"""<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
454 #_voicemailMessageRegex = re.compile(r"""<a .*? class="gc-message-mni">(.*?)</a>""", re.MULTILINE)
455 _voicemailMessageRegex = re.compile(r"""(<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>|<a .*? class="gc-message-mni">(.*?)</a>)""", re.MULTILINE)
458 def _interpret_voicemail_regex(group):
459 quality, content, number = group.group(2), group.group(3), group.group(4)
460 if quality is not None and content is not None:
461 return quality, content
462 elif number is not None:
463 return "high", number
465 def _parse_voicemail(self, voicemailHtml):
466 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
467 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
468 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
469 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
470 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
471 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
472 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
473 locationGroup = self._voicemailLocationRegex.search(messageHtml)
474 location = locationGroup.group(1).strip() if locationGroup else ""
476 nameGroup = self._voicemailNameRegex.search(messageHtml)
477 name = nameGroup.group(1).strip() if nameGroup else ""
478 numberGroup = self._voicemailNumberRegex.search(messageHtml)
479 number = numberGroup.group(1).strip() if numberGroup else ""
480 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
481 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
482 contactIdGroup = self._messagesContactID.search(messageHtml)
483 contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
485 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
487 self._interpret_voicemail_regex(group)
488 for group in messageGroups
489 ) if messageGroups else ()
492 "id": messageId.strip(),
493 "contactId": contactId,
496 "relTime": relativeTime,
497 "prettyNumber": prettyNumber,
499 "location": location,
500 "messageParts": messageParts,
503 def _decorate_voicemail(self, parsedVoicemails):
504 messagePartFormat = {
509 for voicemailData in parsedVoicemails:
511 messagePartFormat[quality] % part
512 for (quality, part) in voicemailData["messageParts"]
515 message = "No Transcription"
516 whoFrom = voicemailData["name"]
517 when = voicemailData["time"]
518 voicemailData["messageParts"] = ((whoFrom, message, when), )
521 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
522 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
523 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
525 def _parse_sms(self, smsHtml):
526 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
527 for messageId, messageHtml in itergroup(splitSms[1:], 2):
528 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
529 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
530 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
531 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
532 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
534 nameGroup = self._voicemailNameRegex.search(messageHtml)
535 name = nameGroup.group(1).strip() if nameGroup else ""
536 numberGroup = self._voicemailNumberRegex.search(messageHtml)
537 number = numberGroup.group(1).strip() if numberGroup else ""
538 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
539 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
540 contactIdGroup = self._messagesContactID.search(messageHtml)
541 contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
543 fromGroups = self._smsFromRegex.finditer(messageHtml)
544 fromParts = (group.group(1).strip() for group in fromGroups)
545 textGroups = self._smsTextRegex.finditer(messageHtml)
546 textParts = (group.group(1).strip() for group in textGroups)
547 timeGroups = self._smsTimeRegex.finditer(messageHtml)
548 timeParts = (group.group(1).strip() for group in timeGroups)
550 messageParts = itertools.izip(fromParts, textParts, timeParts)
553 "id": messageId.strip(),
554 "contactId": contactId,
557 "relTime": relativeTime,
558 "prettyNumber": prettyNumber,
561 "messageParts": messageParts,
564 def _decorate_sms(self, parsedTexts):
568 def set_sane_callback(backend):
570 Try to set a sane default callback number on these preferences
571 1) 1747 numbers ( Gizmo )
572 2) anything with gizmo in the name
573 3) anything with computer in the name
576 numbers = backend.get_callback_numbers()
578 priorityOrderedCriteria = [
586 for numberCriteria, descriptionCriteria in priorityOrderedCriteria:
587 for number, description in numbers.iteritems():
588 if numberCriteria is not None and re.compile(numberCriteria).match(number) is None:
590 if descriptionCriteria is not None and re.compile(descriptionCriteria).match(description) is None:
592 backend.set_callback_number(number)
596 def sort_messages(allMessages):
597 sortableAllMessages = [
598 (message["time"], message)
599 for message in allMessages
601 sortableAllMessages.sort(reverse=True)
604 for (exactTime, message) in sortableAllMessages
608 def decorate_recent(recentCallData):
610 @returns (personsName, phoneNumber, date, action)
612 if recentCallData["name"]:
613 header = recentCallData["name"]
614 elif recentCallData["prettyNumber"]:
615 header = recentCallData["prettyNumber"]
616 elif recentCallData["location"]:
617 header = recentCallData["location"]
621 number = recentCallData["number"]
622 relTime = recentCallData["relTime"]
623 action = recentCallData["action"]
624 return header, number, relTime, action
627 def decorate_message(messageData):
628 exactTime = messageData["time"]
629 if messageData["name"]:
630 header = messageData["name"]
631 elif messageData["prettyNumber"]:
632 header = messageData["prettyNumber"]
635 number = messageData["number"]
636 relativeTime = messageData["relTime"]
638 messageParts = list(messageData["messageParts"])
639 if len(messageParts) == 0:
640 messages = ("No Transcription", )
641 elif len(messageParts) == 1:
642 messages = (messageParts[0][1], )
645 "<b>%s</b>: %s" % (messagePart[0], messagePart[1])
646 for messagePart in messageParts
649 decoratedResults = header, number, relativeTime, messages
650 return decoratedResults
653 def test_backend(username, password):
655 print "Authenticated: ", backend.is_authed()
656 if not backend.is_authed():
657 print "Login?: ", backend.login(username, password)
658 print "Authenticated: ", backend.is_authed()
660 #print "Token: ", backend._token
661 #print "Account: ", backend.get_account_number()
662 #print "Callback: ", backend.get_callback_number()
663 #print "All Callback: ",
665 #pprint.pprint(backend.get_callback_numbers())
668 #for data in backend.get_recent():
669 # pprint.pprint(data)
670 #for data in sort_messages(backend.get_recent()):
671 # pprint.pprint(decorate_recent(data))
672 #pprint.pprint(list(backend.get_recent()))
675 #for contact in backend.get_contacts():
677 # pprint.pprint(list(backend.get_contact_details(contact[0])))
680 #for message in backend.get_messages():
681 # pprint.pprint(message)
682 #for message in sort_messages(backend.get_messages()):
683 # pprint.pprint(decorate_message(message))
688 if __name__ == "__main__":
690 logging.basicConfig(level=logging.DEBUG)
691 test_backend(sys.argv[1], sys.argv[2])