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
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 class GVDialer(object):
69 This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
70 the functions include login, setting up a callback number, and initalting a callback
73 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
74 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
75 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
76 _validateRe = re.compile("^[0-9]{10,}$")
77 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
79 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
80 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
81 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
83 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
84 _smsURL = "https://www.google.com/voice/m/sendsms"
85 _contactsURL = "https://www.google.com/voice/mobile/contacts"
86 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
88 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
89 _setforwardURL = "https://www.google.com//voice/m/setphone"
90 _accountNumberURL = "https://www.google.com/voice/mobile"
91 _forwardURL = "https://www.google.com/voice/mobile/phones"
93 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
94 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
95 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
96 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
97 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
98 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
100 def __init__(self, cookieFile = None):
101 # Important items in this function are the setup of the browser emulation and cookie file
102 self._browser = browser_emu.MozillaEmulator(1)
103 if cookieFile is None:
104 cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
105 self._browser.cookies.filename = cookieFile
106 if os.path.isfile(cookieFile):
107 self._browser.cookies.load()
110 self._accountNum = None
111 self._lastAuthed = 0.0
112 self._callbackNumber = ""
113 self._callbackNumbers = {}
115 self.__contacts = None
117 def is_authed(self, force = False):
119 Attempts to detect a current session
120 @note Once logged in try not to reauth more than once a minute.
121 @returns If authenticated
124 if (time.time() - self._lastAuthed) < 60 and not force:
128 self._grab_account_info()
129 except StandardError, e:
130 warnings.warn(traceback.format_exc())
133 self._browser.cookies.save()
134 self._lastAuthed = time.time()
137 def login(self, username, password):
139 Attempt to login to grandcentral
140 @returns Whether login was successful or not
145 loginPostData = urllib.urlencode({
148 'service': "grandcentral",
151 "PersistentCookie": "yes",
155 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
156 except urllib2.URLError, e:
157 warnings.warn(traceback.format_exc())
158 raise RuntimeError("%s is not accesible" % self._loginURL)
160 return self.is_authed()
163 self._lastAuthed = 0.0
164 self._browser.cookies.clear()
165 self._browser.cookies.save()
169 def dial(self, number):
171 This is the main function responsible for initating the callback
173 number = self._send_validation(number)
175 clickToCallData = urllib.urlencode({
177 "phone": self._callbackNumber,
178 "_rnr_se": self._token,
181 'Referer' : 'https://google.com/voice/m/callsms',
183 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
184 except urllib2.URLError, e:
185 warnings.warn(traceback.format_exc())
186 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
188 if self._gvDialingStrRe.search(callSuccessPage) is None:
189 raise RuntimeError("Google Voice returned an error")
193 def send_sms(self, number, message):
194 number = self._send_validation(number)
195 message = saxutils.escape(message)
197 smsData = urllib.urlencode({
200 "_rnr_se": self._token,
205 'Referer' : 'https://google.com/voice/m/sms',
207 smsSuccessPage = self._browser.download(self._smsURL, smsData, None, otherData)
208 except urllib2.URLError, e:
209 warnings.warn(traceback.format_exc())
210 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
214 def clear_caches(self):
215 self.__contacts = None
217 def is_valid_syntax(self, number):
219 @returns If This number be called ( syntax validation only )
221 return self._validateRe.match(number) is not None
223 def get_account_number(self):
225 @returns The grand central phone number
227 return self._accountNum
229 def set_sane_callback(self):
231 Try to set a sane default callback number on these preferences
232 1) 1747 numbers ( Gizmo )
233 2) anything with gizmo in the name
234 3) anything with computer in the name
237 numbers = self.get_callback_numbers()
239 for number, description in numbers.iteritems():
240 if re.compile(r"""1747""").match(number) is not None:
241 self.set_callback_number(number)
244 for number, description in numbers.iteritems():
245 if re.compile(r"""gizmo""", re.I).search(description) is not None:
246 self.set_callback_number(number)
249 for number, description in numbers.iteritems():
250 if re.compile(r"""computer""", re.I).search(description) is not None:
251 self.set_callback_number(number)
254 for number, description in numbers.iteritems():
255 self.set_callback_number(number)
258 def get_callback_numbers(self):
260 @returns a dictionary mapping call back numbers to descriptions
261 @note These results are cached for 30 minutes.
263 if time.time() - self._lastAuthed < 1800 or self.is_authed():
264 return self._callbackNumbers
268 def set_callback_number(self, callbacknumber):
270 Set the number that grandcental calls
271 @param callbacknumber should be a proper 10 digit number
273 self._callbackNumber = callbacknumber
274 callbackPostData = urllib.urlencode({
275 '_rnr_se': self._token,
276 'phone': callbacknumber
279 callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
280 except urllib2.URLError, e:
281 warnings.warn(traceback.format_exc())
282 raise RuntimeError("%s is not accesible" % self._setforwardURL)
284 # @bug This does not seem to be keeping on my tablet (but works on the
285 # desktop), or the reading isn't working too well
286 self._browser.cookies.save()
289 def get_callback_number(self):
291 @returns Current callback number or None
293 for c in self._browser.cookies:
294 if c.name == "gv-ph":
296 return self._callbackNumber
298 def get_recent(self):
300 @todo Sort this stuff
301 @returns Iterable of (personsName, phoneNumber, date, action)
304 (exactDate, name, number, relativeDate, action)
305 for (name, number, exactDate, relativeDate, action) in self._get_recent()
307 sortedRecent.sort(reverse = True)
308 for exactDate, name, number, relativeDate, action in sortedRecent:
309 yield name, number, relativeDate, action
311 def get_addressbooks(self):
313 @returns Iterable of (Address Book Factory, Book Id, Book Name)
317 def open_addressbook(self, bookId):
321 def contact_source_short_name(contactId):
326 return "Google Voice"
328 def get_contacts(self):
330 @returns Iterable of (contact id, contact name)
332 if self.__contacts is None:
335 contactsPagesUrls = [self._contactsURL]
336 for contactsPageUrl in contactsPagesUrls:
338 contactsPage = self._browser.download(contactsPageUrl)
339 except urllib2.URLError, e:
340 warnings.warn(traceback.format_exc())
341 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
342 for contact_match in self._contactsRe.finditer(contactsPage):
343 contactId = contact_match.group(1)
344 contactName = saxutils.unescape(contact_match.group(2))
345 contact = contactId, contactName
346 self.__contacts.append(contact)
349 next_match = self._contactsNextRe.match(contactsPage)
350 if next_match is not None:
351 newContactsPageUrl = self._contactsURL + next_match.group(1)
352 contactsPagesUrls.append(newContactsPageUrl)
354 for contact in self.__contacts:
357 def get_contact_details(self, contactId):
359 @returns Iterable of (Phone Type, Phone Number)
362 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
363 except urllib2.URLError, e:
364 warnings.warn(traceback.format_exc())
365 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
367 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
368 phoneNumber = detail_match.group(1)
369 phoneType = saxutils.unescape(detail_match.group(2))
370 yield (phoneType, phoneNumber)
372 def get_messages(self):
374 voicemailPage = self._browser.download(self._voicemailURL)
375 except urllib2.URLError, e:
376 warnings.warn(traceback.format_exc())
377 raise RuntimeError("%s is not accesible" % self._voicemailURL)
380 smsPage = self._browser.download(self._smsURL)
381 except urllib2.URLError, e:
382 warnings.warn(traceback.format_exc())
383 raise RuntimeError("%s is not accesible" % self._smsURL)
385 voicemailHtml = self._grab_html(voicemailPage)
386 smsHtml = self._grab_html(smsPage)
396 def _grab_json(self, flatXml):
397 xmlTree = ElementTree.fromstring(flatXml)
398 jsonElement = xmlTree.getchildren()[0]
399 flatJson = jsonElement.text
400 jsonTree = parse_json(flatJson)
403 def _grab_html(self, flatXml):
404 xmlTree = ElementTree.fromstring(flatXml)
405 htmlElement = xmlTree.getchildren()[1]
406 flatHtml = htmlElement.text
409 def _grab_account_info(self):
410 page = self._browser.download(self._forwardURL)
412 tokenGroup = self._tokenRe.search(page)
413 if tokenGroup is None:
414 raise RuntimeError("Could not extract authentication token from GoogleVoice")
415 self._token = tokenGroup.group(1)
417 anGroup = self._accountNumRe.search(page)
419 raise RuntimeError("Could not extract account number from GoogleVoice")
420 self._accountNum = anGroup.group(1)
422 self._callbackNumbers = {}
423 for match in self._callbackRe.finditer(page):
424 callbackNumber = match.group(2)
425 callbackName = match.group(1)
426 self._callbackNumbers[callbackNumber] = callbackName
428 def _send_validation(self, number):
429 if not self.is_valid_syntax(number):
430 raise ValueError('Number is not valid: "%s"' % number)
431 elif not self.is_authed():
432 raise RuntimeError("Not Authenticated")
434 if len(number) == 11 and number[0] == 1:
435 # Strip leading 1 from 11 digit dialing
439 def _get_recent(self):
441 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
444 self._receivedCallsURL,
445 self._missedCallsURL,
446 self._placedCallsURL,
449 flatXml = self._browser.download(url)
450 except urllib2.URLError, e:
451 warnings.warn(traceback.format_exc())
452 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
454 allRecentData = self._grab_json(flatXml)
455 for recentCallData in allRecentData["messages"].itervalues():
456 number = recentCallData["displayNumber"]
457 exactDate = recentCallData["displayStartDateTime"]
458 relativeDate = recentCallData["relativeStartTime"]
461 for label in recentCallData["labels"]
462 if label.lower() != "all" and label.lower() != "inbox"
464 number = saxutils.unescape(number)
465 exactDate = saxutils.unescape(exactDate)
466 exactDate = datetime.datetime.strptime(exactDate, "%m/%d/%y %I:%M %p")
467 relativeDate = saxutils.unescape(relativeDate)
468 action = saxutils.unescape(action)
469 yield "", number, exactDate, relativeDate, action
472 def test_backend(username, password):
475 print "Authenticated: ", backend.is_authed()
476 print "Login?: ", backend.login(username, password)
477 print "Authenticated: ", backend.is_authed()
478 print "Token: ", backend._token
479 print "Account: ", backend.get_account_number()
480 print "Callback: ", backend.get_callback_number()
481 print "All Callback: ",
482 pprint.pprint(backend.get_callback_numbers())
484 # pprint.pprint(list(backend.get_recent()))
485 # print "Contacts: ",
486 # for contact in backend.get_contacts():
488 # pprint.pprint(list(backend.get_contact_details(contact[0])))