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"""<img src="/images/mobile/inbox_logo.gif" alt="GrandCentral" />\s*(.{14})\s* """, re.M)
48 _upgradedAccountNumRe = re.compile(r"""<input type="hidden" name="gcentral_num" [^>]*value="(.*)"/>""")
49 _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)
50 _contactsRe = re.compile(r"""<a href="/mobile/contacts/detail/(\d+)">(.*?)</a>""", re.S)
51 _contactsNextRe = re.compile(r""".*<a href="/mobile/contacts(\?page=\d+)">Next</a>""", re.S)
52 _contactDetailPhoneRe = re.compile(r"""(\w+):[0-9\-\(\) \t]*?<a href="/mobile/calls/click_to_call\?destno=(\d+).*?">call</a>""", re.S)
54 _validateRe = re.compile("^[0-9]{10,}$")
56 _forwardselectURL = "http://www.grandcentral.com/mobile/settings/forwarding_select"
57 _loginURL = "https://www.grandcentral.com/mobile/account/login"
58 _setforwardURL = "http://www.grandcentral.com/mobile/settings/set_forwarding?from=settings"
59 _clicktocallURL = "http://www.grandcentral.com/mobile/calls/click_to_call?a_t=%s&destno=%s"
60 _inboxallURL = "http://www.grandcentral.com/mobile/messages/inbox?types=all"
61 _contactsURL = "http://www.grandcentral.com/mobile/contacts"
62 _contactDetailURL = "http://www.grandcentral.com/mobile/contacts/detail"
64 def __init__(self, cookieFile = None):
65 # Important items in this function are the setup of the browser emulation and cookie file
66 self._browser = browser_emu.MozillaEmulator(1)
67 if cookieFile is None:
68 cookieFile = os.path.join(os.path.expanduser("~"), ".gc_cookies.txt")
69 self._browser.cookies.filename = cookieFile
70 if os.path.isfile(cookieFile):
71 self._browser.cookies.load()
73 self._accessToken = None
75 self._lastAuthed = 0.0
76 self._callbackNumber = ""
77 self._callbackNumbers = {}
79 self.__contacts = None
81 def is_authed(self, force = False):
83 Attempts to detect a current session and pull the auth token ( a_t ) from the page.
84 @note Once logged in try not to reauth more than once a minute.
85 @returns If authenticated
88 if (time.time() - self._lastAuthed) < 60 and not force:
92 forwardSelectionPage = self._browser.download(self._forwardselectURL)
93 except urllib2.URLError, e:
94 warnings.warn(traceback.format_exc())
97 if self._isLoginPageRe.search(forwardSelectionPage) is not None:
101 self._grab_token(forwardSelectionPage)
102 except StandardError, e:
103 warnings.warn(traceback.format_exc())
106 self._browser.cookies.save()
107 self._lastAuthed = time.time()
110 def login(self, username, password):
112 Attempt to login to grandcentral
113 @returns Whether login was successful or not
118 loginPostData = urllib.urlencode( {'username' : username , 'password' : password } )
121 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
122 except urllib2.URLError, e:
123 warnings.warn(traceback.format_exc())
124 raise RuntimeError("%s is not accesible" % self._loginURL)
126 return self.is_authed()
129 self._lastAuthed = 0.0
130 self._browser.cookies.clear()
131 self._browser.cookies.save()
135 def dial(self, number):
137 This is the main function responsible for initating the callback
139 if not self.is_valid_syntax(number):
140 raise ValueError('Number is not valid: "%s"' % number)
141 elif not self.is_authed():
142 raise RuntimeError("Not Authenticated")
144 if len(number) == 11 and number[0] == 1:
145 # Strip leading 1 from 11 digit dialing
149 callSuccessPage = self._browser.download(
150 self._clicktocallURL % (self._accessToken, number),
152 {'Referer' : 'http://www.grandcentral.com/mobile/messages'}
154 except urllib2.URLError, e:
155 warnings.warn(traceback.format_exc())
156 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
158 if self._gcDialingStrRe.search(callSuccessPage) is None:
159 raise RuntimeError("Grand Central returned an error")
163 def send_sms(self, number, message):
164 raise NotImplementedError("SMS Is Not Supported by GrandCentral")
166 def clear_caches(self):
167 self.__contacts = None
169 def is_valid_syntax(self, number):
171 @returns If This number be called ( syntax validation only )
173 return self._validateRe.match(number) is not None
175 def get_account_number(self):
177 @returns The grand central phone number
179 return self._accountNum
181 def set_sane_callback(self):
183 Try to set a sane default callback number on these preferences
184 1) 1747 numbers ( Gizmo )
185 2) anything with gizmo in the name
186 3) anything with computer in the name
189 numbers = self.get_callback_numbers()
191 for number, description in numbers.iteritems():
192 if re.compile(r"""1747""").match(number) is not None:
193 self.set_callback_number(number)
196 for number, description in numbers.iteritems():
197 if re.compile(r"""gizmo""", re.I).search(description) is not None:
198 self.set_callback_number(number)
201 for number, description in numbers.iteritems():
202 if re.compile(r"""computer""", re.I).search(description) is not None:
203 self.set_callback_number(number)
206 for number, description in numbers.iteritems():
207 self.set_callback_number(number)
210 def get_callback_numbers(self):
212 @returns a dictionary mapping call back numbers to descriptions
213 @note These results are cached for 30 minutes.
215 if time.time() - self._lastAuthed < 1800 or self.is_authed():
216 return self._callbackNumbers
220 def set_callback_number(self, callbacknumber):
222 Set the number that grandcental calls
223 @param callbacknumber should be a proper 10 digit number
225 self._callbackNumber = callbacknumber
226 callbackPostData = urllib.urlencode({
227 'a_t': self._accessToken,
228 'default_number': callbacknumber
231 callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
232 except urllib2.URLError, e:
233 warnings.warn(traceback.format_exc())
234 raise RuntimeError("%s is not accesible" % self._setforwardURL)
236 self._browser.cookies.save()
239 def get_callback_number(self):
241 @returns Current callback number or None
243 for c in self._browser.cookies:
244 if c.name == "pda_forwarding_number":
246 return self._callbackNumber
248 def get_recent(self):
250 @returns Iterable of (personsName, phoneNumber, date, action)
253 recentCallsPage = self._browser.download(self._inboxallURL)
254 except urllib2.URLError, e:
255 warnings.warn(traceback.format_exc())
256 raise RuntimeError("%s is not accesible" % self._inboxallURL)
258 for match in self._inboxRe.finditer(recentCallsPage):
259 phoneNumber = match.group(4)
260 action = saxutils.unescape(match.group(1))
261 date = saxutils.unescape(match.group(2))
262 personsName = saxutils.unescape(match.group(3))
263 yield personsName, phoneNumber, date, action
265 def get_addressbooks(self):
267 @returns Iterable of (Address Book Factory, Book Id, Book Name)
271 def open_addressbook(self, bookId):
275 def contact_source_short_name(contactId):
280 return "Grand Central"
282 def get_contacts(self):
284 @returns Iterable of (contact id, contact name)
286 if self.__contacts is None:
289 contactsPagesUrls = [self._contactsURL]
290 for contactsPageUrl in contactsPagesUrls:
292 contactsPage = self._browser.download(contactsPageUrl)
293 except urllib2.URLError, e:
294 warnings.warn(traceback.format_exc())
295 raise RuntimeError("%s is not accesible" % contactsPageUrl)
296 for contact_match in self._contactsRe.finditer(contactsPage):
297 contactId = contact_match.group(1)
298 contactName = contact_match.group(2)
299 contact = contactId, saxutils.unescape(contactName)
300 self.__contacts.append(contact)
303 next_match = self._contactsNextRe.match(contactsPage)
304 if next_match is not None:
305 newContactsPageUrl = self._contactsURL + next_match.group(1)
306 contactsPagesUrls.append(newContactsPageUrl)
308 for contact in self.__contacts:
311 def get_contact_details(self, contactId):
313 @returns Iterable of (Phone Type, Phone Number)
316 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
317 except urllib2.URLError, e:
318 warnings.warn(traceback.format_exc())
319 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
321 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
322 phoneType = saxutils.unescape(detail_match.group(1))
323 phoneNumber = detail_match.group(2)
324 yield (phoneType, phoneNumber)
326 def get_messages(self):
329 def _grab_token(self, data):
330 "Pull the magic cookie from the datastream"
331 atGroup = self._accessTokenRe.search(data)
333 raise RuntimeError("Could not extract authentication token from GrandCentral")
334 self._accessToken = atGroup.group(1)
336 anGroup = self._accountNumRe.search(data)
338 anGroup = self._upgradedAccountNumRe.search(data)
339 if anGroup is not None:
340 self._accountNum = anGroup.group(1)
342 warnings.warn("Could not extract account number from GrandCentral", UserWarning, 2)
344 self._callbackNumbers = {}
345 for match in self._callbackRe.finditer(data):
346 self._callbackNumbers[match.group(1)] = match.group(2)
349 def test_backend(username, password):
352 print "Authenticated: ", backend.is_authed()
353 print "Login?: ", backend.login(username, password)
354 print "Authenticated: ", backend.is_authed()
355 # print "Token: ", backend._accessToken
356 print "Account: ", backend.get_account_number()
357 print "Callback: ", backend.get_callback_number()
358 # print "All Callback: ",
359 # pprint.pprint(backend.get_callback_numbers())
361 # pprint.pprint(list(backend.get_recent()))
362 # print "Contacts: ",
363 # for contact in backend.get_contacts():
365 # pprint.pprint(list(backend.get_contact_details(contact[0])))