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 Grandcentral backend code
32 from xml.sax import saxutils
37 class GCDialer(object):
39 This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
40 the functions include login, setting up a callback number, and initalting a callback
43 _gcDialingStrRe = re.compile("This may take a few seconds", re.M)
44 _accessTokenRe = re.compile(r"""<input type="hidden" name="a_t" [^>]*value="(.*)"/>""")
45 _isLoginPageRe = re.compile(r"""<form method="post" action="https://www.grandcentral.com/mobile/account/login">""")
46 _callbackRe = re.compile(r"""name="default_number" value="(\d+)" />\s+(.*)\s$""", re.M)
47 _accountNumRe = re.compile(r"""<input type="hidden" name="gcentral_num" [^>]*value="(.*)"/>""")
48 _inboxRe = re.compile(r"""<td>.*?(voicemail|received|missed|call return).*?</td>\s+<td>\s+<font size="2">\s+(.*?)\s+ \| \s+<a href="/mobile/contacts/.*?">(.*?)\s?</a>\s+<br/>\s+(.*?)\s?<a href=""", re.S)
49 _contactsRe = re.compile(r"""<a href="/mobile/contacts/detail/(\d+)">(.*?)</a>""", re.S)
50 _contactsNextRe = re.compile(r""".*<a href="/mobile/contacts(\?page=\d+)">Next</a>""", re.S)
51 _contactDetailPhoneRe = re.compile(r"""(\w+):[0-9\-\(\) \t]*?<a href="/mobile/calls/click_to_call\?destno=(\d+).*?">call</a>""", re.S)
53 _validateRe = re.compile("^[0-9]{10,}$")
55 _forwardselectURL = "http://www.grandcentral.com/mobile/settings/forwarding_select"
56 _loginURL = "https://www.grandcentral.com/mobile/account/login"
57 _setforwardURL = "http://www.grandcentral.com/mobile/settings/set_forwarding?from=settings"
58 _clicktocallURL = "http://www.grandcentral.com/mobile/calls/click_to_call?a_t=%s&destno=%s"
59 _inboxallURL = "http://www.grandcentral.com/mobile/messages/inbox?types=all"
60 _contactsURL = "http://www.grandcentral.com/mobile/contacts"
61 _contactDetailURL = "http://www.grandcentral.com/mobile/contacts/detail"
63 def __init__(self, cookieFile = None):
64 # Important items in this function are the setup of the browser emulation and cookie file
65 self._browser = browser_emu.MozillaEmulator(1)
66 if cookieFile is None:
67 cookieFile = os.path.join(os.path.expanduser("~"), ".gc_cookies.txt")
68 self._browser.cookies.filename = cookieFile
69 if os.path.isfile(cookieFile):
70 self._browser.cookies.load()
72 self._accessToken = None
74 self._lastAuthed = 0.0
75 self._callbackNumber = ""
76 self._callbackNumbers = {}
78 self.__contacts = None
80 def is_authed(self, force = False):
82 Attempts to detect a current session and pull the auth token ( a_t ) from the page.
83 @note Once logged in try not to reauth more than once a minute.
84 @returns If authenticated
87 if (time.time() - self._lastAuthed) < 60 and not force:
91 forwardSelectionPage = self._browser.download(self._forwardselectURL)
92 except urllib2.URLError, e:
93 warnings.warn(traceback.format_exc())
96 if self._isLoginPageRe.search(forwardSelectionPage) is not None:
100 self._grab_token(forwardSelectionPage)
101 except StandardError, e:
102 warnings.warn(traceback.format_exc())
105 self._browser.cookies.save()
106 self._lastAuthed = time.time()
109 def login(self, username, password):
111 Attempt to login to grandcentral
112 @returns Whether login was successful or not
117 loginPostData = urllib.urlencode( {'username' : username , 'password' : password } )
120 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
121 except urllib2.URLError, e:
122 warnings.warn(traceback.format_exc())
123 raise RuntimeError("%s is not accesible" % self._loginURL)
125 return self.is_authed()
128 self._lastAuthed = 0.0
129 self._browser.cookies.clear()
130 self._browser.cookies.save()
134 def dial(self, number):
136 This is the main function responsible for initating the callback
138 if not self.is_valid_syntax(number):
139 raise ValueError('Number is not valid: "%s"' % number)
140 elif not self.is_authed():
141 raise RuntimeError("Not Authenticated")
143 if len(number) == 11 and number[0] == 1:
144 # Strip leading 1 from 11 digit dialing
148 callSuccessPage = self._browser.download(
149 self._clicktocallURL % (self._accessToken, number),
151 {'Referer' : 'http://www.grandcentral.com/mobile/messages'}
153 except urllib2.URLError, e:
154 warnings.warn(traceback.format_exc())
155 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
157 if self._gcDialingStrRe.search(callSuccessPage) is None:
158 raise RuntimeError("Grand Central returned an error")
162 def send_sms(self, number, message):
163 raise NotImplementedError("SMS Is Not Supported by GrandCentral")
165 def clear_caches(self):
166 self.__contacts = None
168 def is_valid_syntax(self, number):
170 @returns If This number be called ( syntax validation only )
172 return self._validateRe.match(number) is not None
174 def get_account_number(self):
176 @returns The grand central phone number
178 return self._accountNum
180 def set_sane_callback(self):
182 Try to set a sane default callback number on these preferences
183 1) 1747 numbers ( Gizmo )
184 2) anything with gizmo in the name
185 3) anything with computer in the name
188 numbers = self.get_callback_numbers()
190 for number, description in numbers.iteritems():
191 if re.compile(r"""1747""").match(number) is not None:
192 self.set_callback_number(number)
195 for number, description in numbers.iteritems():
196 if re.compile(r"""gizmo""", re.I).search(description) is not None:
197 self.set_callback_number(number)
200 for number, description in numbers.iteritems():
201 if re.compile(r"""computer""", re.I).search(description) is not None:
202 self.set_callback_number(number)
205 for number, description in numbers.iteritems():
206 self.set_callback_number(number)
209 def get_callback_numbers(self):
211 @returns a dictionary mapping call back numbers to descriptions
212 @note These results are cached for 30 minutes.
214 if time.time() - self._lastAuthed < 1800 or self.is_authed():
215 return self._callbackNumbers
219 def set_callback_number(self, callbacknumber):
221 Set the number that grandcental calls
222 @param callbacknumber should be a proper 10 digit number
224 self._callbackNumber = callbacknumber
225 callbackPostData = urllib.urlencode({
226 'a_t': self._accessToken,
227 'default_number': callbacknumber
230 callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
231 except urllib2.URLError, e:
232 warnings.warn(traceback.format_exc())
233 raise RuntimeError("%s is not accesible" % self._setforwardURL)
235 self._browser.cookies.save()
238 def get_callback_number(self):
240 @returns Current callback number or None
242 for c in self._browser.cookies:
243 if c.name == "pda_forwarding_number":
245 return self._callbackNumber
247 def get_recent(self):
249 @returns Iterable of (personsName, phoneNumber, date, action)
252 recentCallsPage = self._browser.download(self._inboxallURL)
253 except urllib2.URLError, e:
254 warnings.warn(traceback.format_exc())
255 raise RuntimeError("%s is not accesible" % self._inboxallURL)
257 for match in self._inboxRe.finditer(recentCallsPage):
258 phoneNumber = match.group(4)
259 action = saxutils.unescape(match.group(1))
260 date = saxutils.unescape(match.group(2))
261 personsName = saxutils.unescape(match.group(3))
262 yield personsName, phoneNumber, date, action
264 def get_addressbooks(self):
266 @returns Iterable of (Address Book Factory, Book Id, Book Name)
270 def open_addressbook(self, bookId):
274 def contact_source_short_name(contactId):
279 return "Grand Central"
281 def get_contacts(self):
283 @returns Iterable of (contact id, contact name)
285 if self.__contacts is None:
288 contactsPagesUrls = [self._contactsURL]
289 for contactsPageUrl in contactsPagesUrls:
291 contactsPage = self._browser.download(contactsPageUrl)
292 except urllib2.URLError, e:
293 warnings.warn(traceback.format_exc())
294 raise RuntimeError("%s is not accesible" % contactsPageUrl)
295 for contact_match in self._contactsRe.finditer(contactsPage):
296 contactId = contact_match.group(1)
297 contactName = contact_match.group(2)
298 contact = contactId, saxutils.unescape(contactName)
299 self.__contacts.append(contact)
302 next_match = self._contactsNextRe.match(contactsPage)
303 if next_match is not None:
304 newContactsPageUrl = self._contactsURL + next_match.group(1)
305 contactsPagesUrls.append(newContactsPageUrl)
307 for contact in self.__contacts:
310 def get_contact_details(self, contactId):
312 @returns Iterable of (Phone Type, Phone Number)
315 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
316 except urllib2.URLError, e:
317 warnings.warn(traceback.format_exc())
318 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
320 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
321 phoneType = saxutils.unescape(detail_match.group(1))
322 phoneNumber = detail_match.group(2)
323 yield (phoneType, phoneNumber)
325 def get_messages(self):
328 def _grab_token(self, data):
329 "Pull the magic cookie from the datastream"
330 atGroup = self._accessTokenRe.search(data)
332 raise RuntimeError("Could not extract authentication token from GrandCentral")
333 self._accessToken = atGroup.group(1)
335 anGroup = self._accountNumRe.search(data)
336 if anGroup is not None:
337 self._accountNum = anGroup.group(1)
339 warnings.warn("Could not extract account number from GrandCentral", UserWarning, 2)
341 self._callbackNumbers = {}
342 for match in self._callbackRe.finditer(data):
343 self._callbackNumbers[match.group(1)] = match.group(2)
346 def test_backend(username, password):
349 print "Authenticated: ", backend.is_authed()
350 print "Login?: ", backend.login(username, password)
351 print "Authenticated: ", backend.is_authed()
352 # print "Token: ", backend._accessToken
353 print "Account: ", backend.get_account_number()
354 print "Callback: ", backend.get_callback_number()
355 # print "All Callback: ",
356 # pprint.pprint(backend.get_callback_numbers())
358 # pprint.pprint(list(backend.get_recent()))
359 # print "Contacts: ",
360 # for contact in backend.get_contacts():
362 # pprint.pprint(list(backend.get_contact_details(contact[0])))