4 DialCentral - Front end for Google's Grand Central 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
38 from xml.sax import saxutils
40 from xml.etree import ElementTree
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 interace with the grandcentral 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 = None
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) < 60 and not force:
135 self._grab_account_info()
136 except StandardError, e:
137 warnings.warn(traceback.format_exc())
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 grandcentral
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 warnings.warn(traceback.format_exc())
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 warnings.warn(traceback.format_exc())
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 warnings.warn(traceback.format_exc())
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 grand central phone number
242 return self._accountNum
244 def set_sane_callback(self):
246 Try to set a sane default callback number on these preferences
247 1) 1747 numbers ( Gizmo )
248 2) anything with gizmo in the name
249 3) anything with computer in the name
252 numbers = self.get_callback_numbers()
254 for number, description in numbers.iteritems():
255 if re.compile(r"""1747""").match(number) is not None:
256 self.set_callback_number(number)
259 for number, description in numbers.iteritems():
260 if re.compile(r"""gizmo""", re.I).search(description) is not None:
261 self.set_callback_number(number)
264 for number, description in numbers.iteritems():
265 if re.compile(r"""computer""", re.I).search(description) is not None:
266 self.set_callback_number(number)
269 for number, description in numbers.iteritems():
270 self.set_callback_number(number)
273 def get_callback_numbers(self):
275 @returns a dictionary mapping call back numbers to descriptions
276 @note These results are cached for 30 minutes.
278 if time.time() - self._lastAuthed < 1800 or self.is_authed():
279 return self._callbackNumbers
283 _setforwardURL = "https://www.google.com//voice/m/setphone"
285 def set_callback_number(self, callbacknumber):
287 Set the number that grandcental calls
288 @param callbacknumber should be a proper 10 digit number
290 self._callbackNumber = callbacknumber
291 callbackPostData = urllib.urlencode({
292 '_rnr_se': self._token,
293 'phone': callbacknumber
296 callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
297 except urllib2.URLError, e:
298 warnings.warn(traceback.format_exc())
299 raise RuntimeError("%s is not accesible" % self._setforwardURL)
301 self._browser.cookies.save()
304 def get_callback_number(self):
306 @returns Current callback number or None
308 for c in self._browser.cookies:
309 if c.name == "gv-ph":
311 return self._callbackNumber
313 def get_recent(self):
315 @returns Iterable of (personsName, phoneNumber, date, action)
318 (exactDate, name, number, relativeDate, action)
319 for (name, number, exactDate, relativeDate, action) in self._get_recent()
321 sortedRecent.sort(reverse = True)
322 for exactDate, name, number, relativeDate, action in sortedRecent:
323 yield name, number, relativeDate, action
325 def get_addressbooks(self):
327 @returns Iterable of (Address Book Factory, Book Id, Book Name)
331 def open_addressbook(self, bookId):
335 def contact_source_short_name(contactId):
340 return "Google Voice"
342 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
343 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
344 _contactsURL = "https://www.google.com/voice/mobile/contacts"
346 def get_contacts(self):
348 @returns Iterable of (contact id, contact name)
350 if self.__contacts is None:
353 contactsPagesUrls = [self._contactsURL]
354 for contactsPageUrl in contactsPagesUrls:
356 contactsPage = self._browser.download(contactsPageUrl)
357 except urllib2.URLError, e:
358 warnings.warn(traceback.format_exc())
359 raise RuntimeError("%s is not accesible" % contactsPageUrl)
360 for contact_match in self._contactsRe.finditer(contactsPage):
361 contactId = contact_match.group(1)
362 contactName = saxutils.unescape(contact_match.group(2))
363 contact = contactId, contactName
364 self.__contacts.append(contact)
367 next_match = self._contactsNextRe.match(contactsPage)
368 if next_match is not None:
369 newContactsPageUrl = self._contactsURL + next_match.group(1)
370 contactsPagesUrls.append(newContactsPageUrl)
372 for contact in self.__contacts:
375 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
376 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
378 def get_contact_details(self, contactId):
380 @returns Iterable of (Phone Type, Phone Number)
383 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
384 except urllib2.URLError, e:
385 warnings.warn(traceback.format_exc())
386 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
388 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
389 phoneNumber = detail_match.group(1)
390 phoneType = saxutils.unescape(detail_match.group(2))
391 yield (phoneType, phoneNumber)
393 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
394 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
396 def get_messages(self):
398 voicemailPage = self._browser.download(self._voicemailURL)
399 except urllib2.URLError, e:
400 warnings.warn(traceback.format_exc())
401 raise RuntimeError("%s is not accesible" % self._voicemailURL)
402 voicemailHtml = self._grab_html(voicemailPage)
403 parsedVoicemail = self._parse_voicemail(voicemailHtml)
404 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
407 smsPage = self._browser.download(self._smsURL)
408 except urllib2.URLError, e:
409 warnings.warn(traceback.format_exc())
410 raise RuntimeError("%s is not accesible" % self._smsURL)
411 smsHtml = self._grab_html(smsPage)
412 parsedSms = self._parse_sms(smsHtml)
413 decoratedSms = self._decorate_sms(parsedSms)
415 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
416 sortedMessages = list(allMessages)
417 sortedMessages.sort(reverse=True)
418 for exactDate, header, number, relativeDate, message in sortedMessages:
419 yield header, number, relativeDate, message
421 def _grab_json(self, flatXml):
422 xmlTree = ElementTree.fromstring(flatXml)
423 jsonElement = xmlTree.getchildren()[0]
424 flatJson = jsonElement.text
425 jsonTree = parse_json(flatJson)
428 def _grab_html(self, flatXml):
429 xmlTree = ElementTree.fromstring(flatXml)
430 htmlElement = xmlTree.getchildren()[1]
431 flatHtml = htmlElement.text
434 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
435 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
436 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
437 _forwardURL = "https://www.google.com/voice/mobile/phones"
439 def _grab_account_info(self):
440 page = self._browser.download(self._forwardURL)
442 tokenGroup = self._tokenRe.search(page)
443 if tokenGroup is None:
444 raise RuntimeError("Could not extract authentication token from GoogleVoice")
445 self._token = tokenGroup.group(1)
447 anGroup = self._accountNumRe.search(page)
448 if anGroup is not None:
449 self._accountNum = anGroup.group(1)
451 warnings.warn("Could not extract account number from GoogleVoice", UserWarning, 2)
453 self._callbackNumbers = {}
454 for match in self._callbackRe.finditer(page):
455 callbackNumber = match.group(2)
456 callbackName = match.group(1)
457 self._callbackNumbers[callbackNumber] = callbackName
459 def _send_validation(self, number):
460 if not self.is_valid_syntax(number):
461 raise ValueError('Number is not valid: "%s"' % number)
462 elif not self.is_authed():
463 raise RuntimeError("Not Authenticated")
465 if len(number) == 11 and number[0] == 1:
466 # Strip leading 1 from 11 digit dialing
470 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
471 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
472 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
473 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
475 def _get_recent(self):
477 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
480 self._receivedCallsURL,
481 self._missedCallsURL,
482 self._placedCallsURL,
485 flatXml = self._browser.download(url)
486 except urllib2.URLError, e:
487 warnings.warn(traceback.format_exc())
488 raise RuntimeError("%s is not accesible" % url)
490 allRecentData = self._grab_json(flatXml)
491 for recentCallData in allRecentData["messages"].itervalues():
492 number = recentCallData["displayNumber"]
493 exactDate = recentCallData["displayStartDateTime"]
494 relativeDate = recentCallData["relativeStartTime"]
497 for label in recentCallData["labels"]
498 if label.lower() != "all" and label.lower() != "inbox"
500 number = saxutils.unescape(number)
501 exactDate = saxutils.unescape(exactDate)
502 exactDate = datetime.datetime.strptime(exactDate, "%m/%d/%y %I:%M %p")
503 relativeDate = saxutils.unescape(relativeDate)
504 action = saxutils.unescape(action)
505 yield "", number, exactDate, relativeDate, action
507 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class="gc-message.*?">""", re.MULTILINE | re.DOTALL)
508 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
509 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
510 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
511 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
512 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
513 _voicemailMessageRegex = re.compile(r"""<span class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
515 def _parse_voicemail(self, voicemailHtml):
516 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
517 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
518 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
519 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
520 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
521 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
522 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
523 locationGroup = self._voicemailLocationRegex.search(messageHtml)
524 location = locationGroup.group(1).strip() if locationGroup else ""
526 numberGroup = self._voicemailNumberRegex.search(messageHtml)
527 number = numberGroup.group(1).strip() if numberGroup else ""
528 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
529 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
531 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
533 (group.group(1).strip(), group.group(2).strip())
534 for group in messageGroups
535 ) if messageGroups else ()
538 "id": messageId.strip(),
540 "relTime": relativeTime,
541 "prettyNumber": prettyNumber,
543 "location": location,
544 "messageParts": messageParts,
547 def _decorate_voicemail(self, parsedVoicemail):
548 messagePartFormat = {
553 for voicemailData in parsedVoicemail:
554 exactTime = voicemailData["time"]
555 header = "%s %s" % (voicemailData["prettyNumber"], voicemailData["location"])
557 messagePartFormat[quality] % part
558 for (quality, part) in voicemailData["messageParts"]
561 message = "No Transcription"
562 yield exactTime, header, voicemailData["number"], voicemailData["relTime"], message
564 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
565 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
566 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
568 def _parse_sms(self, smsHtml):
569 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
570 for messageId, messageHtml in itergroup(splitSms[1:], 2):
571 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
572 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
573 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
574 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
575 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
577 numberGroup = self._voicemailNumberRegex.search(messageHtml)
578 number = numberGroup.group(1).strip() if numberGroup else ""
579 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
580 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
582 fromGroups = self._smsFromRegex.finditer(messageHtml)
583 fromParts = (group.group(1).strip() for group in fromGroups)
584 textGroups = self._smsTextRegex.finditer(messageHtml)
585 textParts = (group.group(1).strip() for group in textGroups)
586 timeGroups = self._smsTimeRegex.finditer(messageHtml)
587 timeParts = (group.group(1).strip() for group in timeGroups)
589 messageParts = itertools.izip(fromParts, textParts, timeParts)
592 "id": messageId.strip(),
594 "relTime": relativeTime,
595 "prettyNumber": prettyNumber,
597 "messageParts": messageParts,
600 def _decorate_sms(self, parsedSms):
601 for messageData in parsedSms:
602 exactTime = messageData["time"]
603 header = "%s" % (messageData["prettyNumber"])
604 number = messageData["number"]
605 relativeTime = messageData["relTime"]
606 message = "\n".join((
607 "<b>%s (%s)</b>: %s" % messagePart
608 for messagePart in messageData["messageParts"]
611 message = "No Transcription"
612 yield exactTime, header, number, relativeTime, message
615 def test_backend(username, password):
618 print "Authenticated: ", backend.is_authed()
619 print "Login?: ", backend.login(username, password)
620 print "Authenticated: ", backend.is_authed()
621 # print "Token: ", backend._token
622 print "Account: ", backend.get_account_number()
623 print "Callback: ", backend.get_callback_number()
624 # print "All Callback: ",
625 # pprint.pprint(backend.get_callback_numbers())
627 # pprint.pprint(list(backend.get_recent()))
628 # print "Contacts: ",
629 # for contact in backend.get_contacts():
631 # pprint.pprint(list(backend.get_contact_details(contact[0])))