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.backend")
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 GVoiceBackend(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 _grab_json(self, flatXml):
379 xmlTree = ElementTree.fromstring(flatXml)
380 jsonElement = xmlTree.getchildren()[0]
381 flatJson = jsonElement.text
382 jsonTree = parse_json(flatJson)
385 def _grab_html(self, flatXml):
386 xmlTree = ElementTree.fromstring(flatXml)
387 htmlElement = xmlTree.getchildren()[1]
388 flatHtml = htmlElement.text
391 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
392 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
393 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
394 _forwardURL = "https://www.google.com/voice/mobile/phones"
396 def _grab_account_info(self, page):
397 tokenGroup = self._tokenRe.search(page)
398 if tokenGroup is None:
399 raise RuntimeError("Could not extract authentication token from GoogleVoice")
400 self._token = tokenGroup.group(1)
402 anGroup = self._accountNumRe.search(page)
403 if anGroup is not None:
404 self._accountNum = anGroup.group(1)
406 _moduleLogger.debug("Could not extract account number from GoogleVoice")
408 self._callbackNumbers = {}
409 for match in self._callbackRe.finditer(page):
410 callbackNumber = match.group(2)
411 callbackName = match.group(1)
412 self._callbackNumbers[callbackNumber] = callbackName
414 def _send_validation(self, number):
415 if not self.is_valid_syntax(number):
416 raise ValueError('Number is not valid: "%s"' % number)
417 elif not self.is_authed():
418 raise RuntimeError("Not Authenticated")
420 if len(number) == 11 and number[0] == 1:
421 # Strip leading 1 from 11 digit dialing
425 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
426 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
427 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
428 _voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
429 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
430 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
431 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
432 _messagesContactID = re.compile(r"""<a class=".*?gc-message-name-link.*?">.*?</a>\s*?<span .*?>(.*?)</span>""", re.MULTILINE)
433 #_voicemailMessageRegex = re.compile(r"""<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
434 #_voicemailMessageRegex = re.compile(r"""<a .*? class="gc-message-mni">(.*?)</a>""", re.MULTILINE)
435 _voicemailMessageRegex = re.compile(r"""(<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>|<a .*? class="gc-message-mni">(.*?)</a>)""", re.MULTILINE)
438 def _interpret_voicemail_regex(group):
439 quality, content, number = group.group(2), group.group(3), group.group(4)
440 if quality is not None and content is not None:
441 return quality, content
442 elif number is not None:
443 return "high", number
445 def _parse_voicemail(self, voicemailHtml):
446 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
447 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
448 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
449 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
450 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
451 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
452 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
453 locationGroup = self._voicemailLocationRegex.search(messageHtml)
454 location = locationGroup.group(1).strip() if locationGroup else ""
456 nameGroup = self._voicemailNameRegex.search(messageHtml)
457 name = nameGroup.group(1).strip() if nameGroup else ""
458 numberGroup = self._voicemailNumberRegex.search(messageHtml)
459 number = numberGroup.group(1).strip() if numberGroup else ""
460 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
461 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
462 contactIdGroup = self._messagesContactID.search(messageHtml)
463 contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
465 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
467 self._interpret_voicemail_regex(group)
468 for group in messageGroups
469 ) if messageGroups else ()
472 "id": messageId.strip(),
473 "contactId": contactId,
476 "relTime": relativeTime,
477 "prettyNumber": prettyNumber,
479 "location": location,
480 "messageParts": messageParts,
483 def _decorate_voicemail(self, parsedVoicemails):
484 messagePartFormat = {
489 for voicemailData in parsedVoicemails:
491 messagePartFormat[quality] % part
492 for (quality, part) in voicemailData["messageParts"]
495 message = "No Transcription"
496 whoFrom = voicemailData["name"]
497 when = voicemailData["time"]
498 voicemailData["messageParts"] = ((whoFrom, message, when), )
501 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
502 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
503 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
505 def _parse_sms(self, smsHtml):
506 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
507 for messageId, messageHtml in itergroup(splitSms[1:], 2):
508 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
509 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
510 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
511 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
512 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
514 nameGroup = self._voicemailNameRegex.search(messageHtml)
515 name = nameGroup.group(1).strip() if nameGroup else ""
516 numberGroup = self._voicemailNumberRegex.search(messageHtml)
517 number = numberGroup.group(1).strip() if numberGroup else ""
518 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
519 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
520 contactIdGroup = self._messagesContactID.search(messageHtml)
521 contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
523 fromGroups = self._smsFromRegex.finditer(messageHtml)
524 fromParts = (group.group(1).strip() for group in fromGroups)
525 textGroups = self._smsTextRegex.finditer(messageHtml)
526 textParts = (group.group(1).strip() for group in textGroups)
527 timeGroups = self._smsTimeRegex.finditer(messageHtml)
528 timeParts = (group.group(1).strip() for group in timeGroups)
530 messageParts = itertools.izip(fromParts, textParts, timeParts)
533 "id": messageId.strip(),
534 "contactId": contactId,
537 "relTime": relativeTime,
538 "prettyNumber": prettyNumber,
541 "messageParts": messageParts,
544 def _decorate_sms(self, parsedTexts):
548 def set_sane_callback(backend):
550 Try to set a sane default callback number on these preferences
551 1) 1747 numbers ( Gizmo )
552 2) anything with gizmo in the name
553 3) anything with computer in the name
556 numbers = backend.get_callback_numbers()
558 priorityOrderedCriteria = [
566 for numberCriteria, descriptionCriteria in priorityOrderedCriteria:
567 for number, description in numbers.iteritems():
568 if numberCriteria is not None and re.compile(numberCriteria).match(number) is None:
570 if descriptionCriteria is not None and re.compile(descriptionCriteria).match(description) is None:
572 backend.set_callback_number(number)
576 def sort_messages(allMessages):
577 sortableAllMessages = [
578 (message["time"], message)
579 for message in allMessages
581 sortableAllMessages.sort(reverse=True)
584 for (exactTime, message) in sortableAllMessages
588 def decorate_recent(recentCallData):
590 @returns (personsName, phoneNumber, date, action)
592 if recentCallData["name"]:
593 header = recentCallData["name"]
594 elif recentCallData["prettyNumber"]:
595 header = recentCallData["prettyNumber"]
596 elif recentCallData["location"]:
597 header = recentCallData["location"]
601 number = recentCallData["number"]
602 relTime = recentCallData["relTime"]
603 action = recentCallData["action"]
604 return header, number, relTime, action
607 def decorate_message(messageData):
608 exactTime = messageData["time"]
609 if messageData["name"]:
610 header = messageData["name"]
611 elif messageData["prettyNumber"]:
612 header = messageData["prettyNumber"]
615 number = messageData["number"]
616 relativeTime = messageData["relTime"]
618 messageParts = list(messageData["messageParts"])
619 if len(messageParts) == 0:
620 messages = ("No Transcription", )
621 elif len(messageParts) == 1:
622 messages = (messageParts[0][1], )
625 "<b>%s</b>: %s" % (messagePart[0], messagePart[1])
626 for messagePart in messageParts
629 decoratedResults = header, number, relativeTime, messages
630 return decoratedResults
633 def test_backend(username, password):
634 backend = GVoiceBackend()
635 print "Authenticated: ", backend.is_authed()
636 if not backend.is_authed():
637 print "Login?: ", backend.login(username, password)
638 print "Authenticated: ", backend.is_authed()
640 #print "Token: ", backend._token
641 #print "Account: ", backend.get_account_number()
642 #print "Callback: ", backend.get_callback_number()
643 #print "All Callback: ",
645 #pprint.pprint(backend.get_callback_numbers())
648 #for data in backend.get_recent():
649 # pprint.pprint(data)
650 #for data in sort_messages(backend.get_recent()):
651 # pprint.pprint(decorate_recent(data))
652 #pprint.pprint(list(backend.get_recent()))
655 #for contact in backend.get_contacts():
657 # pprint.pprint(list(backend.get_contact_details(contact[0])))
660 #for message in backend.get_messages():
661 # pprint.pprint(message)
662 #for message in sort_messages(backend.get_messages()):
663 # pprint.pprint(decorate_message(message))
668 if __name__ == "__main__":
670 logging.basicConfig(level=logging.DEBUG)
671 test_backend(sys.argv[1], sys.argv[2])