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)
196 smsData = urllib.urlencode({
199 "_rnr_se": self._token,
204 'Referer' : 'https://google.com/voice/m/sms',
206 smsSuccessPage = self._browser.download(self._smsURL, smsData, None, otherData)
207 except urllib2.URLError, e:
208 warnings.warn(traceback.format_exc())
209 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
213 def clear_caches(self):
214 self.__contacts = None
216 def is_valid_syntax(self, number):
218 @returns If This number be called ( syntax validation only )
220 return self._validateRe.match(number) is not None
222 def get_account_number(self):
224 @returns The grand central phone number
226 return self._accountNum
228 def set_sane_callback(self):
230 Try to set a sane default callback number on these preferences
231 1) 1747 numbers ( Gizmo )
232 2) anything with gizmo in the name
233 3) anything with computer in the name
236 numbers = self.get_callback_numbers()
238 for number, description in numbers.iteritems():
239 if re.compile(r"""1747""").match(number) is not None:
240 self.set_callback_number(number)
243 for number, description in numbers.iteritems():
244 if re.compile(r"""gizmo""", re.I).search(description) is not None:
245 self.set_callback_number(number)
248 for number, description in numbers.iteritems():
249 if re.compile(r"""computer""", re.I).search(description) is not None:
250 self.set_callback_number(number)
253 for number, description in numbers.iteritems():
254 self.set_callback_number(number)
257 def get_callback_numbers(self):
259 @returns a dictionary mapping call back numbers to descriptions
260 @note These results are cached for 30 minutes.
262 if time.time() - self._lastAuthed < 1800 or self.is_authed():
263 return self._callbackNumbers
267 def set_callback_number(self, callbacknumber):
269 Set the number that grandcental calls
270 @param callbacknumber should be a proper 10 digit number
272 self._callbackNumber = callbacknumber
273 callbackPostData = urllib.urlencode({
274 '_rnr_se': self._token,
275 'phone': callbacknumber
278 callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
279 except urllib2.URLError, e:
280 warnings.warn(traceback.format_exc())
281 raise RuntimeError("%s is not accesible" % self._setforwardURL)
283 # @bug This does not seem to be keeping on my tablet (but works on the
284 # desktop), or the reading isn't working too well
285 self._browser.cookies.save()
288 def get_callback_number(self):
290 @returns Current callback number or None
292 for c in self._browser.cookies:
293 if c.name == "gv-ph":
295 return self._callbackNumber
297 def get_recent(self):
299 @todo Sort this stuff
300 @returns Iterable of (personsName, phoneNumber, date, action)
303 (exactDate, name, number, relativeDate, action)
304 for (name, number, exactDate, relativeDate, action) in self._get_recent()
306 sortedRecent.sort(reverse = True)
307 for exactDate, name, number, relativeDate, action in sortedRecent:
308 yield name, number, relativeDate, action
310 def get_addressbooks(self):
312 @returns Iterable of (Address Book Factory, Book Id, Book Name)
316 def open_addressbook(self, bookId):
320 def contact_source_short_name(contactId):
325 return "Google Voice"
327 def get_contacts(self):
329 @returns Iterable of (contact id, contact name)
331 if self.__contacts is None:
334 contactsPagesUrls = [self._contactsURL]
335 for contactsPageUrl in contactsPagesUrls:
337 contactsPage = self._browser.download(contactsPageUrl)
338 except urllib2.URLError, e:
339 warnings.warn(traceback.format_exc())
340 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
341 for contact_match in self._contactsRe.finditer(contactsPage):
342 contactId = contact_match.group(1)
343 contactName = saxutils.unescape(contact_match.group(2))
344 contact = contactId, contactName
345 self.__contacts.append(contact)
348 next_match = self._contactsNextRe.match(contactsPage)
349 if next_match is not None:
350 newContactsPageUrl = self._contactsURL + next_match.group(1)
351 contactsPagesUrls.append(newContactsPageUrl)
353 for contact in self.__contacts:
356 def get_contact_details(self, contactId):
358 @returns Iterable of (Phone Type, Phone Number)
361 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
362 except urllib2.URLError, e:
363 warnings.warn(traceback.format_exc())
364 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
366 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
367 phoneNumber = detail_match.group(1)
368 phoneType = saxutils.unescape(detail_match.group(2))
369 yield (phoneType, phoneNumber)
371 def get_messages(self):
373 voicemailPage = self._browser.download(self._voicemailURL)
374 except urllib2.URLError, e:
375 warnings.warn(traceback.format_exc())
376 raise RuntimeError("%s is not accesible" % self._voicemailURL)
379 smsPage = self._browser.download(self._smsURL)
380 except urllib2.URLError, e:
381 warnings.warn(traceback.format_exc())
382 raise RuntimeError("%s is not accesible" % self._smsURL)
384 voicemailHtml = self._grab_html(voicemailPage)
385 smsHtml = self._grab_html(smsPage)
395 def _grab_json(self, flatXml):
396 xmlTree = ElementTree.fromstring(flatXml)
397 jsonElement = xmlTree.getchildren()[0]
398 flatJson = jsonElement.text
399 jsonTree = parse_json(flatJson)
402 def _grab_html(self, flatXml):
403 xmlTree = ElementTree.fromstring(flatXml)
404 htmlElement = xmlTree.getchildren()[1]
405 flatHtml = htmlElement.text
408 def _grab_account_info(self):
409 page = self._browser.download(self._forwardURL)
411 tokenGroup = self._tokenRe.search(page)
412 if tokenGroup is None:
413 raise RuntimeError("Could not extract authentication token from GoogleVoice")
414 self._token = tokenGroup.group(1)
416 anGroup = self._accountNumRe.search(page)
418 raise RuntimeError("Could not extract account number from GoogleVoice")
419 self._accountNum = anGroup.group(1)
421 self._callbackNumbers = {}
422 for match in self._callbackRe.finditer(page):
423 callbackNumber = match.group(2)
424 callbackName = match.group(1)
425 self._callbackNumbers[callbackNumber] = callbackName
427 def _send_validation(self, number):
428 if not self.is_valid_syntax(number):
429 raise ValueError('Number is not valid: "%s"' % number)
430 elif not self.is_authed():
431 raise RuntimeError("Not Authenticated")
433 if len(number) == 11 and number[0] == 1:
434 # Strip leading 1 from 11 digit dialing
438 def _get_recent(self):
440 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
443 self._receivedCallsURL,
444 self._missedCallsURL,
445 self._placedCallsURL,
448 flatXml = self._browser.download(url)
449 except urllib2.URLError, e:
450 warnings.warn(traceback.format_exc())
451 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
453 allRecentData = self._grab_json(flatXml)
454 for recentCallData in allRecentData["messages"].itervalues():
455 number = recentCallData["displayNumber"]
456 exactDate = recentCallData["displayStartDateTime"]
457 relativeDate = recentCallData["relativeStartTime"]
460 for label in recentCallData["labels"]
461 if label.lower() != "all" and label.lower() != "inbox"
463 number = saxutils.unescape(number)
464 exactDate = saxutils.unescape(exactDate)
465 exactDate = datetime.datetime.strptime(exactDate, "%m/%d/%y %I:%M %p")
466 relativeDate = saxutils.unescape(relativeDate)
467 action = saxutils.unescape(action)
468 yield "", number, exactDate, relativeDate, action
471 def test_backend(username, password):
474 print "Authenticated: ", backend.is_authed()
475 print "Login?: ", backend.login(username, password)
476 print "Authenticated: ", backend.is_authed()
477 print "Token: ", backend._token
478 print "Account: ", backend.get_account_number()
479 print "Callback: ", backend.get_callback_number()
480 print "All Callback: ",
481 pprint.pprint(backend.get_callback_numbers())
483 # pprint.pprint(list(backend.get_recent()))
484 # print "Contacts: ",
485 # for contact in backend.get_contacts():
487 # pprint.pprint(list(backend.get_contact_details(contact[0])))