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 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
148 def login(self, username, password):
150 Attempt to login to GoogleVoice
151 @returns Whether login was successful or not
153 loginPostData = urllib.urlencode({
156 'service': "grandcentral",
159 "PersistentCookie": "yes",
160 "continue": self._forwardURL,
164 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
165 except urllib2.URLError, e:
166 _moduleLogger.exception("Translating error: %s" % str(e))
167 raise NetworkError("%s is not accesible" % self._loginURL)
170 self._grab_account_info(loginSuccessOrFailurePage)
172 _moduleLogger.exception(str(e))
175 self._browser.cookies.save()
176 self._lastAuthed = time.time()
180 self._lastAuthed = 0.0
181 self._browser.cookies.clear()
182 self._browser.cookies.save()
184 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
185 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
187 def dial(self, number):
189 This is the main function responsible for initating the callback
191 number = self._send_validation(number)
193 clickToCallData = urllib.urlencode({
195 "phone": self._callbackNumber,
196 "_rnr_se": self._token,
199 'Referer' : 'https://google.com/voice/m/callsms',
201 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
202 except urllib2.URLError, e:
203 _moduleLogger.exception("Translating error: %s" % str(e))
204 raise NetworkError("%s is not accesible" % self._clicktocallURL)
206 if self._gvDialingStrRe.search(callSuccessPage) is None:
207 raise RuntimeError("Google Voice returned an error")
211 _sendSmsURL = "https://www.google.com/voice/m/sendsms"
213 def send_sms(self, number, message):
214 number = self._send_validation(number)
216 smsData = urllib.urlencode({
219 "_rnr_se": self._token,
224 'Referer' : 'https://google.com/voice/m/sms',
226 smsSuccessPage = self._browser.download(self._sendSmsURL, smsData, None, otherData)
227 except urllib2.URLError, e:
228 _moduleLogger.exception("Translating error: %s" % str(e))
229 raise NetworkError("%s is not accesible" % self._sendSmsURL)
233 _validateRe = re.compile("^[0-9]{10,}$")
235 def is_valid_syntax(self, number):
237 @returns If This number be called ( syntax validation only )
239 return self._validateRe.match(number) is not None
241 def get_account_number(self):
243 @returns The GoogleVoice phone number
245 return self._accountNum
247 def get_callback_numbers(self):
249 @returns a dictionary mapping call back numbers to descriptions
250 @note These results are cached for 30 minutes.
252 if not self.is_authed():
254 return self._callbackNumbers
256 _setforwardURL = "https://www.google.com//voice/m/setphone"
258 def set_callback_number(self, callbacknumber):
260 Set the number that GoogleVoice calls
261 @param callbacknumber should be a proper 10 digit number
263 self._callbackNumber = callbacknumber
266 def get_callback_number(self):
268 @returns Current callback number or None
270 return self._callbackNumber
272 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
273 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
274 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
275 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
277 def get_recent(self):
279 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
282 ("Received", self._receivedCallsURL),
283 ("Missed", self._missedCallsURL),
284 ("Placed", self._placedCallsURL),
287 flatXml = self._browser.download(url)
288 except urllib2.URLError, e:
289 _moduleLogger.exception("Translating error: %s" % str(e))
290 raise NetworkError("%s is not accesible" % url)
292 allRecentHtml = self._grab_html(flatXml)
293 allRecentData = self._parse_voicemail(allRecentHtml)
294 for recentCallData in allRecentData:
295 recentCallData["action"] = action
298 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
299 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
300 _contactsURL = "https://www.google.com/voice/mobile/contacts"
302 def get_contacts(self):
304 @returns Iterable of (contact id, contact name)
306 contactsPagesUrls = [self._contactsURL]
307 for contactsPageUrl in contactsPagesUrls:
309 contactsPage = self._browser.download(contactsPageUrl)
310 except urllib2.URLError, e:
311 _moduleLogger.exception("Translating error: %s" % str(e))
312 raise NetworkError("%s is not accesible" % contactsPageUrl)
313 for contact_match in self._contactsRe.finditer(contactsPage):
314 contactId = contact_match.group(1)
315 contactName = saxutils.unescape(contact_match.group(2))
316 contact = contactId, contactName
319 next_match = self._contactsNextRe.match(contactsPage)
320 if next_match is not None:
321 newContactsPageUrl = self._contactsURL + next_match.group(1)
322 contactsPagesUrls.append(newContactsPageUrl)
324 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9+\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
325 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
327 def get_contact_details(self, contactId):
329 @returns Iterable of (Phone Type, Phone Number)
332 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
333 except urllib2.URLError, e:
334 _moduleLogger.exception("Translating error: %s" % str(e))
335 raise NetworkError("%s is not accesible" % self._contactDetailURL)
337 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
338 phoneNumber = detail_match.group(1)
339 phoneType = saxutils.unescape(detail_match.group(2))
340 yield (phoneType, phoneNumber)
342 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
343 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
345 def get_messages(self):
347 voicemailPage = self._browser.download(self._voicemailURL)
348 except urllib2.URLError, e:
349 _moduleLogger.exception("Translating error: %s" % str(e))
350 raise NetworkError("%s is not accesible" % self._voicemailURL)
351 voicemailHtml = self._grab_html(voicemailPage)
352 parsedVoicemail = self._parse_voicemail(voicemailHtml)
353 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
356 smsPage = self._browser.download(self._smsURL)
357 except urllib2.URLError, e:
358 _moduleLogger.exception("Translating error: %s" % str(e))
359 raise NetworkError("%s is not accesible" % self._smsURL)
360 smsHtml = self._grab_html(smsPage)
361 parsedSms = self._parse_sms(smsHtml)
362 decoratedSms = self._decorate_sms(parsedSms)
364 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
367 def _grab_json(self, flatXml):
368 xmlTree = ElementTree.fromstring(flatXml)
369 jsonElement = xmlTree.getchildren()[0]
370 flatJson = jsonElement.text
371 jsonTree = parse_json(flatJson)
374 def _grab_html(self, flatXml):
375 xmlTree = ElementTree.fromstring(flatXml)
376 htmlElement = xmlTree.getchildren()[1]
377 flatHtml = htmlElement.text
380 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
381 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
382 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
383 _forwardURL = "https://www.google.com/voice/mobile/phones"
385 def _grab_account_info(self, page):
386 tokenGroup = self._tokenRe.search(page)
387 if tokenGroup is None:
388 raise RuntimeError("Could not extract authentication token from GoogleVoice")
389 self._token = tokenGroup.group(1)
391 anGroup = self._accountNumRe.search(page)
392 if anGroup is not None:
393 self._accountNum = anGroup.group(1)
395 _moduleLogger.debug("Could not extract account number from GoogleVoice")
397 self._callbackNumbers = {}
398 for match in self._callbackRe.finditer(page):
399 callbackNumber = match.group(2)
400 callbackName = match.group(1)
401 self._callbackNumbers[callbackNumber] = callbackName
403 def _send_validation(self, number):
404 if not self.is_valid_syntax(number):
405 raise ValueError('Number is not valid: "%s"' % number)
406 elif not self.is_authed():
407 raise RuntimeError("Not Authenticated")
409 if len(number) == 11 and number[0] == 1:
410 # Strip leading 1 from 11 digit dialing
414 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
415 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
416 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
417 _voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
418 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
419 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
420 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
421 _messagesContactID = re.compile(r"""<a class=".*?gc-message-name-link.*?">.*?</a>\s*?<span .*?>(.*?)</span>""", re.MULTILINE)
422 #_voicemailMessageRegex = re.compile(r"""<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
423 #_voicemailMessageRegex = re.compile(r"""<a .*? class="gc-message-mni">(.*?)</a>""", re.MULTILINE)
424 _voicemailMessageRegex = re.compile(r"""(<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>|<a .*? class="gc-message-mni">(.*?)</a>)""", re.MULTILINE)
427 def _interpret_voicemail_regex(group):
428 quality, content, number = group.group(2), group.group(3), group.group(4)
429 if quality is not None and content is not None:
430 return quality, content
431 elif number is not None:
432 return "high", number
434 def _parse_voicemail(self, voicemailHtml):
435 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
436 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
437 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
438 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
439 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
440 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
441 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
442 locationGroup = self._voicemailLocationRegex.search(messageHtml)
443 location = locationGroup.group(1).strip() if locationGroup else ""
445 nameGroup = self._voicemailNameRegex.search(messageHtml)
446 name = nameGroup.group(1).strip() if nameGroup else ""
447 numberGroup = self._voicemailNumberRegex.search(messageHtml)
448 number = numberGroup.group(1).strip() if numberGroup else ""
449 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
450 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
451 contactIdGroup = self._messagesContactID.search(messageHtml)
452 contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
454 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
456 self._interpret_voicemail_regex(group)
457 for group in messageGroups
458 ) if messageGroups else ()
461 "id": messageId.strip(),
462 "contactId": contactId,
465 "relTime": relativeTime,
466 "prettyNumber": prettyNumber,
468 "location": location,
469 "messageParts": messageParts,
472 def _decorate_voicemail(self, parsedVoicemails):
473 messagePartFormat = {
478 for voicemailData in parsedVoicemails:
480 messagePartFormat[quality] % part
481 for (quality, part) in voicemailData["messageParts"]
484 message = "No Transcription"
485 whoFrom = voicemailData["name"]
486 when = voicemailData["time"]
487 voicemailData["messageParts"] = ((whoFrom, message, when), )
490 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
491 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
492 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
494 def _parse_sms(self, smsHtml):
495 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
496 for messageId, messageHtml in itergroup(splitSms[1:], 2):
497 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
498 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
499 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
500 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
501 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
503 nameGroup = self._voicemailNameRegex.search(messageHtml)
504 name = nameGroup.group(1).strip() if nameGroup else ""
505 numberGroup = self._voicemailNumberRegex.search(messageHtml)
506 number = numberGroup.group(1).strip() if numberGroup else ""
507 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
508 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
509 contactIdGroup = self._messagesContactID.search(messageHtml)
510 contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
512 fromGroups = self._smsFromRegex.finditer(messageHtml)
513 fromParts = (group.group(1).strip() for group in fromGroups)
514 textGroups = self._smsTextRegex.finditer(messageHtml)
515 textParts = (group.group(1).strip() for group in textGroups)
516 timeGroups = self._smsTimeRegex.finditer(messageHtml)
517 timeParts = (group.group(1).strip() for group in timeGroups)
519 messageParts = itertools.izip(fromParts, textParts, timeParts)
522 "id": messageId.strip(),
523 "contactId": contactId,
526 "relTime": relativeTime,
527 "prettyNumber": prettyNumber,
530 "messageParts": messageParts,
533 def _decorate_sms(self, parsedTexts):
537 def set_sane_callback(backend):
539 Try to set a sane default callback number on these preferences
540 1) 1747 numbers ( Gizmo )
541 2) anything with gizmo in the name
542 3) anything with computer in the name
545 numbers = backend.get_callback_numbers()
547 priorityOrderedCriteria = [
555 for numberCriteria, descriptionCriteria in priorityOrderedCriteria:
556 for number, description in numbers.iteritems():
557 if numberCriteria is not None and re.compile(numberCriteria).match(number) is None:
559 if descriptionCriteria is not None and re.compile(descriptionCriteria).match(description) is None:
561 backend.set_callback_number(number)
565 def sort_messages(allMessages):
566 sortableAllMessages = [
567 (message["time"], message)
568 for message in allMessages
570 sortableAllMessages.sort(reverse=True)
573 for (exactTime, message) in sortableAllMessages
577 def decorate_recent(recentCallData):
579 @returns (personsName, phoneNumber, date, action)
581 if recentCallData["name"]:
582 header = recentCallData["name"]
583 elif recentCallData["prettyNumber"]:
584 header = recentCallData["prettyNumber"]
585 elif recentCallData["location"]:
586 header = recentCallData["location"]
590 number = recentCallData["number"]
591 relTime = recentCallData["relTime"]
592 action = recentCallData["action"]
593 return header, number, relTime, action
596 def decorate_message(messageData):
597 exactTime = messageData["time"]
598 if messageData["name"]:
599 header = messageData["name"]
600 elif messageData["prettyNumber"]:
601 header = messageData["prettyNumber"]
604 number = messageData["number"]
605 relativeTime = messageData["relTime"]
607 messageParts = list(messageData["messageParts"])
608 if len(messageParts) == 0:
609 messages = ("No Transcription", )
610 elif len(messageParts) == 1:
611 messages = (messageParts[0][1], )
614 "<b>%s</b>: %s" % (messagePart[0], messagePart[1])
615 for messagePart in messageParts
618 decoratedResults = header, number, relativeTime, messages
619 return decoratedResults
622 def test_backend(username, password):
623 backend = GVoiceBackend()
624 print "Authenticated: ", backend.is_authed()
625 if not backend.is_authed():
626 print "Login?: ", backend.login(username, password)
627 print "Authenticated: ", backend.is_authed()
629 #print "Token: ", backend._token
630 #print "Account: ", backend.get_account_number()
631 #print "Callback: ", backend.get_callback_number()
632 #print "All Callback: ",
634 #pprint.pprint(backend.get_callback_numbers())
637 #for data in backend.get_recent():
638 # pprint.pprint(data)
639 #for data in sort_messages(backend.get_recent()):
640 # pprint.pprint(decorate_recent(data))
641 #pprint.pprint(list(backend.get_recent()))
644 #for contact in backend.get_contacts():
646 # pprint.pprint(list(backend.get_contact_details(contact[0])))
649 #for message in backend.get_messages():
650 # pprint.pprint(message)
651 #for message in sort_messages(backend.get_messages()):
652 # pprint.pprint(decorate_message(message))
657 if __name__ == "__main__":
659 logging.basicConfig(level=logging.DEBUG)
660 test_backend(sys.argv[1], sys.argv[2])