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 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"""<img src="/images/mobile/inbox_logo.gif" alt="GrandCentral" />\s*(.{14})\s* """, re.M)
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 clear_caches(self):
162 self.__contacts = None
164 def is_valid_syntax(self, number):
166 @returns If This number be called ( syntax validation only )
168 return self._validateRe.match(number) is not None
170 def get_account_number(self):
172 @returns The grand central phone number
174 return self._accountNum
176 def set_sane_callback(self):
178 Try to set a sane default callback number on these preferences
179 1) 1747 numbers ( Gizmo )
180 2) anything with gizmo in the name
181 3) anything with computer in the name
184 numbers = self.get_callback_numbers()
186 for number, description in numbers.iteritems():
187 if re.compile(r"""1747""").match(number) is not None:
188 self.set_callback_number(number)
191 for number, description in numbers.iteritems():
192 if re.compile(r"""gizmo""", re.I).search(description) is not None:
193 self.set_callback_number(number)
196 for number, description in numbers.iteritems():
197 if re.compile(r"""computer""", re.I).search(description) is not None:
198 self.set_callback_number(number)
201 for number, description in numbers.iteritems():
202 self.set_callback_number(number)
205 def get_callback_numbers(self):
207 @returns a dictionary mapping call back numbers to descriptions
208 @note These results are cached for 30 minutes.
210 if time.time() - self._lastAuthed < 1800 or self.is_authed():
211 return self._callbackNumbers
215 def set_callback_number(self, callbacknumber):
217 Set the number that grandcental calls
218 @param callbacknumber should be a proper 10 digit number
220 callbackPostData = urllib.urlencode({
221 'a_t': self._accessToken,
222 'default_number': callbacknumber
225 callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
226 except urllib2.URLError, e:
227 warnings.warn(traceback.format_exc())
228 raise RuntimeError("%s is not accesible" % self._setforwardURL)
230 self._browser.cookies.save()
233 def get_callback_number(self):
235 @returns Current callback number or None
237 for c in self._browser.cookies:
238 if c.name == "pda_forwarding_number":
242 def get_recent(self):
244 @returns Iterable of (personsName, phoneNumber, date, action)
247 recentCallsPage = self._browser.download(self._inboxallURL)
248 except urllib2.URLError, e:
249 warnings.warn(traceback.format_exc())
250 raise RuntimeError("%s is not accesible" % self._inboxallURL)
252 for match in self._inboxRe.finditer(recentCallsPage):
253 phoneNumber = match.group(4)
254 action = saxutils.unescape(match.group(1))
255 date = saxutils.unescape(match.group(2))
256 personsName = saxutils.unescape(match.group(3))
257 yield personsName, phoneNumber, date, action
259 def get_addressbooks(self):
261 @returns Iterable of (Address Book Factory, Book Id, Book Name)
265 def open_addressbook(self, bookId):
269 def contact_source_short_name(contactId):
274 return "Grand Central"
276 def get_contacts(self):
278 @returns Iterable of (contact id, contact name)
280 if self.__contacts is None:
283 contactsPagesUrls = [self._contactsURL]
284 for contactsPageUrl in contactsPagesUrls:
286 contactsPage = self._browser.download(contactsPageUrl)
287 except urllib2.URLError, e:
288 warnings.warn(traceback.format_exc())
289 raise RuntimeError("%s is not accesible" % contactsPageUrl)
290 for contact_match in self._contactsRe.finditer(contactsPage):
291 contactId = contact_match.group(1)
292 contactName = contact_match.group(2)
293 contact = contactId, saxutils.unescape(contactName)
294 self.__contacts.append(contact)
297 next_match = self._contactsNextRe.match(contactsPage)
298 if next_match is not None:
299 newContactsPageUrl = self._contactsURL + next_match.group(1)
300 contactsPagesUrls.append(newContactsPageUrl)
302 for contact in self.__contacts:
305 def get_contact_details(self, contactId):
307 @returns Iterable of (Phone Type, Phone Number)
310 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
311 except urllib2.URLError, e:
312 warnings.warn(traceback.format_exc())
313 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
315 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
316 phoneType = saxutils.unescape(detail_match.group(1))
317 phoneNumber = detail_match.group(2)
318 yield (phoneType, phoneNumber)
320 def _grab_token(self, data):
321 "Pull the magic cookie from the datastream"
322 atGroup = self._accessTokenRe.search(data)
324 raise RuntimeError("Could not extract authentication token from GrandCentral")
325 self._accessToken = atGroup.group(1)
327 anGroup = self._accountNumRe.search(data)
329 raise RuntimeError("Could not extract account number from GrandCentral")
330 self._accountNum = anGroup.group(1)
332 self._callbackNumbers = {}
333 for match in self._callbackRe.finditer(data):
334 self._callbackNumbers[match.group(1)] = match.group(2)
337 def test_backend(username, password):
340 print "Authenticated: ", backend.is_authed()
341 print "Login?: ", backend.login(username, password)
342 print "Authenticated: ", backend.is_authed()
343 print "Token: ", backend._accessToken
344 print "Account: ", backend.get_account_number()
345 print "Callback: ", backend.get_callback_number()
346 # print "All Callback: ",
347 # pprint.pprint(backend.get_callback_numbers())
349 # pprint.pprint(list(backend.get_recent()))
350 # print "Contacts: ",
351 # for contact in backend.get_contacts():
353 # pprint.pprint(list(backend.get_contact_details(contact[0])))