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("gv_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 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 self._grab_account_info()
137 _moduleLogger.exception(str(e))
140 self._browser.cookies.save()
141 self._lastAuthed = time.time()
144 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
146 def login(self, username, password):
148 Attempt to login to GoogleVoice
149 @returns Whether login was successful or not
154 loginPostData = urllib.urlencode({
157 'service': "grandcentral",
160 "PersistentCookie": "yes",
164 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
165 except urllib2.URLError, e:
166 _moduleLogger.exception(str(e))
167 raise RuntimeError("%s is not accesible" % self._loginURL)
169 return self.is_authed()
172 self._lastAuthed = 0.0
173 self._browser.cookies.clear()
174 self._browser.cookies.save()
178 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
179 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
181 def dial(self, number):
183 This is the main function responsible for initating the callback
185 number = self._send_validation(number)
187 clickToCallData = urllib.urlencode({
189 "phone": self._callbackNumber,
190 "_rnr_se": self._token,
193 'Referer' : 'https://google.com/voice/m/callsms',
195 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
196 except urllib2.URLError, e:
197 _moduleLogger.exception(str(e))
198 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
200 if self._gvDialingStrRe.search(callSuccessPage) is None:
201 raise RuntimeError("Google Voice returned an error")
205 _sendSmsURL = "https://www.google.com/voice/m/sendsms"
207 def send_sms(self, number, message):
208 number = self._send_validation(number)
210 smsData = urllib.urlencode({
213 "_rnr_se": self._token,
218 'Referer' : 'https://google.com/voice/m/sms',
220 smsSuccessPage = self._browser.download(self._sendSmsURL, smsData, None, otherData)
221 except urllib2.URLError, e:
222 _moduleLogger.exception(str(e))
223 raise RuntimeError("%s is not accesible" % self._sendSmsURL)
227 def clear_caches(self):
228 self.__contacts = None
230 _validateRe = re.compile("^[0-9]{10,}$")
232 def is_valid_syntax(self, number):
234 @returns If This number be called ( syntax validation only )
236 return self._validateRe.match(number) is not None
238 def get_account_number(self):
240 @returns The GoogleVoice phone number
242 return self._accountNum
244 def get_callback_numbers(self):
246 @returns a dictionary mapping call back numbers to descriptions
247 @note These results are cached for 30 minutes.
249 if not self.is_authed():
251 return self._callbackNumbers
253 _setforwardURL = "https://www.google.com//voice/m/setphone"
255 def set_callback_number(self, callbacknumber):
257 Set the number that GoogleVoice calls
258 @param callbacknumber should be a proper 10 digit number
260 self._callbackNumber = callbacknumber
263 def get_callback_number(self):
265 @returns Current callback number or None
267 return self._callbackNumber
269 def get_recent(self):
271 @returns Iterable of (personsName, phoneNumber, date, action)
274 (exactDate, name, number, relativeDate, action)
275 for (name, number, exactDate, relativeDate, action) in self._get_recent()
277 sortedRecent.sort(reverse = True)
278 for exactDate, name, number, relativeDate, action in sortedRecent:
279 yield name, number, relativeDate, action
281 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
282 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
283 _contactsURL = "https://www.google.com/voice/mobile/contacts"
285 def get_contacts(self):
287 @returns Iterable of (contact id, contact name)
289 if self.__contacts is None:
292 contactsPagesUrls = [self._contactsURL]
293 for contactsPageUrl in contactsPagesUrls:
295 contactsPage = self._browser.download(contactsPageUrl)
296 except urllib2.URLError, e:
297 _moduleLogger.exception(str(e))
298 raise RuntimeError("%s is not accesible" % contactsPageUrl)
299 for contact_match in self._contactsRe.finditer(contactsPage):
300 contactId = contact_match.group(1)
301 contactName = saxutils.unescape(contact_match.group(2))
302 contact = contactId, contactName
303 self.__contacts.append(contact)
306 next_match = self._contactsNextRe.match(contactsPage)
307 if next_match is not None:
308 newContactsPageUrl = self._contactsURL + next_match.group(1)
309 contactsPagesUrls.append(newContactsPageUrl)
311 for contact in self.__contacts:
314 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9+\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
315 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
317 def get_contact_details(self, contactId):
319 @returns Iterable of (Phone Type, Phone Number)
322 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
323 except urllib2.URLError, e:
324 _moduleLogger.exception(str(e))
325 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
327 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
328 phoneNumber = detail_match.group(1)
329 phoneType = saxutils.unescape(detail_match.group(2))
330 yield (phoneType, phoneNumber)
332 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
333 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
335 def get_messages(self):
337 voicemailPage = self._browser.download(self._voicemailURL)
338 except urllib2.URLError, e:
339 _moduleLogger.exception(str(e))
340 raise RuntimeError("%s is not accesible" % self._voicemailURL)
341 voicemailHtml = self._grab_html(voicemailPage)
342 parsedVoicemail = self._parse_voicemail(voicemailHtml)
343 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
346 smsPage = self._browser.download(self._smsURL)
347 except urllib2.URLError, e:
348 _moduleLogger.exception(str(e))
349 raise RuntimeError("%s is not accesible" % self._smsURL)
350 smsHtml = self._grab_html(smsPage)
351 parsedSms = self._parse_sms(smsHtml)
352 decoratedSms = self._decorate_sms(parsedSms)
354 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
357 def _grab_json(self, flatXml):
358 xmlTree = ElementTree.fromstring(flatXml)
359 jsonElement = xmlTree.getchildren()[0]
360 flatJson = jsonElement.text
361 jsonTree = parse_json(flatJson)
364 def _grab_html(self, flatXml):
365 xmlTree = ElementTree.fromstring(flatXml)
366 htmlElement = xmlTree.getchildren()[1]
367 flatHtml = htmlElement.text
370 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
371 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
372 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
373 _forwardURL = "https://www.google.com/voice/mobile/phones"
375 def _grab_account_info(self):
376 page = self._browser.download(self._forwardURL)
378 tokenGroup = self._tokenRe.search(page)
379 if tokenGroup is None:
380 raise RuntimeError("Could not extract authentication token from GoogleVoice")
381 self._token = tokenGroup.group(1)
383 anGroup = self._accountNumRe.search(page)
384 if anGroup is not None:
385 self._accountNum = anGroup.group(1)
387 _moduleLogger.debug("Could not extract account number from GoogleVoice")
389 self._callbackNumbers = {}
390 for match in self._callbackRe.finditer(page):
391 callbackNumber = match.group(2)
392 callbackName = match.group(1)
393 self._callbackNumbers[callbackNumber] = callbackName
395 def _send_validation(self, number):
396 if not self.is_valid_syntax(number):
397 raise ValueError('Number is not valid: "%s"' % number)
398 elif not self.is_authed():
399 raise RuntimeError("Not Authenticated")
401 if len(number) == 11 and number[0] == 1:
402 # Strip leading 1 from 11 digit dialing
406 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
407 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
408 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
409 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
411 def _get_recent(self):
413 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
416 ("Received", self._receivedCallsURL),
417 ("Missed", self._missedCallsURL),
418 ("Placed", self._placedCallsURL),
421 flatXml = self._browser.download(url)
422 except urllib2.URLError, e:
423 _moduleLogger.exception(str(e))
424 raise RuntimeError("%s is not accesible" % url)
426 allRecentHtml = self._grab_html(flatXml)
427 allRecentData = self._parse_voicemail(allRecentHtml)
428 for recentCallData in allRecentData:
429 exactTime = recentCallData["time"]
430 if recentCallData["name"]:
431 header = recentCallData["name"]
432 elif recentCallData["prettyNumber"]:
433 header = recentCallData["prettyNumber"]
434 elif recentCallData["location"]:
435 header = recentCallData["location"]
438 yield header, recentCallData["number"], exactTime, recentCallData["relTime"], action
440 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
441 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
442 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
443 _voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
444 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
445 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
446 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
447 _messagesContactID = re.compile(r"""<a class=".*?gc-message-name-link.*?">.*?</a>\s*?<span .*?>(.*?)</span>""", re.MULTILINE)
448 #_voicemailMessageRegex = re.compile(r"""<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
449 #_voicemailMessageRegex = re.compile(r"""<a .*? class="gc-message-mni">(.*?)</a>""", re.MULTILINE)
450 _voicemailMessageRegex = re.compile(r"""(<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>|<a .*? class="gc-message-mni">(.*?)</a>)""", re.MULTILINE)
453 def _interpret_voicemail_regex(group):
454 quality, content, number = group.group(2), group.group(3), group.group(4)
455 if quality is not None and content is not None:
456 return quality, content
457 elif number is not None:
458 return "high", number
460 def _parse_voicemail(self, voicemailHtml):
461 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
462 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
463 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
464 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
465 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
466 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
467 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
468 locationGroup = self._voicemailLocationRegex.search(messageHtml)
469 location = locationGroup.group(1).strip() if locationGroup else ""
471 nameGroup = self._voicemailNameRegex.search(messageHtml)
472 name = nameGroup.group(1).strip() if nameGroup else ""
473 numberGroup = self._voicemailNumberRegex.search(messageHtml)
474 number = numberGroup.group(1).strip() if numberGroup else ""
475 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
476 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
477 contactIdGroup = self._messagesContactID.search(messageHtml)
478 contactId = contactIdGroup.group(1).strip() if contactIdGroup else number
480 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
482 self._interpret_voicemail_regex(group)
483 for group in messageGroups
484 ) if messageGroups else ()
487 "id": messageId.strip(),
488 "contactId": contactId,
491 "relTime": relativeTime,
492 "prettyNumber": prettyNumber,
494 "location": location,
495 "messageParts": messageParts,
498 def _decorate_voicemail(self, parsedVoicemails):
499 messagePartFormat = {
504 for voicemailData in parsedVoicemails:
506 messagePartFormat[quality] % part
507 for (quality, part) in voicemailData["messageParts"]
510 message = "No Transcription"
511 whoFrom = voicemailData["name"]
512 when = voicemailData["time"]
513 voicemailData["messageParts"] = ((whoFrom, message, when), )
516 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
517 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
518 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
520 def _parse_sms(self, smsHtml):
521 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
522 for messageId, messageHtml in itergroup(splitSms[1:], 2):
523 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
524 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
525 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
526 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
527 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
529 nameGroup = self._voicemailNameRegex.search(messageHtml)
530 name = nameGroup.group(1).strip() if nameGroup else ""
531 numberGroup = self._voicemailNumberRegex.search(messageHtml)
532 number = numberGroup.group(1).strip() if numberGroup else ""
533 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
534 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
535 contactIdGroup = self._messagesContactID.search(messageHtml)
536 contactId = contactIdGroup.group(1).strip() if contactIdGroup else number
538 fromGroups = self._smsFromRegex.finditer(messageHtml)
539 fromParts = (group.group(1).strip() for group in fromGroups)
540 textGroups = self._smsTextRegex.finditer(messageHtml)
541 textParts = (group.group(1).strip() for group in textGroups)
542 timeGroups = self._smsTimeRegex.finditer(messageHtml)
543 timeParts = (group.group(1).strip() for group in timeGroups)
545 messageParts = itertools.izip(fromParts, textParts, timeParts)
548 "id": messageId.strip(),
549 "contactId": contactId,
552 "relTime": relativeTime,
553 "prettyNumber": prettyNumber,
556 "messageParts": messageParts,
559 def _decorate_sms(self, parsedTexts):
563 def set_sane_callback(backend):
565 Try to set a sane default callback number on these preferences
566 1) 1747 numbers ( Gizmo )
567 2) anything with gizmo in the name
568 3) anything with computer in the name
571 numbers = backend.get_callback_numbers()
573 priorityOrderedCriteria = [
581 for numberCriteria, descriptionCriteria in priorityOrderedCriteria:
582 for number, description in numbers.iteritems():
583 if numberCriteria is not None and re.compile(numberCriteria).match(number) is None:
585 if descriptionCriteria is not None and re.compile(descriptionCriteria).match(description) is None:
587 backend.set_callback_number(number)
591 def sort_messages(allMessages):
592 sortableAllMessages = [
593 (message["time"], message)
594 for message in allMessages
596 sortableAllMessages.sort(reverse=True)
599 for (exactTime, message) in sortableAllMessages
603 def decorate_message(messageData):
604 exactTime = messageData["time"]
605 if messageData["name"]:
606 header = messageData["name"]
607 elif messageData["prettyNumber"]:
608 header = messageData["prettyNumber"]
611 number = messageData["number"]
612 relativeTime = messageData["relTime"]
614 messageParts = list(messageData["messageParts"])
615 if len(messageParts) == 0:
616 messages = ("No Transcription", )
617 elif len(messageParts) == 1:
618 messages = (messageParts[0][1], )
621 "<b>%s</b>: %s" % (messagePart[0], messagePart[-1])
622 for messagePart in messageParts
625 decoratedResults = header, number, relativeTime, messages
626 return decoratedResults
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)
648 for message in sort_messages(backend.get_messages()):
649 pprint.pprint(decorate_message(message))
654 if __name__ == "__main__":
656 logging.basicConfig(level=logging.DEBUG)
657 test_backend(sys.argv[1], sys.argv[2])