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 GVDialer(object):
102 This class encapsulates all of the knowledge necessary to interact with the GoogleVoice servers
103 the functions include login, setting up a callback number, and initalting a callback
106 def __init__(self, cookieFile = None):
107 # Important items in this function are the setup of the browser emulation and cookie file
108 self._browser = browser_emu.MozillaEmulator(1)
109 if cookieFile is None:
110 cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
111 self._browser.cookies.filename = cookieFile
112 if os.path.isfile(cookieFile):
113 self._browser.cookies.load()
116 self._accountNum = ""
117 self._lastAuthed = 0.0
118 self._callbackNumber = ""
119 self._callbackNumbers = {}
121 self.__contacts = None
123 def is_authed(self, force = False):
125 Attempts to detect a current session
126 @note Once logged in try not to reauth more than once a minute.
127 @returns If authenticated
130 if (time.time() - self._lastAuthed) < 120 and not force:
134 self._grab_account_info()
136 logging.exception(str(e))
139 self._browser.cookies.save()
140 self._lastAuthed = time.time()
143 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
145 def login(self, username, password):
147 Attempt to login to GoogleVoice
148 @returns Whether login was successful or not
153 loginPostData = urllib.urlencode({
156 'service': "grandcentral",
159 "PersistentCookie": "yes",
163 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
164 except urllib2.URLError, e:
165 logging.exception(str(e))
166 raise RuntimeError("%s is not accesible" % self._loginURL)
168 return self.is_authed()
171 self._lastAuthed = 0.0
172 self._browser.cookies.clear()
173 self._browser.cookies.save()
177 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
178 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
180 def dial(self, number):
182 This is the main function responsible for initating the callback
184 number = self._send_validation(number)
186 clickToCallData = urllib.urlencode({
188 "phone": self._callbackNumber,
189 "_rnr_se": self._token,
192 'Referer' : 'https://google.com/voice/m/callsms',
194 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
195 except urllib2.URLError, e:
196 logging.exception(str(e))
197 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
199 if self._gvDialingStrRe.search(callSuccessPage) is None:
200 raise RuntimeError("Google Voice returned an error")
204 _sendSmsURL = "https://www.google.com/voice/m/sendsms"
206 def send_sms(self, number, message):
207 number = self._send_validation(number)
209 smsData = urllib.urlencode({
212 "_rnr_se": self._token,
217 'Referer' : 'https://google.com/voice/m/sms',
219 smsSuccessPage = self._browser.download(self._sendSmsURL, smsData, None, otherData)
220 except urllib2.URLError, e:
221 logging.exception(str(e))
222 raise RuntimeError("%s is not accesible" % self._sendSmsURL)
226 def clear_caches(self):
227 self.__contacts = None
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 set_sane_callback(self):
245 Try to set a sane default callback number on these preferences
246 1) 1747 numbers ( Gizmo )
247 2) anything with gizmo in the name
248 3) anything with computer in the name
251 numbers = self.get_callback_numbers()
253 for number, description in numbers.iteritems():
254 if re.compile(r"""1747""").match(number) is not None:
255 self.set_callback_number(number)
258 for number, description in numbers.iteritems():
259 if re.compile(r"""gizmo""", re.I).search(description) is not None:
260 self.set_callback_number(number)
263 for number, description in numbers.iteritems():
264 if re.compile(r"""computer""", re.I).search(description) is not None:
265 self.set_callback_number(number)
268 for number, description in numbers.iteritems():
269 self.set_callback_number(number)
272 def get_callback_numbers(self):
274 @returns a dictionary mapping call back numbers to descriptions
275 @note These results are cached for 30 minutes.
277 if not self.is_authed():
279 return self._callbackNumbers
281 _setforwardURL = "https://www.google.com//voice/m/setphone"
283 def set_callback_number(self, callbacknumber):
285 Set the number that GoogleVoice calls
286 @param callbacknumber should be a proper 10 digit number
288 self._callbackNumber = callbacknumber
291 def get_callback_number(self):
293 @returns Current callback number or None
295 return self._callbackNumber
297 def get_recent(self):
299 @returns Iterable of (personsName, phoneNumber, date, action)
302 (exactDate, name, number, relativeDate, action)
303 for (name, number, exactDate, relativeDate, action) in self._get_recent()
305 sortedRecent.sort(reverse = True)
306 for exactDate, name, number, relativeDate, action in sortedRecent:
307 yield name, number, relativeDate, action
309 def get_addressbooks(self):
311 @returns Iterable of (Address Book Factory, Book Id, Book Name)
315 def open_addressbook(self, bookId):
319 def contact_source_short_name(contactId):
324 return "Google Voice"
326 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
327 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
328 _contactsURL = "https://www.google.com/voice/mobile/contacts"
330 def get_contacts(self):
332 @returns Iterable of (contact id, contact name)
334 if self.__contacts is None:
337 contactsPagesUrls = [self._contactsURL]
338 for contactsPageUrl in contactsPagesUrls:
340 contactsPage = self._browser.download(contactsPageUrl)
341 except urllib2.URLError, e:
342 logging.exception(str(e))
343 raise RuntimeError("%s is not accesible" % contactsPageUrl)
344 for contact_match in self._contactsRe.finditer(contactsPage):
345 contactId = contact_match.group(1)
346 contactName = saxutils.unescape(contact_match.group(2))
347 contact = contactId, contactName
348 self.__contacts.append(contact)
351 next_match = self._contactsNextRe.match(contactsPage)
352 if next_match is not None:
353 newContactsPageUrl = self._contactsURL + next_match.group(1)
354 contactsPagesUrls.append(newContactsPageUrl)
356 for contact in self.__contacts:
359 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9+\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
360 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
362 def get_contact_details(self, contactId):
364 @returns Iterable of (Phone Type, Phone Number)
367 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
368 except urllib2.URLError, e:
369 logging.exception(str(e))
370 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
372 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
373 phoneNumber = detail_match.group(1)
374 phoneType = saxutils.unescape(detail_match.group(2))
375 yield (phoneType, phoneNumber)
377 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
378 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
380 def get_messages(self):
382 voicemailPage = self._browser.download(self._voicemailURL)
383 except urllib2.URLError, e:
384 logging.exception(str(e))
385 raise RuntimeError("%s is not accesible" % self._voicemailURL)
386 voicemailHtml = self._grab_html(voicemailPage)
387 parsedVoicemail = self._parse_voicemail(voicemailHtml)
388 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
391 smsPage = self._browser.download(self._smsURL)
392 except urllib2.URLError, e:
393 logging.exception(str(e))
394 raise RuntimeError("%s is not accesible" % self._smsURL)
395 smsHtml = self._grab_html(smsPage)
396 parsedSms = self._parse_sms(smsHtml)
397 decoratedSms = self._decorate_sms(parsedSms)
399 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
400 sortedMessages = list(allMessages)
401 sortedMessages.sort(reverse=True)
402 for exactDate, header, number, relativeDate, message in sortedMessages:
403 yield header, number, relativeDate, message
405 def _grab_json(self, flatXml):
406 xmlTree = ElementTree.fromstring(flatXml)
407 jsonElement = xmlTree.getchildren()[0]
408 flatJson = jsonElement.text
409 jsonTree = parse_json(flatJson)
412 def _grab_html(self, flatXml):
413 xmlTree = ElementTree.fromstring(flatXml)
414 htmlElement = xmlTree.getchildren()[1]
415 flatHtml = htmlElement.text
418 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
419 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
420 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
421 _forwardURL = "https://www.google.com/voice/mobile/phones"
423 def _grab_account_info(self):
424 page = self._browser.download(self._forwardURL)
426 tokenGroup = self._tokenRe.search(page)
427 if tokenGroup is None:
428 raise RuntimeError("Could not extract authentication token from GoogleVoice")
429 self._token = tokenGroup.group(1)
431 anGroup = self._accountNumRe.search(page)
432 if anGroup is not None:
433 self._accountNum = anGroup.group(1)
435 logging.debug("Could not extract account number from GoogleVoice")
437 self._callbackNumbers = {}
438 for match in self._callbackRe.finditer(page):
439 callbackNumber = match.group(2)
440 callbackName = match.group(1)
441 self._callbackNumbers[callbackNumber] = callbackName
443 def _send_validation(self, number):
444 if not self.is_valid_syntax(number):
445 raise ValueError('Number is not valid: "%s"' % number)
446 elif not self.is_authed():
447 raise RuntimeError("Not Authenticated")
449 if len(number) == 11 and number[0] == 1:
450 # Strip leading 1 from 11 digit dialing
454 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
455 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
456 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
457 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
459 def _get_recent(self):
461 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
464 ("Received", self._receivedCallsURL),
465 ("Missed", self._missedCallsURL),
466 ("Placed", self._placedCallsURL),
469 flatXml = self._browser.download(url)
470 except urllib2.URLError, e:
471 logging.exception(str(e))
472 raise RuntimeError("%s is not accesible" % url)
474 allRecentHtml = self._grab_html(flatXml)
475 allRecentData = self._parse_voicemail(allRecentHtml)
476 for recentCallData in allRecentData:
477 exactTime = recentCallData["time"]
478 if recentCallData["name"]:
479 header = recentCallData["name"]
480 elif recentCallData["prettyNumber"]:
481 header = recentCallData["prettyNumber"]
482 elif recentCallData["location"]:
483 header = recentCallData["location"]
486 yield header, recentCallData["number"], exactTime, recentCallData["relTime"], action
488 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
489 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
490 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
491 _voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
492 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
493 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
494 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
495 #_voicemailMessageRegex = re.compile(r"""<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
496 #_voicemailMessageRegex = re.compile(r"""<a .*? class="gc-message-mni">(.*?)</a>""", re.MULTILINE)
497 _voicemailMessageRegex = re.compile(r"""(<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>|<a .*? class="gc-message-mni">(.*?)</a>)""", re.MULTILINE)
500 def _interpret_voicemail_regex(group):
501 quality, content, number = group.group(2), group.group(3), group.group(4)
502 if quality is not None and content is not None:
503 return quality, content
504 elif number is not None:
505 return "high", number
507 def _parse_voicemail(self, voicemailHtml):
508 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
509 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
510 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
511 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
512 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
513 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
514 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
515 locationGroup = self._voicemailLocationRegex.search(messageHtml)
516 location = locationGroup.group(1).strip() if locationGroup else ""
518 nameGroup = self._voicemailNameRegex.search(messageHtml)
519 name = nameGroup.group(1).strip() if nameGroup else ""
520 numberGroup = self._voicemailNumberRegex.search(messageHtml)
521 number = numberGroup.group(1).strip() if numberGroup else ""
522 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
523 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
525 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
527 self._interpret_voicemail_regex(group)
528 for group in messageGroups
529 ) if messageGroups else ()
532 "id": messageId.strip(),
535 "relTime": relativeTime,
536 "prettyNumber": prettyNumber,
538 "location": location,
539 "messageParts": messageParts,
542 def _decorate_voicemail(self, parsedVoicemail):
543 messagePartFormat = {
548 for voicemailData in parsedVoicemail:
549 exactTime = voicemailData["time"]
550 if voicemailData["name"]:
551 header = voicemailData["name"]
552 elif voicemailData["prettyNumber"]:
553 header = voicemailData["prettyNumber"]
554 elif voicemailData["location"]:
555 header = voicemailData["location"]
559 messagePartFormat[quality] % part
560 for (quality, part) in voicemailData["messageParts"]
563 message = "No Transcription"
564 yield exactTime, header, voicemailData["number"], voicemailData["relTime"], (message, )
566 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
567 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
568 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
570 def _parse_sms(self, smsHtml):
571 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
572 for messageId, messageHtml in itergroup(splitSms[1:], 2):
573 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
574 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
575 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
576 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
577 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
579 nameGroup = self._voicemailNameRegex.search(messageHtml)
580 name = nameGroup.group(1).strip() if nameGroup else ""
581 numberGroup = self._voicemailNumberRegex.search(messageHtml)
582 number = numberGroup.group(1).strip() if numberGroup else ""
583 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
584 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
586 fromGroups = self._smsFromRegex.finditer(messageHtml)
587 fromParts = (group.group(1).strip() for group in fromGroups)
588 textGroups = self._smsTextRegex.finditer(messageHtml)
589 textParts = (group.group(1).strip() for group in textGroups)
590 timeGroups = self._smsTimeRegex.finditer(messageHtml)
591 timeParts = (group.group(1).strip() for group in timeGroups)
593 messageParts = itertools.izip(fromParts, textParts, timeParts)
596 "id": messageId.strip(),
599 "relTime": relativeTime,
600 "prettyNumber": prettyNumber,
602 "messageParts": messageParts,
605 def _decorate_sms(self, parsedSms):
606 for messageData in parsedSms:
607 exactTime = messageData["time"]
608 if messageData["name"]:
609 header = messageData["name"]
610 elif messageData["prettyNumber"]:
611 header = messageData["prettyNumber"]
614 number = messageData["number"]
615 relativeTime = messageData["relTime"]
617 "<b>%s</b>: %s" % (messagePart[0], messagePart[-1])
618 for messagePart in messageData["messageParts"]
621 messages = ("No Transcription", )
622 yield exactTime, header, number, relativeTime, messages
625 def test_backend(username, password):
627 print "Authenticated: ", backend.is_authed()
628 print "Login?: ", backend.login(username, password)
629 print "Authenticated: ", backend.is_authed()
630 # print "Token: ", backend._token
631 print "Account: ", backend.get_account_number()
632 print "Callback: ", backend.get_callback_number()
633 # print "All Callback: ",
635 # pprint.pprint(backend.get_callback_numbers())
637 # pprint.pprint(list(backend.get_recent()))
638 # print "Contacts: ",
639 # for contact in backend.get_contacts():
641 # pprint.pprint(list(backend.get_contact_details(contact[0])))
642 for message in backend.get_messages():
643 pprint.pprint(message)