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 GVDialer(object):
103 This class encapsulates all of the knowledge necessary to interact with the GoogleVoice servers
104 the functions include login, setting up a callback number, and initalting a callback
107 def __init__(self, cookieFile = None):
108 # Important items in this function are the setup of the browser emulation and cookie file
109 self._browser = browser_emu.MozillaEmulator(1)
110 if cookieFile is None:
111 cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
112 self._browser.cookies.filename = cookieFile
113 if os.path.isfile(cookieFile):
114 self._browser.cookies.load()
117 self._accountNum = ""
118 self._lastAuthed = 0.0
119 self._callbackNumber = ""
120 self._callbackNumbers = {}
122 self.__contacts = None
124 def is_authed(self, force = False):
126 Attempts to detect a current session
127 @note Once logged in try not to reauth more than once a minute.
128 @returns If authenticated
131 if (time.time() - self._lastAuthed) < 120 and not force:
135 page = self._browser.download(self._forwardURL)
136 self._grab_account_info(page)
138 _moduleLogger.exception(str(e))
141 self._browser.cookies.save()
142 self._lastAuthed = time.time()
145 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
147 def login(self, username, password):
149 Attempt to login to GoogleVoice
150 @returns Whether login was successful or not
152 loginPostData = urllib.urlencode({
155 'service': "grandcentral",
158 "PersistentCookie": "yes",
159 "continue": self._forwardURL,
163 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
164 except urllib2.URLError, e:
165 _moduleLogger.exception(str(e))
166 raise RuntimeError("%s is not accesible" % self._loginURL)
169 self._grab_account_info(loginSuccessOrFailurePage)
171 _moduleLogger.exception(str(e))
174 self._browser.cookies.save()
175 self._lastAuthed = time.time()
179 self._lastAuthed = 0.0
180 self._browser.cookies.clear()
181 self._browser.cookies.save()
185 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
186 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
188 def dial(self, number):
190 This is the main function responsible for initating the callback
192 number = self._send_validation(number)
194 clickToCallData = urllib.urlencode({
196 "phone": self._callbackNumber,
197 "_rnr_se": self._token,
200 'Referer' : 'https://google.com/voice/m/callsms',
202 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
203 except urllib2.URLError, e:
204 _moduleLogger.exception(str(e))
205 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
207 if self._gvDialingStrRe.search(callSuccessPage) is None:
208 raise RuntimeError("Google Voice returned an error")
212 _sendSmsURL = "https://www.google.com/voice/m/sendsms"
214 def send_sms(self, number, message):
215 number = self._send_validation(number)
217 smsData = urllib.urlencode({
220 "_rnr_se": self._token,
225 'Referer' : 'https://google.com/voice/m/sms',
227 smsSuccessPage = self._browser.download(self._sendSmsURL, smsData, None, otherData)
228 except urllib2.URLError, e:
229 _moduleLogger.exception(str(e))
230 raise RuntimeError("%s is not accesible" % self._sendSmsURL)
234 def clear_caches(self):
235 self.__contacts = None
237 _validateRe = re.compile("^[0-9]{10,}$")
239 def is_valid_syntax(self, number):
241 @returns If This number be called ( syntax validation only )
243 return self._validateRe.match(number) is not None
245 def get_account_number(self):
247 @returns The GoogleVoice phone number
249 return self._accountNum
251 def get_callback_numbers(self):
253 @returns a dictionary mapping call back numbers to descriptions
254 @note These results are cached for 30 minutes.
256 if not self.is_authed():
258 return self._callbackNumbers
260 _setforwardURL = "https://www.google.com//voice/m/setphone"
262 def set_callback_number(self, callbacknumber):
264 Set the number that GoogleVoice calls
265 @param callbacknumber should be a proper 10 digit number
267 self._callbackNumber = callbacknumber
270 def get_callback_number(self):
272 @returns Current callback number or None
274 return self._callbackNumber
276 def get_recent(self):
278 @returns Iterable of (personsName, phoneNumber, date, action)
281 (exactDate, name, number, relativeDate, action)
282 for (name, number, exactDate, relativeDate, action) in self._get_recent()
284 sortedRecent.sort(reverse = True)
285 for exactDate, name, number, relativeDate, action in sortedRecent:
286 yield name, number, relativeDate, action
288 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
289 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
290 _contactsURL = "https://www.google.com/voice/mobile/contacts"
292 def get_contacts(self):
294 @returns Iterable of (contact id, contact name)
296 if self.__contacts is None:
299 contactsPagesUrls = [self._contactsURL]
300 for contactsPageUrl in contactsPagesUrls:
302 contactsPage = self._browser.download(contactsPageUrl)
303 except urllib2.URLError, e:
304 _moduleLogger.exception(str(e))
305 raise RuntimeError("%s is not accesible" % contactsPageUrl)
306 for contact_match in self._contactsRe.finditer(contactsPage):
307 contactId = contact_match.group(1)
308 contactName = saxutils.unescape(contact_match.group(2))
309 contact = contactId, contactName
310 self.__contacts.append(contact)
313 next_match = self._contactsNextRe.match(contactsPage)
314 if next_match is not None:
315 newContactsPageUrl = self._contactsURL + next_match.group(1)
316 contactsPagesUrls.append(newContactsPageUrl)
318 for contact in self.__contacts:
321 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9+\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
322 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
324 def get_contact_details(self, contactId):
326 @returns Iterable of (Phone Type, Phone Number)
329 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
330 except urllib2.URLError, e:
331 _moduleLogger.exception(str(e))
332 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
334 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
335 phoneNumber = detail_match.group(1)
336 phoneType = saxutils.unescape(detail_match.group(2))
337 yield (phoneType, phoneNumber)
339 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
340 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
342 def get_messages(self):
344 voicemailPage = self._browser.download(self._voicemailURL)
345 except urllib2.URLError, e:
346 _moduleLogger.exception(str(e))
347 raise RuntimeError("%s is not accesible" % self._voicemailURL)
348 voicemailHtml = self._grab_html(voicemailPage)
349 parsedVoicemail = self._parse_voicemail(voicemailHtml)
350 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
353 smsPage = self._browser.download(self._smsURL)
354 except urllib2.URLError, e:
355 _moduleLogger.exception(str(e))
356 raise RuntimeError("%s is not accesible" % self._smsURL)
357 smsHtml = self._grab_html(smsPage)
358 parsedSms = self._parse_sms(smsHtml)
359 decoratedSms = self._decorate_sms(parsedSms)
361 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
364 def _grab_json(self, flatXml):
365 xmlTree = ElementTree.fromstring(flatXml)
366 jsonElement = xmlTree.getchildren()[0]
367 flatJson = jsonElement.text
368 jsonTree = parse_json(flatJson)
371 def _grab_html(self, flatXml):
372 xmlTree = ElementTree.fromstring(flatXml)
373 htmlElement = xmlTree.getchildren()[1]
374 flatHtml = htmlElement.text
377 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
378 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
379 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
380 _forwardURL = "https://www.google.com/voice/mobile/phones"
382 def _grab_account_info(self, page):
383 tokenGroup = self._tokenRe.search(page)
384 if tokenGroup is None:
385 raise RuntimeError("Could not extract authentication token from GoogleVoice")
386 self._token = tokenGroup.group(1)
388 anGroup = self._accountNumRe.search(page)
389 if anGroup is not None:
390 self._accountNum = anGroup.group(1)
392 _moduleLogger.debug("Could not extract account number from GoogleVoice")
394 self._callbackNumbers = {}
395 for match in self._callbackRe.finditer(page):
396 callbackNumber = match.group(2)
397 callbackName = match.group(1)
398 self._callbackNumbers[callbackNumber] = callbackName
400 def _send_validation(self, number):
401 if not self.is_valid_syntax(number):
402 raise ValueError('Number is not valid: "%s"' % number)
403 elif not self.is_authed():
404 raise RuntimeError("Not Authenticated")
406 if len(number) == 11 and number[0] == 1:
407 # Strip leading 1 from 11 digit dialing
411 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
412 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
413 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
414 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
416 def _get_recent(self):
418 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
421 ("Received", self._receivedCallsURL),
422 ("Missed", self._missedCallsURL),
423 ("Placed", self._placedCallsURL),
426 flatXml = self._browser.download(url)
427 except urllib2.URLError, e:
428 _moduleLogger.exception(str(e))
429 raise RuntimeError("%s is not accesible" % url)
431 allRecentHtml = self._grab_html(flatXml)
432 allRecentData = self._parse_voicemail(allRecentHtml)
433 for recentCallData in allRecentData:
434 exactTime = recentCallData["time"]
435 if recentCallData["name"]:
436 header = recentCallData["name"]
437 elif recentCallData["prettyNumber"]:
438 header = recentCallData["prettyNumber"]
439 elif recentCallData["location"]:
440 header = recentCallData["location"]
443 yield header, recentCallData["number"], exactTime, recentCallData["relTime"], action
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 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
523 _smsTimeRegex = 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_message(messageData):
609 exactTime = messageData["time"]
610 if messageData["name"]:
611 header = messageData["name"]
612 elif messageData["prettyNumber"]:
613 header = messageData["prettyNumber"]
616 number = messageData["number"]
617 relativeTime = messageData["relTime"]
619 messageParts = list(messageData["messageParts"])
620 if len(messageParts) == 0:
621 messages = ("No Transcription", )
622 elif len(messageParts) == 1:
623 messages = (messageParts[0][1], )
626 "<b>%s</b>: %s" % (messagePart[0], messagePart[-1])
627 for messagePart in messageParts
630 decoratedResults = header, number, relativeTime, messages
631 return decoratedResults
634 def test_backend(username, password):
636 print "Authenticated: ", backend.is_authed()
637 print "Login?: ", backend.login(username, password)
638 print "Authenticated: ", backend.is_authed()
639 # print "Token: ", backend._token
640 #print "Account: ", backend.get_account_number()
641 #print "Callback: ", backend.get_callback_number()
642 # print "All Callback: ",
644 # pprint.pprint(backend.get_callback_numbers())
646 # pprint.pprint(list(backend.get_recent()))
647 # print "Contacts: ",
648 # for contact in backend.get_contacts():
650 # pprint.pprint(list(backend.get_contact_details(contact[0])))
651 #for message in backend.get_messages():
652 # pprint.pprint(message)
653 #for message in sort_messages(backend.get_messages()):
654 # pprint.pprint(decorate_message(message))
659 if __name__ == "__main__":
661 logging.basicConfig(level=logging.DEBUG)
662 test_backend(sys.argv[1], sys.argv[2])