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
47 _TRUE_REGEX = re.compile("true")
48 _FALSE_REGEX = re.compile("false")
52 s = _TRUE_REGEX.sub("True", s)
53 s = _FALSE_REGEX.sub("False", s)
54 return eval(s, {}, {})
57 if simplejson is None:
58 def parse_json(flattened):
59 return safe_eval(flattened)
61 def parse_json(flattened):
62 return simplejson.loads(flattened)
65 class GVDialer(object):
67 This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
68 the functions include login, setting up a callback number, and initalting a callback
71 _isNotLoginPageRe = re.compile(r"""I cannot access my account""")
72 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
73 _accountNumRe = re.compile(r"""<b class="ms2">(.{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 _inboxURL = "https://www.google.com/voice/inbox/"
92 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
93 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
94 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
95 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
97 def __init__(self, cookieFile = None):
98 # Important items in this function are the setup of the browser emulation and cookie file
99 self._browser = MozillaEmulator(None, 0)
100 if cookieFile is None:
101 cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
102 self._browser.cookies.filename = cookieFile
103 if os.path.isfile(cookieFile):
104 self._browser.cookies.load()
106 self._accountNum = None
107 self._lastAuthed = 0.0
109 self._callbackNumber = ""
110 self._callbackNumbers = {}
112 self.__contacts = None
114 def is_authed(self, force = False):
116 Attempts to detect a current session
117 @note Once logged in try not to reauth more than once a minute.
118 @returns If authenticated
121 if (time.time() - self._lastAuthed) < 60 and not force:
125 inboxPage = self._browser.download(self._inboxURL)
126 except urllib2.URLError, e:
127 warnings.warn(traceback.format_exc())
128 raise RuntimeError("%s is not accesible" % self._inboxURL)
130 self._browser.cookies.save()
131 if self._isNotLoginPageRe.search(inboxPage) is not None:
134 self._grab_account_info()
135 self._lastAuthed = time.time()
138 def login(self, username, password):
140 Attempt to login to grandcentral
141 @returns Whether login was successful or not
146 loginPostData = urllib.urlencode({
149 'service': "grandcentral",
153 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
154 except urllib2.URLError, e:
155 warnings.warn(traceback.format_exc())
156 raise RuntimeError("%s is not accesible" % self._loginURL)
158 return self.is_authed()
161 self._lastAuthed = 0.0
162 self._browser.cookies.clear()
163 self._browser.cookies.save()
167 def dial(self, number):
169 This is the main function responsible for initating the callback
171 if not self.is_valid_syntax(number):
172 raise ValueError('Number is not valid: "%s"' % number)
173 elif not self.is_authed():
174 raise RuntimeError("Not Authenticated")
176 if len(number) == 11 and number[0] == 1:
177 # Strip leading 1 from 11 digit dialing
181 clickToCallData = urllib.urlencode({
183 "phone": self._callbackNumber,
184 "_rnr_se": self._token,
187 'Referer' : 'https://google.com/voice/m/callsms',
189 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
190 except urllib2.URLError, e:
191 warnings.warn(traceback.format_exc())
192 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
194 if self._gvDialingStrRe.search(callSuccessPage) is None:
195 raise RuntimeError("Google Voice returned an error")
199 def clear_caches(self):
200 self.__contacts = None
202 def is_valid_syntax(self, number):
204 @returns If This number be called ( syntax validation only )
206 return self._validateRe.match(number) is not None
208 def get_account_number(self):
210 @returns The grand central phone number
212 return self._accountNum
214 def set_sane_callback(self):
216 Try to set a sane default callback number on these preferences
217 1) 1747 numbers ( Gizmo )
218 2) anything with gizmo in the name
219 3) anything with computer in the name
222 numbers = self.get_callback_numbers()
224 for number, description in numbers.iteritems():
225 if re.compile(r"""1747""").match(number) is not None:
226 self.set_callback_number(number)
229 for number, description in numbers.iteritems():
230 if re.compile(r"""gizmo""", re.I).search(description) is not None:
231 self.set_callback_number(number)
234 for number, description in numbers.iteritems():
235 if re.compile(r"""computer""", re.I).search(description) is not None:
236 self.set_callback_number(number)
239 for number, description in numbers.iteritems():
240 self.set_callback_number(number)
243 def get_callback_numbers(self):
245 @returns a dictionary mapping call back numbers to descriptions
246 @note These results are cached for 30 minutes.
248 if time.time() - self._lastAuthed < 1800 or self.is_authed():
249 return self._callbackNumbers
253 def set_callback_number(self, callbacknumber):
255 Set the number that grandcental calls
256 @param callbacknumber should be a proper 10 digit number
258 self._callbackNumber = callbacknumber
259 callbackPostData = urllib.urlencode({
260 '_rnr_se': self._token,
261 'phone': callbacknumber
264 callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
265 except urllib2.URLError, e:
266 warnings.warn(traceback.format_exc())
267 raise RuntimeError("%s is not accesible" % self._setforwardURL)
269 self._browser.cookies.save()
272 def get_callback_number(self):
274 @returns Current callback number or None
276 return self._callbackNumber
278 def get_recent(self):
280 @returns Iterable of (personsName, phoneNumber, date, action)
283 self._receivedCallsURL,
284 self._missedCallsURL,
285 self._placedCallsURL,
288 allRecentData = self._grab_json(url)
289 except urllib2.URLError, e:
290 warnings.warn(traceback.format_exc())
291 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
293 for recentCallData in allRecentData["messages"].itervalues():
294 number = recentCallData["displayNumber"]
295 date = recentCallData["relativeStartTime"]
298 for label in recentCallData["labels"]
299 if label.lower() != "all" and label.lower() != "inbox"
301 yield "", number, date, action
303 def get_addressbooks(self):
305 @returns Iterable of (Address Book Factory, Book Id, Book Name)
309 def open_addressbook(self, bookId):
313 def contact_source_short_name(contactId):
318 return "Google Voice"
320 def get_contacts(self):
322 @returns Iterable of (contact id, contact name)
324 if self.__contacts is None:
327 contactsPagesUrls = [self._contactsURL]
328 for contactsPageUrl in contactsPagesUrls:
330 contactsPage = self._browser.download(contactsPageUrl)
331 except urllib2.URLError, e:
332 warnings.warn(traceback.format_exc())
333 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
334 for contact_match in self._contactsRe.finditer(contactsPage):
335 contactId = contact_match.group(1)
336 contactName = contact_match.group(2)
337 contact = contactId, contactName
338 self.__contacts.append(contact)
341 next_match = self._contactsNextRe.match(contactsPage)
342 if next_match is not None:
343 newContactsPageUrl = self._contactsURL + next_match.group(1)
344 contactsPagesUrls.append(newContactsPageUrl)
346 for contact in self.__contacts:
349 def get_contact_details(self, contactId):
351 @returns Iterable of (Phone Type, Phone Number)
354 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
355 except urllib2.URLError, e:
356 warnings.warn(traceback.format_exc())
357 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
359 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
360 phoneNumber = detail_match.group(1)
361 phoneType = detail_match.group(2)
362 yield (phoneType, phoneNumber)
364 def _grab_json(self, url):
365 flatXml = self._browser.download(url)
366 xmlTree = ElementTree.fromstring(flatXml)
367 jsonElement = xmlTree.getchildren()[0]
368 flatJson = jsonElement.text
369 jsonTree = parse_json(flatJson)
372 def _grab_account_info(self, loginPage = None):
373 if loginPage is None:
374 accountNumberPage = self._browser.download(self._accountNumberURL)
376 accountNumberPage = loginPage
377 tokenGroup = self._tokenRe.search(accountNumberPage)
378 if tokenGroup is not None:
379 self._token = tokenGroup.group(1)
380 anGroup = self._accountNumRe.search(accountNumberPage)
381 if anGroup is not None:
382 self._accountNum = anGroup.group(1)
384 callbackPage = self._browser.download(self._forwardURL)
385 self._callbackNumbers = {}
386 for match in self._callbackRe.finditer(callbackPage):
387 self._callbackNumbers[match.group(2)] = match.group(1)
389 if len(self._callbackNumber) == 0:
390 self.set_sane_callback()
393 def test_backend(username, password):
396 print "Authenticated: ", backend.is_authed()
397 print "Login?: ", backend.login(username, password)
398 print "Authenticated: ", backend.is_authed()
399 print "Token: ", backend._token
400 print "Account: ", backend.get_account_number()
401 print "Callback: ", backend.get_callback_number()
402 # print "All Callback: ",
403 # pprint.pprint(backend.get_callback_numbers())
405 # pprint.pprint(list(backend.get_recent()))
406 # print "Contacts: ",
407 # for contact in backend.get_contacts():
409 # pprint.pprint(list(backend.get_contact_details(contact[0])))