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 _contactsURL = "https://www.google.com/voice/mobile/contacts"
85 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
87 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
88 _setforwardURL = "https://www.google.com//voice/m/setphone"
89 _accountNumberURL = "https://www.google.com/voice/mobile"
90 _forwardURL = "https://www.google.com/voice/mobile/phones"
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/"
96 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
97 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
99 def __init__(self, cookieFile = None):
100 # Important items in this function are the setup of the browser emulation and cookie file
101 self._browser = browser_emu.MozillaEmulator(1)
102 if cookieFile is None:
103 cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
104 self._browser.cookies.filename = cookieFile
105 if os.path.isfile(cookieFile):
106 self._browser.cookies.load()
109 self._accountNum = None
110 self._lastAuthed = 0.0
111 self._callbackNumber = ""
112 self._callbackNumbers = {}
114 self.__contacts = None
116 def is_authed(self, force = False):
118 Attempts to detect a current session
119 @note Once logged in try not to reauth more than once a minute.
120 @returns If authenticated
123 if (time.time() - self._lastAuthed) < 60 and not force:
127 self._grab_account_info()
128 except StandardError, e:
129 warnings.warn(traceback.format_exc())
132 self._browser.cookies.save()
133 self._lastAuthed = time.time()
136 def login(self, username, password):
138 Attempt to login to grandcentral
139 @returns Whether login was successful or not
144 loginPostData = urllib.urlencode({
147 'service': "grandcentral",
150 "PersistentCookie": "yes",
154 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
155 except urllib2.URLError, e:
156 warnings.warn(traceback.format_exc())
157 raise RuntimeError("%s is not accesible" % self._loginURL)
159 return self.is_authed()
162 self._lastAuthed = 0.0
163 self._browser.cookies.clear()
164 self._browser.cookies.save()
168 def dial(self, number):
170 This is the main function responsible for initating the callback
172 if not self.is_valid_syntax(number):
173 raise ValueError('Number is not valid: "%s"' % number)
174 elif not self.is_authed():
175 raise RuntimeError("Not Authenticated")
177 if len(number) == 11 and number[0] == 1:
178 # Strip leading 1 from 11 digit dialing
182 clickToCallData = urllib.urlencode({
184 "phone": self._callbackNumber,
185 "_rnr_se": self._token,
188 'Referer' : 'https://google.com/voice/m/callsms',
190 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
191 except urllib2.URLError, e:
192 warnings.warn(traceback.format_exc())
193 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
195 if self._gvDialingStrRe.search(callSuccessPage) is None:
196 raise RuntimeError("Google Voice returned an error")
200 def clear_caches(self):
201 self.__contacts = None
203 def is_valid_syntax(self, number):
205 @returns If This number be called ( syntax validation only )
207 return self._validateRe.match(number) is not None
209 def get_account_number(self):
211 @returns The grand central phone number
213 return self._accountNum
215 def set_sane_callback(self):
217 Try to set a sane default callback number on these preferences
218 1) 1747 numbers ( Gizmo )
219 2) anything with gizmo in the name
220 3) anything with computer in the name
223 numbers = self.get_callback_numbers()
225 for number, description in numbers.iteritems():
226 if re.compile(r"""1747""").match(number) is not None:
227 self.set_callback_number(number)
230 for number, description in numbers.iteritems():
231 if re.compile(r"""gizmo""", re.I).search(description) is not None:
232 self.set_callback_number(number)
235 for number, description in numbers.iteritems():
236 if re.compile(r"""computer""", re.I).search(description) is not None:
237 self.set_callback_number(number)
240 for number, description in numbers.iteritems():
241 self.set_callback_number(number)
244 def get_callback_numbers(self):
246 @returns a dictionary mapping call back numbers to descriptions
247 @note These results are cached for 30 minutes.
249 if time.time() - self._lastAuthed < 1800 or self.is_authed():
250 return self._callbackNumbers
254 def set_callback_number(self, callbacknumber):
256 Set the number that grandcental calls
257 @param callbacknumber should be a proper 10 digit number
259 self._callbackNumber = callbacknumber
260 callbackPostData = urllib.urlencode({
261 '_rnr_se': self._token,
262 'phone': callbacknumber
265 callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
266 except urllib2.URLError, e:
267 warnings.warn(traceback.format_exc())
268 raise RuntimeError("%s is not accesible" % self._setforwardURL)
270 # @bug This does not seem to be keeping on my tablet (but works on the
271 # desktop), or the reading isn't working too well
272 self._browser.cookies.save()
275 def get_callback_number(self):
277 @returns Current callback number or None
279 for c in self._browser.cookies:
280 if c.name == "gv-ph":
282 return self._callbackNumber
284 def get_recent(self):
286 @todo Sort this stuff
287 @returns Iterable of (personsName, phoneNumber, date, action)
290 (exactDate, name, number, relativeDate, action)
291 for (name, number, exactDate, relativeDate, action) in self._get_recent()
293 sortedRecent.sort(reverse = True)
294 for exactDate, name, number, relativeDate, action in sortedRecent:
295 yield name, number, relativeDate, action
297 def get_addressbooks(self):
299 @returns Iterable of (Address Book Factory, Book Id, Book Name)
303 def open_addressbook(self, bookId):
307 def contact_source_short_name(contactId):
312 return "Google Voice"
314 def get_contacts(self):
316 @returns Iterable of (contact id, contact name)
318 if self.__contacts is None:
321 contactsPagesUrls = [self._contactsURL]
322 for contactsPageUrl in contactsPagesUrls:
324 contactsPage = self._browser.download(contactsPageUrl)
325 except urllib2.URLError, e:
326 warnings.warn(traceback.format_exc())
327 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
328 for contact_match in self._contactsRe.finditer(contactsPage):
329 contactId = contact_match.group(1)
330 contactName = saxutils.unescape(contact_match.group(2))
331 contact = contactId, contactName
332 self.__contacts.append(contact)
335 next_match = self._contactsNextRe.match(contactsPage)
336 if next_match is not None:
337 newContactsPageUrl = self._contactsURL + next_match.group(1)
338 contactsPagesUrls.append(newContactsPageUrl)
340 for contact in self.__contacts:
343 def get_contact_details(self, contactId):
345 @returns Iterable of (Phone Type, Phone Number)
348 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
349 except urllib2.URLError, e:
350 warnings.warn(traceback.format_exc())
351 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
353 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
354 phoneNumber = detail_match.group(1)
355 phoneType = saxutils.unescape(detail_match.group(2))
356 yield (phoneType, phoneNumber)
358 def get_messages(self):
360 voicemailPage = self._browser.download(self._voicemailURL)
361 except urllib2.URLError, e:
362 warnings.warn(traceback.format_exc())
363 raise RuntimeError("%s is not accesible" % self._voicemailURL)
366 smsPage = self._browser.download(self._smsURL)
367 except urllib2.URLError, e:
368 warnings.warn(traceback.format_exc())
369 raise RuntimeError("%s is not accesible" % self._smsURL)
371 voicemailHtml = self._grab_html(voicemailPage)
372 smsHtml = self._grab_html(smsPage)
382 def _grab_json(self, flatXml):
383 xmlTree = ElementTree.fromstring(flatXml)
384 jsonElement = xmlTree.getchildren()[0]
385 flatJson = jsonElement.text
386 jsonTree = parse_json(flatJson)
389 def _grab_html(self, flatXml):
390 xmlTree = ElementTree.fromstring(flatXml)
391 htmlElement = xmlTree.getchildren()[1]
392 flatHtml = htmlElement.text
395 def _grab_account_info(self):
396 page = self._browser.download(self._forwardURL)
398 tokenGroup = self._tokenRe.search(page)
399 if tokenGroup is None:
400 raise RuntimeError("Could not extract authentication token from GoogleVoice")
401 self._token = tokenGroup.group(1)
403 anGroup = self._accountNumRe.search(page)
405 raise RuntimeError("Could not extract account number from GoogleVoice")
406 self._accountNum = anGroup.group(1)
408 self._callbackNumbers = {}
409 for match in self._callbackRe.finditer(page):
410 callbackNumber = match.group(2)
411 callbackName = match.group(1)
412 self._callbackNumbers[callbackNumber] = callbackName
414 def _get_recent(self):
416 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
419 self._receivedCallsURL,
420 self._missedCallsURL,
421 self._placedCallsURL,
424 flatXml = self._browser.download(url)
425 except urllib2.URLError, e:
426 warnings.warn(traceback.format_exc())
427 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
429 allRecentData = self._grab_json(flatXml)
430 for recentCallData in allRecentData["messages"].itervalues():
431 number = recentCallData["displayNumber"]
432 exactDate = recentCallData["displayStartDateTime"]
433 relativeDate = recentCallData["relativeStartTime"]
436 for label in recentCallData["labels"]
437 if label.lower() != "all" and label.lower() != "inbox"
439 number = saxutils.unescape(number)
440 exactDate = saxutils.unescape(exactDate)
441 exactDate = datetime.datetime.strptime(exactDate, "%m/%d/%y %I:%M %p")
442 relativeDate = saxutils.unescape(relativeDate)
443 action = saxutils.unescape(action)
444 yield "", number, exactDate, relativeDate, action
447 def test_backend(username, password):
450 print "Authenticated: ", backend.is_authed()
451 print "Login?: ", backend.login(username, password)
452 print "Authenticated: ", backend.is_authed()
453 print "Token: ", backend._token
454 print "Account: ", backend.get_account_number()
455 print "Callback: ", backend.get_callback_number()
456 print "All Callback: ",
457 pprint.pprint(backend.get_callback_numbers())
459 # pprint.pprint(list(backend.get_recent()))
460 # print "Contacts: ",
461 # for contact in backend.get_contacts():
463 # pprint.pprint(list(backend.get_contact_details(contact[0])))