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
37 from xml.etree import ElementTree
39 from browser_emu import MozillaEmulator
48 socket.setdefaulttimeout(5)
51 _TRUE_REGEX = re.compile("true")
52 _FALSE_REGEX = re.compile("false")
56 s = _TRUE_REGEX.sub("True", s)
57 s = _FALSE_REGEX.sub("False", s)
58 return eval(s, {}, {})
61 if simplejson is None:
62 def parse_json(flattened):
63 return safe_eval(flattened)
65 def parse_json(flattened):
66 return simplejson.loads(flattened)
69 class GVDialer(object):
71 This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
72 the functions include login, setting up a callback number, and initalting a callback
75 _isNotLoginPageRe = re.compile(r"""I cannot access my account""")
76 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
77 _accountNumRe = re.compile(r"""<b class="ms2">(.{14})</b></div>""")
78 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
79 _validateRe = re.compile("^[0-9]{10,}$")
80 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
82 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
83 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
84 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
86 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
87 _contactsURL = "https://www.google.com/voice/mobile/contacts"
88 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
90 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
91 _setforwardURL = "https://www.google.com//voice/m/setphone"
92 _accountNumberURL = "https://www.google.com/voice/mobile"
93 _forwardURL = "https://www.google.com/voice/mobile/phones"
95 _inboxURL = "https://www.google.com/voice/inbox/"
96 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
97 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
98 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
99 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
101 def __init__(self, cookieFile = None):
102 # Important items in this function are the setup of the browser emulation and cookie file
103 self._browser = MozillaEmulator(None, 0)
104 if cookieFile is None:
105 cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
106 self._browser.cookies.filename = cookieFile
107 if os.path.isfile(cookieFile):
108 self._browser.cookies.load()
110 self._accountNum = None
111 self._lastAuthed = 0.0
113 self._callbackNumber = ""
114 self._callbackNumbers = {}
116 self.__contacts = None
118 def is_authed(self, force = False):
120 Attempts to detect a current session
121 @note Once logged in try not to reauth more than once a minute.
122 @returns If authenticated
125 if (time.time() - self._lastAuthed) < 60 and not force:
129 inboxPage = self._browser.download(self._inboxURL)
130 except urllib2.URLError, e:
131 warnings.warn(traceback.format_exc())
132 raise RuntimeError("%s is not accesible" % self._inboxURL)
134 self._browser.cookies.save()
135 if self._isNotLoginPageRe.search(inboxPage) is not None:
138 self._grab_account_info()
139 self._lastAuthed = time.time()
142 def login(self, username, password):
144 Attempt to login to grandcentral
145 @returns Whether login was successful or not
150 loginPostData = urllib.urlencode({
153 'service': "grandcentral",
157 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
158 except urllib2.URLError, e:
159 warnings.warn(traceback.format_exc())
160 raise RuntimeError("%s is not accesible" % self._loginURL)
162 return self.is_authed()
165 self._lastAuthed = 0.0
166 self._browser.cookies.clear()
167 self._browser.cookies.save()
171 def dial(self, number):
173 This is the main function responsible for initating the callback
175 if not self.is_valid_syntax(number):
176 raise ValueError('Number is not valid: "%s"' % number)
177 elif not self.is_authed():
178 raise RuntimeError("Not Authenticated")
180 if len(number) == 11 and number[0] == 1:
181 # Strip leading 1 from 11 digit dialing
185 clickToCallData = urllib.urlencode({
187 "phone": self._callbackNumber,
188 "_rnr_se": self._token,
191 'Referer' : 'https://google.com/voice/m/callsms',
193 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
194 except urllib2.URLError, e:
195 warnings.warn(traceback.format_exc())
196 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
198 if self._gvDialingStrRe.search(callSuccessPage) is None:
199 raise RuntimeError("Google Voice returned an error")
203 def clear_caches(self):
204 self.__contacts = None
206 def is_valid_syntax(self, number):
208 @returns If This number be called ( syntax validation only )
210 return self._validateRe.match(number) is not None
212 def get_account_number(self):
214 @returns The grand central phone number
216 return self._accountNum
218 def set_sane_callback(self):
220 Try to set a sane default callback number on these preferences
221 1) 1747 numbers ( Gizmo )
222 2) anything with gizmo in the name
223 3) anything with computer in the name
226 numbers = self.get_callback_numbers()
228 for number, description in numbers.iteritems():
229 if re.compile(r"""1747""").match(number) is not None:
230 self.set_callback_number(number)
233 for number, description in numbers.iteritems():
234 if re.compile(r"""gizmo""", re.I).search(description) is not None:
235 self.set_callback_number(number)
238 for number, description in numbers.iteritems():
239 if re.compile(r"""computer""", re.I).search(description) is not None:
240 self.set_callback_number(number)
243 for number, description in numbers.iteritems():
244 self.set_callback_number(number)
247 def get_callback_numbers(self):
249 @returns a dictionary mapping call back numbers to descriptions
250 @note These results are cached for 30 minutes.
252 if time.time() - self._lastAuthed < 1800 or self.is_authed():
253 return self._callbackNumbers
257 def set_callback_number(self, callbacknumber):
259 Set the number that grandcental calls
260 @param callbacknumber should be a proper 10 digit number
262 self._callbackNumber = callbacknumber
263 callbackPostData = urllib.urlencode({
264 '_rnr_se': self._token,
265 'phone': callbacknumber
268 callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
269 except urllib2.URLError, e:
270 warnings.warn(traceback.format_exc())
271 raise RuntimeError("%s is not accesible" % self._setforwardURL)
273 self._browser.cookies.save()
276 def get_callback_number(self):
278 @returns Current callback number or None
280 return self._callbackNumber
282 def get_recent(self):
284 @returns Iterable of (personsName, phoneNumber, date, action)
287 self._receivedCallsURL,
288 self._missedCallsURL,
289 self._placedCallsURL,
292 allRecentData = self._grab_json(url)
293 except urllib2.URLError, e:
294 warnings.warn(traceback.format_exc())
295 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
297 for recentCallData in allRecentData["messages"].itervalues():
298 number = recentCallData["displayNumber"]
299 date = recentCallData["relativeStartTime"]
302 for label in recentCallData["labels"]
303 if label.lower() != "all" and label.lower() != "inbox"
305 yield "", number, date, action
307 def get_addressbooks(self):
309 @returns Iterable of (Address Book Factory, Book Id, Book Name)
313 def open_addressbook(self, bookId):
317 def contact_source_short_name(contactId):
322 return "Google Voice"
324 def get_contacts(self):
326 @returns Iterable of (contact id, contact name)
328 if self.__contacts is None:
331 contactsPagesUrls = [self._contactsURL]
332 for contactsPageUrl in contactsPagesUrls:
334 contactsPage = self._browser.download(contactsPageUrl)
335 except urllib2.URLError, e:
336 warnings.warn(traceback.format_exc())
337 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
338 for contact_match in self._contactsRe.finditer(contactsPage):
339 contactId = contact_match.group(1)
340 contactName = contact_match.group(2)
341 contact = contactId, contactName
342 self.__contacts.append(contact)
345 next_match = self._contactsNextRe.match(contactsPage)
346 if next_match is not None:
347 newContactsPageUrl = self._contactsURL + next_match.group(1)
348 contactsPagesUrls.append(newContactsPageUrl)
350 for contact in self.__contacts:
353 def get_contact_details(self, contactId):
355 @returns Iterable of (Phone Type, Phone Number)
358 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
359 except urllib2.URLError, e:
360 warnings.warn(traceback.format_exc())
361 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
363 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
364 phoneNumber = detail_match.group(1)
365 phoneType = detail_match.group(2)
366 yield (phoneType, phoneNumber)
368 def _grab_json(self, url):
369 flatXml = self._browser.download(url)
370 xmlTree = ElementTree.fromstring(flatXml)
371 jsonElement = xmlTree.getchildren()[0]
372 flatJson = jsonElement.text
373 jsonTree = parse_json(flatJson)
376 def _grab_account_info(self, loginPage = None):
377 if loginPage is None:
378 accountNumberPage = self._browser.download(self._accountNumberURL)
380 accountNumberPage = loginPage
381 tokenGroup = self._tokenRe.search(accountNumberPage)
382 if tokenGroup is not None:
383 self._token = tokenGroup.group(1)
384 anGroup = self._accountNumRe.search(accountNumberPage)
385 if anGroup is not None:
386 self._accountNum = anGroup.group(1)
388 callbackPage = self._browser.download(self._forwardURL)
389 self._callbackNumbers = {}
390 for match in self._callbackRe.finditer(callbackPage):
391 self._callbackNumbers[match.group(2)] = match.group(1)
393 if len(self._callbackNumber) == 0:
394 self.set_sane_callback()
397 def test_backend(username, password):
400 print "Authenticated: ", backend.is_authed()
401 print "Login?: ", backend.login(username, password)
402 print "Authenticated: ", backend.is_authed()
403 print "Token: ", backend._token
404 print "Account: ", backend.get_account_number()
405 print "Callback: ", backend.get_callback_number()
406 # print "All Callback: ",
407 # pprint.pprint(backend.get_callback_numbers())
409 # pprint.pprint(list(backend.get_recent()))
410 # print "Contacts: ",
411 # for contact in backend.get_contacts():
413 # pprint.pprint(list(backend.get_contact_details(contact[0])))