3 # DialCentral - Front end for Google's Grand Central service.
4 # Copyright (C) 2008 Eric Warnke ericew AT gmail DOT com
6 # This library is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU Lesser General Public
8 # License as published by the Free Software Foundation; either
9 # version 2.1 of the License, or (at your option) any later version.
11 # This library is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14 # Lesser General Public License for more details.
16 # You should have received a copy of the GNU Lesser General Public
17 # License along with this library; if not, write to the Free Software
18 # 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
36 from xml.sax import saxutils
38 from xml.etree import ElementTree
48 _TRUE_REGEX = re.compile("true")
49 _FALSE_REGEX = re.compile("false")
53 s = _TRUE_REGEX.sub("True", s)
54 s = _FALSE_REGEX.sub("False", s)
55 return eval(s, {}, {})
58 if simplejson is None:
59 def parse_json(flattened):
60 return safe_eval(flattened)
62 def parse_json(flattened):
63 return simplejson.loads(flattened)
66 class GVDialer(object):
68 This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
69 the functions include login, setting up a callback number, and initalting a callback
72 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
73 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
74 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
75 _validateRe = re.compile("^[0-9]{10,}$")
76 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
78 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
79 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
80 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
82 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
83 _contactsURL = "https://www.google.com/voice/mobile/contacts"
84 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
86 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
87 _setforwardURL = "https://www.google.com//voice/m/setphone"
88 _accountNumberURL = "https://www.google.com/voice/mobile"
89 _forwardURL = "https://www.google.com/voice/mobile/phones"
91 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
92 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
93 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
94 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
96 def __init__(self, cookieFile = None):
97 # Important items in this function are the setup of the browser emulation and cookie file
98 self._browser = browser_emu.MozillaEmulator(1)
99 if cookieFile is None:
100 cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
101 self._browser.cookies.filename = cookieFile
102 if os.path.isfile(cookieFile):
103 self._browser.cookies.load()
106 self._accountNum = None
107 self._lastAuthed = 0.0
108 self._callbackNumber = ""
109 self._callbackNumbers = {}
111 self.__contacts = None
113 def is_authed(self, force = False):
115 Attempts to detect a current session
116 @note Once logged in try not to reauth more than once a minute.
117 @returns If authenticated
120 if (time.time() - self._lastAuthed) < 60 and not force:
124 self._grab_account_info()
125 except StandardError, e:
126 warnings.warn(traceback.format_exc())
129 self._browser.cookies.save()
130 self._lastAuthed = time.time()
133 def login(self, username, password):
135 Attempt to login to grandcentral
136 @returns Whether login was successful or not
141 loginPostData = urllib.urlencode({
144 'service': "grandcentral",
147 "PersistentCookie": "yes",
151 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
152 except urllib2.URLError, e:
153 warnings.warn(traceback.format_exc())
154 raise RuntimeError("%s is not accesible" % self._loginURL)
156 return self.is_authed()
159 self._lastAuthed = 0.0
160 self._browser.cookies.clear()
161 self._browser.cookies.save()
165 def dial(self, number):
167 This is the main function responsible for initating the callback
169 if not self.is_valid_syntax(number):
170 raise ValueError('Number is not valid: "%s"' % number)
171 elif not self.is_authed():
172 raise RuntimeError("Not Authenticated")
174 if len(number) == 11 and number[0] == 1:
175 # Strip leading 1 from 11 digit dialing
179 clickToCallData = urllib.urlencode({
181 "phone": self._callbackNumber,
182 "_rnr_se": self._token,
185 'Referer' : 'https://google.com/voice/m/callsms',
187 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
188 except urllib2.URLError, e:
189 warnings.warn(traceback.format_exc())
190 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
192 if self._gvDialingStrRe.search(callSuccessPage) is None:
193 raise RuntimeError("Google Voice returned an error")
197 def clear_caches(self):
198 self.__contacts = None
200 def is_valid_syntax(self, number):
202 @returns If This number be called ( syntax validation only )
204 return self._validateRe.match(number) is not None
206 def get_account_number(self):
208 @returns The grand central phone number
210 return self._accountNum
212 def set_sane_callback(self):
214 Try to set a sane default callback number on these preferences
215 1) 1747 numbers ( Gizmo )
216 2) anything with gizmo in the name
217 3) anything with computer in the name
220 numbers = self.get_callback_numbers()
222 for number, description in numbers.iteritems():
223 if re.compile(r"""1747""").match(number) is not None:
224 self.set_callback_number(number)
227 for number, description in numbers.iteritems():
228 if re.compile(r"""gizmo""", re.I).search(description) is not None:
229 self.set_callback_number(number)
232 for number, description in numbers.iteritems():
233 if re.compile(r"""computer""", re.I).search(description) is not None:
234 self.set_callback_number(number)
237 for number, description in numbers.iteritems():
238 self.set_callback_number(number)
241 def get_callback_numbers(self):
243 @returns a dictionary mapping call back numbers to descriptions
244 @note These results are cached for 30 minutes.
246 if time.time() - self._lastAuthed < 1800 or self.is_authed():
247 return self._callbackNumbers
251 def set_callback_number(self, callbacknumber):
253 Set the number that grandcental calls
254 @param callbacknumber should be a proper 10 digit number
256 self._callbackNumber = callbacknumber
257 callbackPostData = urllib.urlencode({
258 '_rnr_se': self._token,
259 'phone': callbacknumber
262 callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
263 except urllib2.URLError, e:
264 warnings.warn(traceback.format_exc())
265 raise RuntimeError("%s is not accesible" % self._setforwardURL)
267 # @bug This does not seem to be keeping on my tablet (but works on the
268 # desktop), or the reading isn't working too well
269 self._browser.cookies.save()
272 def get_callback_number(self):
274 @returns Current callback number or None
276 for c in self._browser.cookies:
277 if c.name == "gv-ph":
279 return self._callbackNumber
281 def get_recent(self):
283 @returns Iterable of (personsName, phoneNumber, date, action)
286 self._receivedCallsURL,
287 self._missedCallsURL,
288 self._placedCallsURL,
291 allRecentData = self._grab_json(url)
292 except urllib2.URLError, e:
293 warnings.warn(traceback.format_exc())
294 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
296 for recentCallData in allRecentData["messages"].itervalues():
297 number = recentCallData["displayNumber"]
298 date = recentCallData["relativeStartTime"]
301 for label in recentCallData["labels"]
302 if label.lower() != "all" and label.lower() != "inbox"
304 number = saxutils.unescape(number)
305 date = saxutils.unescape(date)
306 action = saxutils.unescape(action)
307 yield "", number, date, 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 def get_contacts(self):
328 @returns Iterable of (contact id, contact name)
330 if self.__contacts is None:
333 contactsPagesUrls = [self._contactsURL]
334 for contactsPageUrl in contactsPagesUrls:
336 contactsPage = self._browser.download(contactsPageUrl)
337 except urllib2.URLError, e:
338 warnings.warn(traceback.format_exc())
339 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
340 for contact_match in self._contactsRe.finditer(contactsPage):
341 contactId = contact_match.group(1)
342 contactName = saxutils.unescape(contact_match.group(2))
343 contact = contactId, contactName
344 self.__contacts.append(contact)
347 next_match = self._contactsNextRe.match(contactsPage)
348 if next_match is not None:
349 newContactsPageUrl = self._contactsURL + next_match.group(1)
350 contactsPagesUrls.append(newContactsPageUrl)
352 for contact in self.__contacts:
355 def get_contact_details(self, contactId):
357 @returns Iterable of (Phone Type, Phone Number)
360 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
361 except urllib2.URLError, e:
362 warnings.warn(traceback.format_exc())
363 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
365 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
366 phoneNumber = detail_match.group(1)
367 phoneType = saxutils.unescape(detail_match.group(2))
368 yield (phoneType, phoneNumber)
370 def _grab_json(self, url):
371 flatXml = self._browser.download(url)
372 xmlTree = ElementTree.fromstring(flatXml)
373 jsonElement = xmlTree.getchildren()[0]
374 flatJson = jsonElement.text
375 jsonTree = parse_json(flatJson)
378 def _grab_account_info(self):
379 page = self._browser.download(self._forwardURL)
382 tokenGroup = self._tokenRe.search(page)
383 if tokenGroup is None:
384 raise RuntimeError("Could not extract authentication token from GoogleVoice")
385 self._token = tokenGroup.group(1)
387 anGroup = self._accountNumRe.search(page)
389 raise RuntimeError("Could not extract account number from GoogleVoice")
390 self._accountNum = anGroup.group(1)
392 self._callbackNumbers = {}
393 for match in self._callbackRe.finditer(page):
394 callbackNumber = match.group(2)
395 callbackName = match.group(1)
396 self._callbackNumbers[callbackNumber] = callbackName
399 def test_backend(username, password):
402 print "Authenticated: ", backend.is_authed()
403 print "Login?: ", backend.login(username, password)
404 print "Authenticated: ", backend.is_authed()
405 print "Token: ", backend._token
406 print "Account: ", backend.get_account_number()
407 print "Callback: ", backend.get_callback_number()
408 print "All Callback: ",
409 pprint.pprint(backend.get_callback_numbers())
411 # pprint.pprint(list(backend.get_recent()))
412 # print "Contacts: ",
413 # for contact in backend.get_contacts():
415 # pprint.pprint(list(backend.get_contact_details(contact[0])))