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 def is_authed(self, force = False):
124 Attempts to detect a current session
125 @note Once logged in try not to reauth more than once a minute.
126 @returns If authenticated
128 if (time.time() - self._lastAuthed) < 120 and not force:
132 page = self._browser.download(self._forwardURL)
133 self._grab_account_info(page)
135 _moduleLogger.exception(str(e))
138 self._browser.cookies.save()
139 self._lastAuthed = time.time()
142 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
144 def login(self, username, password):
146 Attempt to login to GoogleVoice
147 @returns Whether login was successful or not
149 loginPostData = urllib.urlencode({
152 'service': "grandcentral",
155 "PersistentCookie": "yes",
156 "continue": self._forwardURL,
160 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
161 except urllib2.URLError, e:
162 _moduleLogger.exception(str(e))
163 raise RuntimeError("%s is not accesible" % self._loginURL)
166 self._grab_account_info(loginSuccessOrFailurePage)
168 _moduleLogger.exception(str(e))
171 self._browser.cookies.save()
172 self._lastAuthed = time.time()
176 self._lastAuthed = 0.0
177 self._browser.cookies.clear()
178 self._browser.cookies.save()
180 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
181 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
183 def dial(self, number):
185 This is the main function responsible for initating the callback
187 number = self._send_validation(number)
189 clickToCallData = urllib.urlencode({
191 "phone": self._callbackNumber,
192 "_rnr_se": self._token,
195 'Referer' : 'https://google.com/voice/m/callsms',
197 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
198 except urllib2.URLError, e:
199 _moduleLogger.exception(str(e))
200 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
202 if self._gvDialingStrRe.search(callSuccessPage) is None:
203 raise RuntimeError("Google Voice returned an error")
207 _sendSmsURL = "https://www.google.com/voice/m/sendsms"
209 def send_sms(self, number, message):
210 number = self._send_validation(number)
212 smsData = urllib.urlencode({
215 "_rnr_se": self._token,
220 'Referer' : 'https://google.com/voice/m/sms',
222 smsSuccessPage = self._browser.download(self._sendSmsURL, smsData, None, otherData)
223 except urllib2.URLError, e:
224 _moduleLogger.exception(str(e))
225 raise RuntimeError("%s is not accesible" % self._sendSmsURL)
229 _validateRe = re.compile("^[0-9]{10,}$")
231 def is_valid_syntax(self, number):
233 @returns If This number be called ( syntax validation only )
235 return self._validateRe.match(number) is not None
237 def get_account_number(self):
239 @returns The GoogleVoice phone number
241 return self._accountNum
243 def get_callback_numbers(self):
245 @returns a dictionary mapping call back numbers to descriptions
246 @note These results are cached for 30 minutes.
248 if not self.is_authed():
250 return self._callbackNumbers
252 _setforwardURL = "https://www.google.com//voice/m/setphone"
254 def set_callback_number(self, callbacknumber):
256 Set the number that GoogleVoice calls
257 @param callbacknumber should be a proper 10 digit number
259 self._callbackNumber = callbacknumber
262 def get_callback_number(self):
264 @returns Current callback number or None
266 return self._callbackNumber
268 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
269 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
270 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
271 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
273 def get_recent(self):
275 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
278 ("Received", self._receivedCallsURL),
279 ("Missed", self._missedCallsURL),
280 ("Placed", self._placedCallsURL),
283 flatXml = self._browser.download(url)
284 except urllib2.URLError, e:
285 _moduleLogger.exception(str(e))
286 raise RuntimeError("%s is not accesible" % url)
288 allRecentHtml = self._grab_html(flatXml)
289 allRecentData = self._parse_voicemail(allRecentHtml)
290 for recentCallData in allRecentData:
291 recentCallData["action"] = action
294 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
295 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
296 _contactsURL = "https://www.google.com/voice/mobile/contacts"
298 def get_contacts(self):
300 @returns Iterable of (contact id, contact name)
302 contactsPagesUrls = [self._contactsURL]
303 for contactsPageUrl in contactsPagesUrls:
305 contactsPage = self._browser.download(contactsPageUrl)
306 except urllib2.URLError, e:
307 _moduleLogger.exception(str(e))
308 raise RuntimeError("%s is not accesible" % contactsPageUrl)
309 for contact_match in self._contactsRe.finditer(contactsPage):
310 contactId = contact_match.group(1)
311 contactName = saxutils.unescape(contact_match.group(2))
312 contact = contactId, contactName
315 next_match = self._contactsNextRe.match(contactsPage)
316 if next_match is not None:
317 newContactsPageUrl = self._contactsURL + next_match.group(1)
318 contactsPagesUrls.append(newContactsPageUrl)
320 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9+\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
321 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
323 def get_contact_details(self, contactId):
325 @returns Iterable of (Phone Type, Phone Number)
328 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
329 except urllib2.URLError, e:
330 _moduleLogger.exception(str(e))
331 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
333 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
334 phoneNumber = detail_match.group(1)
335 phoneType = saxutils.unescape(detail_match.group(2))
336 yield (phoneType, phoneNumber)
338 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
339 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
341 def get_messages(self):
343 voicemailPage = self._browser.download(self._voicemailURL)
344 except urllib2.URLError, e:
345 _moduleLogger.exception(str(e))
346 raise RuntimeError("%s is not accesible" % self._voicemailURL)
347 voicemailHtml = self._grab_html(voicemailPage)
348 parsedVoicemail = self._parse_voicemail(voicemailHtml)
349 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
352 smsPage = self._browser.download(self._smsURL)
353 except urllib2.URLError, e:
354 _moduleLogger.exception(str(e))
355 raise RuntimeError("%s is not accesible" % self._smsURL)
356 smsHtml = self._grab_html(smsPage)
357 parsedSms = self._parse_sms(smsHtml)
358 decoratedSms = self._decorate_sms(parsedSms)
360 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
363 def clear_caches(self):
366 def get_addressbooks(self):
368 @returns Iterable of (Address Book Factory, Book Id, Book Name)
372 def open_addressbook(self, bookId):
376 def contact_source_short_name(contactId):
381 return "Google Voice"
383 def _grab_json(self, flatXml):
384 xmlTree = ElementTree.fromstring(flatXml)
385 jsonElement = xmlTree.getchildren()[0]
386 flatJson = jsonElement.text
387 jsonTree = parse_json(flatJson)
390 def _grab_html(self, flatXml):
391 xmlTree = ElementTree.fromstring(flatXml)
392 htmlElement = xmlTree.getchildren()[1]
393 flatHtml = htmlElement.text
396 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
397 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
398 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
399 _forwardURL = "https://www.google.com/voice/mobile/phones"
401 def _grab_account_info(self, page):
402 tokenGroup = self._tokenRe.search(page)
403 if tokenGroup is None:
404 raise RuntimeError("Could not extract authentication token from GoogleVoice")
405 self._token = tokenGroup.group(1)
407 anGroup = self._accountNumRe.search(page)
408 if anGroup is not None:
409 self._accountNum = anGroup.group(1)
411 _moduleLogger.debug("Could not extract account number from GoogleVoice")
413 self._callbackNumbers = {}
414 for match in self._callbackRe.finditer(page):
415 callbackNumber = match.group(2)
416 callbackName = match.group(1)
417 self._callbackNumbers[callbackNumber] = callbackName
419 def _send_validation(self, number):
420 if not self.is_valid_syntax(number):
421 raise ValueError('Number is not valid: "%s"' % number)
422 elif not self.is_authed():
423 raise RuntimeError("Not Authenticated")
425 if len(number) == 11 and number[0] == 1:
426 # Strip leading 1 from 11 digit dialing
430 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
431 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
432 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
433 _voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
434 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
435 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
436 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
437 _messagesContactID = re.compile(r"""<a class=".*?gc-message-name-link.*?">.*?</a>\s*?<span .*?>(.*?)</span>""", re.MULTILINE)
438 #_voicemailMessageRegex = re.compile(r"""<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
439 #_voicemailMessageRegex = re.compile(r"""<a .*? class="gc-message-mni">(.*?)</a>""", re.MULTILINE)
440 _voicemailMessageRegex = re.compile(r"""(<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>|<a .*? class="gc-message-mni">(.*?)</a>)""", re.MULTILINE)
443 def _interpret_voicemail_regex(group):
444 quality, content, number = group.group(2), group.group(3), group.group(4)
445 if quality is not None and content is not None:
446 return quality, content
447 elif number is not None:
448 return "high", number
450 def _parse_voicemail(self, voicemailHtml):
451 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
452 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
453 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
454 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
455 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
456 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
457 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
458 locationGroup = self._voicemailLocationRegex.search(messageHtml)
459 location = locationGroup.group(1).strip() if locationGroup else ""
461 nameGroup = self._voicemailNameRegex.search(messageHtml)
462 name = nameGroup.group(1).strip() if nameGroup else ""
463 numberGroup = self._voicemailNumberRegex.search(messageHtml)
464 number = numberGroup.group(1).strip() if numberGroup else ""
465 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
466 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
467 contactIdGroup = self._messagesContactID.search(messageHtml)
468 contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
470 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
472 self._interpret_voicemail_regex(group)
473 for group in messageGroups
474 ) if messageGroups else ()
477 "id": messageId.strip(),
478 "contactId": contactId,
481 "relTime": relativeTime,
482 "prettyNumber": prettyNumber,
484 "location": location,
485 "messageParts": messageParts,
488 def _decorate_voicemail(self, parsedVoicemails):
489 messagePartFormat = {
494 for voicemailData in parsedVoicemails:
496 messagePartFormat[quality] % part
497 for (quality, part) in voicemailData["messageParts"]
500 message = "No Transcription"
501 whoFrom = voicemailData["name"]
502 when = voicemailData["time"]
503 voicemailData["messageParts"] = ((whoFrom, message, when), )
506 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
507 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
508 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
510 def _parse_sms(self, smsHtml):
511 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
512 for messageId, messageHtml in itergroup(splitSms[1:], 2):
513 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
514 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
515 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
516 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
517 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
519 nameGroup = self._voicemailNameRegex.search(messageHtml)
520 name = nameGroup.group(1).strip() if nameGroup else ""
521 numberGroup = self._voicemailNumberRegex.search(messageHtml)
522 number = numberGroup.group(1).strip() if numberGroup else ""
523 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
524 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
525 contactIdGroup = self._messagesContactID.search(messageHtml)
526 contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
528 fromGroups = self._smsFromRegex.finditer(messageHtml)
529 fromParts = (group.group(1).strip() for group in fromGroups)
530 textGroups = self._smsTextRegex.finditer(messageHtml)
531 textParts = (group.group(1).strip() for group in textGroups)
532 timeGroups = self._smsTimeRegex.finditer(messageHtml)
533 timeParts = (group.group(1).strip() for group in timeGroups)
535 messageParts = itertools.izip(fromParts, textParts, timeParts)
538 "id": messageId.strip(),
539 "contactId": contactId,
542 "relTime": relativeTime,
543 "prettyNumber": prettyNumber,
546 "messageParts": messageParts,
549 def _decorate_sms(self, parsedTexts):
553 def set_sane_callback(backend):
555 Try to set a sane default callback number on these preferences
556 1) 1747 numbers ( Gizmo )
557 2) anything with gizmo in the name
558 3) anything with computer in the name
561 numbers = backend.get_callback_numbers()
563 priorityOrderedCriteria = [
571 for numberCriteria, descriptionCriteria in priorityOrderedCriteria:
572 for number, description in numbers.iteritems():
573 if numberCriteria is not None and re.compile(numberCriteria).match(number) is None:
575 if descriptionCriteria is not None and re.compile(descriptionCriteria).match(description) is None:
577 backend.set_callback_number(number)
581 def sort_messages(allMessages):
582 sortableAllMessages = [
583 (message["time"], message)
584 for message in allMessages
586 sortableAllMessages.sort(reverse=True)
589 for (exactTime, message) in sortableAllMessages
593 def decorate_recent(recentCallData):
595 @returns (personsName, phoneNumber, date, action)
597 if recentCallData["name"]:
598 header = recentCallData["name"]
599 elif recentCallData["prettyNumber"]:
600 header = recentCallData["prettyNumber"]
601 elif recentCallData["location"]:
602 header = recentCallData["location"]
606 number = recentCallData["number"]
607 relTime = recentCallData["relTime"]
608 action = recentCallData["action"]
609 return header, number, relTime, action
612 def decorate_message(messageData):
613 exactTime = messageData["time"]
614 if messageData["name"]:
615 header = messageData["name"]
616 elif messageData["prettyNumber"]:
617 header = messageData["prettyNumber"]
620 number = messageData["number"]
621 relativeTime = messageData["relTime"]
623 messageParts = list(messageData["messageParts"])
624 if len(messageParts) == 0:
625 messages = ("No Transcription", )
626 elif len(messageParts) == 1:
627 messages = (messageParts[0][1], )
630 "<b>%s</b>: %s" % (messagePart[0], messagePart[-1])
631 for messagePart in messageParts
634 decoratedResults = header, number, relativeTime, messages
635 return decoratedResults
638 def test_backend(username, password):
640 print "Authenticated: ", backend.is_authed()
641 if not backend.is_authed():
642 print "Login?: ", backend.login(username, password)
643 print "Authenticated: ", backend.is_authed()
645 #print "Token: ", backend._token
646 #print "Account: ", backend.get_account_number()
647 #print "Callback: ", backend.get_callback_number()
648 #print "All Callback: ",
650 #pprint.pprint(backend.get_callback_numbers())
653 #for data in backend.get_recent():
654 # pprint.pprint(data)
655 #for data in sort_messages(backend.get_recent()):
656 # pprint.pprint(decorate_recent(data))
657 #pprint.pprint(list(backend.get_recent()))
660 #for contact in backend.get_contacts():
662 # pprint.pprint(list(backend.get_contact_details(contact[0])))
665 #for message in backend.get_messages():
666 # pprint.pprint(message)
667 #for message in sort_messages(backend.get_messages()):
668 # pprint.pprint(decorate_message(message))
673 if __name__ == "__main__":
675 logging.basicConfig(level=logging.DEBUG)
676 test_backend(sys.argv[1], sys.argv[2])