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 _TRUE_REGEX = re.compile("true")
50 _FALSE_REGEX = re.compile("false")
54 s = _TRUE_REGEX.sub("True", s)
55 s = _FALSE_REGEX.sub("False", s)
56 return eval(s, {}, {})
59 if simplejson is None:
60 def parse_json(flattened):
61 return safe_eval(flattened)
63 def parse_json(flattened):
64 return simplejson.loads(flattened)
67 def itergroup(iterator, count, padValue = None):
69 Iterate in groups of 'count' values. If there
70 aren't enough values, the last result is padded with
73 >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
77 >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
81 >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3):
86 >>> for val in itergroup("123456", 3):
90 >>> for val in itergroup("123456", 3):
91 ... print repr("".join(val))
95 paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1))
96 nIterators = (paddedIterator, ) * count
97 return itertools.izip(*nIterators)
100 class NetworkError(RuntimeError):
104 class GVDialer(object):
106 This class encapsulates all of the knowledge necessary to interact with the GoogleVoice servers
107 the functions include login, setting up a callback number, and initalting a callback
110 def __init__(self, cookieFile = None):
111 # Important items in this function are the setup of the browser emulation and cookie file
112 self._browser = browser_emu.MozillaEmulator(1)
113 if cookieFile is None:
114 cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
115 self._browser.cookies.filename = cookieFile
116 if os.path.isfile(cookieFile):
117 self._browser.cookies.load()
120 self._accountNum = ""
121 self._lastAuthed = 0.0
122 self._callbackNumber = ""
123 self._callbackNumbers = {}
125 self.__contacts = None
127 def is_authed(self, force = False):
129 Attempts to detect a current session
130 @note Once logged in try not to reauth more than once a minute.
131 @returns If authenticated
134 if (time.time() - self._lastAuthed) < 120 and not force:
138 self._grab_account_info()
140 logging.exception(str(e))
143 self._browser.cookies.save()
144 self._lastAuthed = time.time()
147 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
149 def login(self, username, password):
151 Attempt to login to GoogleVoice
152 @returns Whether login was successful or not
157 loginPostData = urllib.urlencode({
160 'service': "grandcentral",
163 "PersistentCookie": "yes",
167 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
168 except urllib2.URLError, e:
169 logging.exception(str(e))
170 raise NetworkError("%s is not accesible" % self._loginURL)
172 return self.is_authed()
175 self._lastAuthed = 0.0
176 self._browser.cookies.clear()
177 self._browser.cookies.save()
181 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
182 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
184 def dial(self, number):
186 This is the main function responsible for initating the callback
188 number = self._send_validation(number)
190 clickToCallData = urllib.urlencode({
192 "phone": self._callbackNumber,
193 "_rnr_se": self._token,
196 'Referer' : 'https://google.com/voice/m/callsms',
198 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
199 except urllib2.URLError, e:
200 logging.exception(str(e))
201 raise NetworkError("%s is not accesible" % self._clicktocallURL)
203 if self._gvDialingStrRe.search(callSuccessPage) is None:
204 raise RuntimeError("Google Voice returned an error")
208 _sendSmsURL = "https://www.google.com/voice/m/sendsms"
210 def send_sms(self, number, message):
211 number = self._send_validation(number)
213 smsData = urllib.urlencode({
216 "_rnr_se": self._token,
221 'Referer' : 'https://google.com/voice/m/sms',
223 smsSuccessPage = self._browser.download(self._sendSmsURL, smsData, None, otherData)
224 except urllib2.URLError, e:
225 logging.exception(str(e))
226 raise NetworkError("%s is not accesible" % self._sendSmsURL)
230 def clear_caches(self):
231 self.__contacts = None
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 set_sane_callback(self):
249 Try to set a sane default callback number on these preferences
250 1) 1747 numbers ( Gizmo )
251 2) anything with gizmo in the name
252 3) anything with computer in the name
255 numbers = self.get_callback_numbers()
257 for number, description in numbers.iteritems():
258 if re.compile(r"""1747""").match(number) is not None:
259 self.set_callback_number(number)
262 for number, description in numbers.iteritems():
263 if re.compile(r"""gizmo""", re.I).search(description) is not None:
264 self.set_callback_number(number)
267 for number, description in numbers.iteritems():
268 if re.compile(r"""computer""", re.I).search(description) is not None:
269 self.set_callback_number(number)
272 for number, description in numbers.iteritems():
273 self.set_callback_number(number)
276 def get_callback_numbers(self):
278 @returns a dictionary mapping call back numbers to descriptions
279 @note These results are cached for 30 minutes.
281 if not self.is_authed():
283 return self._callbackNumbers
285 _setforwardURL = "https://www.google.com//voice/m/setphone"
287 def set_callback_number(self, callbacknumber):
289 Set the number that GoogleVoice calls
290 @param callbacknumber should be a proper 10 digit number
292 self._callbackNumber = callbacknumber
295 def get_callback_number(self):
297 @returns Current callback number or None
299 return self._callbackNumber
301 def get_recent(self):
303 @returns Iterable of (personsName, phoneNumber, date, action)
306 (exactDate, name, number, relativeDate, action)
307 for (name, number, exactDate, relativeDate, action) in self._get_recent()
309 sortedRecent.sort(reverse = True)
310 for exactDate, name, number, relativeDate, action in sortedRecent:
311 yield name, number, relativeDate, action
313 def get_addressbooks(self):
315 @returns Iterable of (Address Book Factory, Book Id, Book Name)
319 def open_addressbook(self, bookId):
323 def contact_source_short_name(contactId):
328 return "Google Voice"
330 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
331 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
332 _contactsURL = "https://www.google.com/voice/mobile/contacts"
334 def get_contacts(self):
336 @returns Iterable of (contact id, contact name)
338 if self.__contacts is None:
341 contactsPagesUrls = [self._contactsURL]
342 for contactsPageUrl in contactsPagesUrls:
344 contactsPage = self._browser.download(contactsPageUrl)
345 except urllib2.URLError, e:
346 logging.exception(str(e))
347 raise NetworkError("%s is not accesible" % contactsPageUrl)
348 for contact_match in self._contactsRe.finditer(contactsPage):
349 contactId = contact_match.group(1)
350 contactName = saxutils.unescape(contact_match.group(2))
351 contact = contactId, contactName
352 self.__contacts.append(contact)
355 next_match = self._contactsNextRe.match(contactsPage)
356 if next_match is not None:
357 newContactsPageUrl = self._contactsURL + next_match.group(1)
358 contactsPagesUrls.append(newContactsPageUrl)
360 for contact in self.__contacts:
363 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9+\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
364 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
366 def get_contact_details(self, contactId):
368 @returns Iterable of (Phone Type, Phone Number)
371 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
372 except urllib2.URLError, e:
373 logging.exception(str(e))
374 raise NetworkError("%s is not accesible" % self._contactDetailURL)
376 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
377 phoneNumber = detail_match.group(1)
378 phoneType = saxutils.unescape(detail_match.group(2))
379 yield (phoneType, phoneNumber)
381 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
382 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
384 def get_messages(self):
386 voicemailPage = self._browser.download(self._voicemailURL)
387 except urllib2.URLError, e:
388 logging.exception(str(e))
389 raise NetworkError("%s is not accesible" % self._voicemailURL)
390 voicemailHtml = self._grab_html(voicemailPage)
391 parsedVoicemail = self._parse_voicemail(voicemailHtml)
392 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
395 smsPage = self._browser.download(self._smsURL)
396 except urllib2.URLError, e:
397 logging.exception(str(e))
398 raise NetworkError("%s is not accesible" % self._smsURL)
399 smsHtml = self._grab_html(smsPage)
400 parsedSms = self._parse_sms(smsHtml)
401 decoratedSms = self._decorate_sms(parsedSms)
403 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
404 sortedMessages = list(allMessages)
405 sortedMessages.sort(reverse=True)
406 for exactDate, header, number, relativeDate, message in sortedMessages:
407 yield header, number, relativeDate, message
409 def _grab_json(self, flatXml):
410 xmlTree = ElementTree.fromstring(flatXml)
411 jsonElement = xmlTree.getchildren()[0]
412 flatJson = jsonElement.text
413 jsonTree = parse_json(flatJson)
416 def _grab_html(self, flatXml):
417 xmlTree = ElementTree.fromstring(flatXml)
418 htmlElement = xmlTree.getchildren()[1]
419 flatHtml = htmlElement.text
422 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
423 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
424 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
425 _forwardURL = "https://www.google.com/voice/mobile/phones"
427 def _grab_account_info(self):
428 page = self._browser.download(self._forwardURL)
430 tokenGroup = self._tokenRe.search(page)
431 if tokenGroup is None:
432 raise RuntimeError("Could not extract authentication token from GoogleVoice")
433 self._token = tokenGroup.group(1)
435 anGroup = self._accountNumRe.search(page)
436 if anGroup is not None:
437 self._accountNum = anGroup.group(1)
439 logging.debug("Could not extract account number from GoogleVoice")
441 self._callbackNumbers = {}
442 for match in self._callbackRe.finditer(page):
443 callbackNumber = match.group(2)
444 callbackName = match.group(1)
445 self._callbackNumbers[callbackNumber] = callbackName
447 def _send_validation(self, number):
448 if not self.is_valid_syntax(number):
449 raise ValueError('Number is not valid: "%s"' % number)
450 elif not self.is_authed():
451 raise RuntimeError("Not Authenticated")
453 if len(number) == 11 and number[0] == 1:
454 # Strip leading 1 from 11 digit dialing
458 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
459 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
460 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
461 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
463 def _get_recent(self):
465 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
468 ("Received", self._receivedCallsURL),
469 ("Missed", self._missedCallsURL),
470 ("Placed", self._placedCallsURL),
473 flatXml = self._browser.download(url)
474 except urllib2.URLError, e:
475 logging.exception(str(e))
476 raise NetworkError("%s is not accesible" % url)
478 allRecentHtml = self._grab_html(flatXml)
479 allRecentData = self._parse_voicemail(allRecentHtml)
480 for recentCallData in allRecentData:
481 exactTime = recentCallData["time"]
482 if recentCallData["name"]:
483 header = recentCallData["name"]
484 elif recentCallData["prettyNumber"]:
485 header = recentCallData["prettyNumber"]
486 elif recentCallData["location"]:
487 header = recentCallData["location"]
490 yield header, recentCallData["number"], exactTime, recentCallData["relTime"], action
492 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
493 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
494 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
495 _voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
496 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
497 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
498 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
499 #_voicemailMessageRegex = re.compile(r"""<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
500 #_voicemailMessageRegex = re.compile(r"""<a .*? class="gc-message-mni">(.*?)</a>""", re.MULTILINE)
501 _voicemailMessageRegex = re.compile(r"""(<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>|<a .*? class="gc-message-mni">(.*?)</a>)""", re.MULTILINE)
504 def _interpret_voicemail_regex(group):
505 quality, content, number = group.group(2), group.group(3), group.group(4)
506 if quality is not None and content is not None:
507 return quality, content
508 elif number is not None:
509 return "high", number
511 def _parse_voicemail(self, voicemailHtml):
512 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
513 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
514 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
515 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
516 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
517 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
518 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
519 locationGroup = self._voicemailLocationRegex.search(messageHtml)
520 location = locationGroup.group(1).strip() if locationGroup else ""
522 nameGroup = self._voicemailNameRegex.search(messageHtml)
523 name = nameGroup.group(1).strip() if nameGroup else ""
524 numberGroup = self._voicemailNumberRegex.search(messageHtml)
525 number = numberGroup.group(1).strip() if numberGroup else ""
526 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
527 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
529 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
531 self._interpret_voicemail_regex(group)
532 for group in messageGroups
533 ) if messageGroups else ()
536 "id": messageId.strip(),
539 "relTime": relativeTime,
540 "prettyNumber": prettyNumber,
542 "location": location,
543 "messageParts": messageParts,
546 def _decorate_voicemail(self, parsedVoicemail):
547 messagePartFormat = {
552 for voicemailData in parsedVoicemail:
553 exactTime = voicemailData["time"]
554 if voicemailData["name"]:
555 header = voicemailData["name"]
556 elif voicemailData["prettyNumber"]:
557 header = voicemailData["prettyNumber"]
558 elif voicemailData["location"]:
559 header = voicemailData["location"]
563 messagePartFormat[quality] % part
564 for (quality, part) in voicemailData["messageParts"]
567 message = "No Transcription"
568 yield exactTime, header, voicemailData["number"], voicemailData["relTime"], (message, )
570 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
571 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
572 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
574 def _parse_sms(self, smsHtml):
575 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
576 for messageId, messageHtml in itergroup(splitSms[1:], 2):
577 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
578 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
579 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
580 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
581 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
583 nameGroup = self._voicemailNameRegex.search(messageHtml)
584 name = nameGroup.group(1).strip() if nameGroup else ""
585 numberGroup = self._voicemailNumberRegex.search(messageHtml)
586 number = numberGroup.group(1).strip() if numberGroup else ""
587 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
588 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
590 fromGroups = self._smsFromRegex.finditer(messageHtml)
591 fromParts = (group.group(1).strip() for group in fromGroups)
592 textGroups = self._smsTextRegex.finditer(messageHtml)
593 textParts = (group.group(1).strip() for group in textGroups)
594 timeGroups = self._smsTimeRegex.finditer(messageHtml)
595 timeParts = (group.group(1).strip() for group in timeGroups)
597 messageParts = itertools.izip(fromParts, textParts, timeParts)
600 "id": messageId.strip(),
603 "relTime": relativeTime,
604 "prettyNumber": prettyNumber,
606 "messageParts": messageParts,
609 def _decorate_sms(self, parsedSms):
610 for messageData in parsedSms:
611 exactTime = messageData["time"]
612 if messageData["name"]:
613 header = messageData["name"]
614 elif messageData["prettyNumber"]:
615 header = messageData["prettyNumber"]
618 number = messageData["number"]
619 relativeTime = messageData["relTime"]
621 "<b>%s</b>: %s" % (messagePart[0], messagePart[-1])
622 for messagePart in messageData["messageParts"]
625 messages = ("No Transcription", )
626 yield exactTime, header, number, relativeTime, messages
629 def test_backend(username, password):
631 print "Authenticated: ", backend.is_authed()
632 print "Login?: ", backend.login(username, password)
633 print "Authenticated: ", backend.is_authed()
634 # print "Token: ", backend._token
635 print "Account: ", backend.get_account_number()
636 print "Callback: ", backend.get_callback_number()
637 # print "All Callback: ",
639 # pprint.pprint(backend.get_callback_numbers())
641 # pprint.pprint(list(backend.get_recent()))
642 # print "Contacts: ",
643 # for contact in backend.get_contacts():
645 # pprint.pprint(list(backend.get_contact_details(contact[0])))
646 for message in backend.get_messages():
647 pprint.pprint(message)