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
73 self._accountNum = None
74 self._lastAuthed = 0.0
75 self._callbackNumbers = {}
77 self.__contacts = None
79 def is_authed(self, force = False):
81 Attempts to detect a current session and pull the auth token ( a_t ) from the page.
82 @note Once logged in try not to reauth more than once a minute.
83 @returns If authenticated
86 if (time.time() - self._lastAuthed) < 60 and not force:
90 forwardSelectionPage = self._browser.download(self._forwardselectURL)
91 except urllib2.URLError, e:
92 warnings.warn(traceback.format_exc())
95 if self._isLoginPageRe.search(forwardSelectionPage) is not None:
99 self._grab_token(forwardSelectionPage)
100 except StandardError, e:
101 warnings.warn(traceback.format_exc())
104 self._browser.cookies.save()
105 self._lastAuthed = time.time()
108 def login(self, username, password):
110 Attempt to login to grandcentral
111 @returns Whether login was successful or not
116 loginPostData = urllib.urlencode( {'username' : username , 'password' : password } )
119 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
120 except urllib2.URLError, e:
121 warnings.warn(traceback.format_exc())
122 raise RuntimeError("%s is not accesible" % self._loginURL)
124 return self.is_authed()
127 self._lastAuthed = 0.0
128 self._browser.cookies.clear()
129 self._browser.cookies.save()
133 def dial(self, number):
135 This is the main function responsible for initating the callback
137 if not self.is_valid_syntax(number):
138 raise ValueError('Number is not valid: "%s"' % number)
139 elif not self.is_authed():
140 raise RuntimeError("Not Authenticated")
142 if len(number) == 11 and number[0] == 1:
143 # Strip leading 1 from 11 digit dialing
147 callSuccessPage = self._browser.download(
148 self._clicktocallURL % (self._accessToken, number),
150 {'Referer' : 'http://www.grandcentral.com/mobile/messages'}
152 except urllib2.URLError, e:
153 warnings.warn(traceback.format_exc())
154 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
156 if self._gcDialingStrRe.search(callSuccessPage) is None:
157 raise RuntimeError("Grand Central returned an error")
161 def send_sms(self, number, message):
162 raise NotImplementedError("SMS Is Not Supported by GrandCentral")
164 def clear_caches(self):
165 self.__contacts = None
167 def is_valid_syntax(self, number):
169 @returns If This number be called ( syntax validation only )
171 return self._validateRe.match(number) is not None
173 def get_account_number(self):
175 @returns The grand central phone number
177 return self._accountNum
179 def set_sane_callback(self):
181 Try to set a sane default callback number on these preferences
182 1) 1747 numbers ( Gizmo )
183 2) anything with gizmo in the name
184 3) anything with computer in the name
187 numbers = self.get_callback_numbers()
189 for number, description in numbers.iteritems():
190 if re.compile(r"""1747""").match(number) is not None:
191 self.set_callback_number(number)
194 for number, description in numbers.iteritems():
195 if re.compile(r"""gizmo""", re.I).search(description) is not None:
196 self.set_callback_number(number)
199 for number, description in numbers.iteritems():
200 if re.compile(r"""computer""", re.I).search(description) is not None:
201 self.set_callback_number(number)
204 for number, description in numbers.iteritems():
205 self.set_callback_number(number)
208 def get_callback_numbers(self):
210 @returns a dictionary mapping call back numbers to descriptions
211 @note These results are cached for 30 minutes.
213 if time.time() - self._lastAuthed < 1800 or self.is_authed():
214 return self._callbackNumbers
218 def set_callback_number(self, callbacknumber):
220 Set the number that grandcental calls
221 @param callbacknumber should be a proper 10 digit number
223 callbackPostData = urllib.urlencode({
224 'a_t': self._accessToken,
225 'default_number': callbacknumber
228 callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
229 except urllib2.URLError, e:
230 warnings.warn(traceback.format_exc())
231 raise RuntimeError("%s is not accesible" % self._setforwardURL)
233 self._browser.cookies.save()
236 def get_callback_number(self):
238 @returns Current callback number or None
240 for c in self._browser.cookies:
241 if c.name == "pda_forwarding_number":
245 def get_recent(self):
247 @returns Iterable of (personsName, phoneNumber, date, action)
250 recentCallsPage = self._browser.download(self._inboxallURL)
251 except urllib2.URLError, e:
252 warnings.warn(traceback.format_exc())
253 raise RuntimeError("%s is not accesible" % self._inboxallURL)
255 for match in self._inboxRe.finditer(recentCallsPage):
256 phoneNumber = match.group(4)
257 action = saxutils.unescape(match.group(1))
258 date = saxutils.unescape(match.group(2))
259 personsName = saxutils.unescape(match.group(3))
260 yield personsName, phoneNumber, date, action
262 def get_addressbooks(self):
264 @returns Iterable of (Address Book Factory, Book Id, Book Name)
268 def open_addressbook(self, bookId):
272 def contact_source_short_name(contactId):
277 return "Grand Central"
279 def get_contacts(self):
281 @returns Iterable of (contact id, contact name)
283 if self.__contacts is None:
286 contactsPagesUrls = [self._contactsURL]
287 for contactsPageUrl in contactsPagesUrls:
289 contactsPage = self._browser.download(contactsPageUrl)
290 except urllib2.URLError, e:
291 warnings.warn(traceback.format_exc())
292 raise RuntimeError("%s is not accesible" % contactsPageUrl)
293 for contact_match in self._contactsRe.finditer(contactsPage):
294 contactId = contact_match.group(1)
295 contactName = contact_match.group(2)
296 contact = contactId, saxutils.unescape(contactName)
297 self.__contacts.append(contact)
300 next_match = self._contactsNextRe.match(contactsPage)
301 if next_match is not None:
302 newContactsPageUrl = self._contactsURL + next_match.group(1)
303 contactsPagesUrls.append(newContactsPageUrl)
305 for contact in self.__contacts:
308 def get_contact_details(self, contactId):
310 @returns Iterable of (Phone Type, Phone Number)
313 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
314 except urllib2.URLError, e:
315 warnings.warn(traceback.format_exc())
316 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
318 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
319 phoneType = saxutils.unescape(detail_match.group(1))
320 phoneNumber = detail_match.group(2)
321 yield (phoneType, phoneNumber)
323 def get_messages(self):
326 def _grab_token(self, data):
327 "Pull the magic cookie from the datastream"
328 atGroup = self._accessTokenRe.search(data)
330 raise RuntimeError("Could not extract authentication token from GrandCentral")
331 self._accessToken = atGroup.group(1)
333 anGroup = self._accountNumRe.search(data)
334 if anGroup is not None:
335 self._accountNum = anGroup.group(1)
337 warnings.warn("Could not extract account number from GrandCentral", UserWarning, 2)
339 self._callbackNumbers = {}
340 for match in self._callbackRe.finditer(data):
341 self._callbackNumbers[match.group(1)] = match.group(2)
344 def test_backend(username, password):
347 print "Authenticated: ", backend.is_authed()
348 print "Login?: ", backend.login(username, password)
349 print "Authenticated: ", backend.is_authed()
350 # print "Token: ", backend._accessToken
351 print "Account: ", backend.get_account_number()
352 print "Callback: ", backend.get_callback_number()
353 # print "All Callback: ",
354 # pprint.pprint(backend.get_callback_numbers())
356 # pprint.pprint(list(backend.get_recent()))
357 # print "Contacts: ",
358 # for contact in backend.get_contacts():
360 # pprint.pprint(list(backend.get_contact_details(contact[0])))