b388aff034ef12fc8784f18debeb3e49e9fef7f6
[gc-dialer] / src / gc_backend.py
1 #!/usr/bin/python
2
3 """
4 DialCentral - Front end for Google's Grand Central service.
5 Copyright (C) 2008  Eric Warnke ericew AT gmail DOT com
6
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.
11
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.
16
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
20
21 Grandcentral backend code
22 """
23
24
25 import os
26 import re
27 import urllib
28 import urllib2
29 import time
30 import warnings
31 import traceback
32 from xml.sax import saxutils
33
34 import browser_emu
35
36
37 class GCDialer(object):
38         """
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
41         """
42
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*&nbsp""", re.M)
48         _inboxRe = re.compile(r"""<td>.*?(voicemail|received|missed|call return).*?</td>\s+<td>\s+<font size="2">\s+(.*?)\s+&nbsp;\|&nbsp;\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)
52
53         _validateRe = re.compile("^[0-9]{10,}$")
54
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"
62
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()
71
72                 self._accessToken = None
73                 self._accountNum = None
74                 self._lastAuthed = 0.0
75                 self._callbackNumbers = {}
76
77                 self.__contacts = None
78
79         def is_authed(self, force = False):
80                 """
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
84                 """
85
86                 if (time.time() - self._lastAuthed) < 60 and not force:
87                         return True
88
89                 try:
90                         forwardSelectionPage = self._browser.download(self._forwardselectURL)
91                 except urllib2.URLError, e:
92                         warnings.warn(traceback.format_exc())
93                         return False
94
95                 if self._isLoginPageRe.search(forwardSelectionPage) is not None:
96                         return False
97
98                 try:
99                         self._grab_token(forwardSelectionPage)
100                 except StandardError, e:
101                         warnings.warn(traceback.format_exc())
102                         return False
103
104                 self._browser.cookies.save()
105                 self._lastAuthed = time.time()
106                 return True
107
108         def login(self, username, password):
109                 """
110                 Attempt to login to grandcentral
111                 @returns Whether login was successful or not
112                 """
113                 if self.is_authed():
114                         return True
115
116                 loginPostData = urllib.urlencode( {'username' : username , 'password' : password } )
117
118                 try:
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)
123
124                 return self.is_authed()
125
126         def logout(self):
127                 self._lastAuthed = 0.0
128                 self._browser.cookies.clear()
129                 self._browser.cookies.save()
130
131                 self.clear_caches()
132
133         def dial(self, number):
134                 """
135                 This is the main function responsible for initating the callback
136                 """
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")
141
142                 if len(number) == 11 and number[0] == 1:
143                         # Strip leading 1 from 11 digit dialing
144                         number = number[1:]
145
146                 try:
147                         callSuccessPage = self._browser.download(
148                                 self._clicktocallURL % (self._accessToken, number),
149                                 None,
150                                 {'Referer' : 'http://www.grandcentral.com/mobile/messages'}
151                         )
152                 except urllib2.URLError, e:
153                         warnings.warn(traceback.format_exc())
154                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
155
156                 if self._gcDialingStrRe.search(callSuccessPage) is None:
157                         raise RuntimeError("Grand Central returned an error")
158
159                 return True
160
161         def clear_caches(self):
162                 self.__contacts = None
163
164         def is_valid_syntax(self, number):
165                 """
166                 @returns If This number be called ( syntax validation only )
167                 """
168                 return self._validateRe.match(number) is not None
169
170         def get_account_number(self):
171                 """
172                 @returns The grand central phone number
173                 """
174                 return self._accountNum
175
176         def set_sane_callback(self):
177                 """
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
182                 4) the first value
183                 """
184                 numbers = self.get_callback_numbers()
185
186                 for number, description in numbers.iteritems():
187                         if re.compile(r"""1747""").match(number) is not None:
188                                 self.set_callback_number(number)
189                                 return
190
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)
194                                 return
195
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)
199                                 return
200
201                 for number, description in numbers.iteritems():
202                         self.set_callback_number(number)
203                         return
204
205         def get_callback_numbers(self):
206                 """
207                 @returns a dictionary mapping call back numbers to descriptions
208                 @note These results are cached for 30 minutes.
209                 """
210                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
211                         return self._callbackNumbers
212
213                 return {}
214
215         def set_callback_number(self, callbacknumber):
216                 """
217                 Set the number that grandcental calls
218                 @param callbacknumber should be a proper 10 digit number
219                 """
220                 callbackPostData = urllib.urlencode({
221                         'a_t': self._accessToken,
222                         'default_number': callbacknumber
223                 })
224                 try:
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)
229
230                 self._browser.cookies.save()
231                 return True
232
233         def get_callback_number(self):
234                 """
235                 @returns Current callback number or None
236                 """
237                 for c in self._browser.cookies:
238                         if c.name == "pda_forwarding_number":
239                                 return c.value
240                 return None
241
242         def get_recent(self):
243                 """
244                 @returns Iterable of (personsName, phoneNumber, date, action)
245                 """
246                 try:
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)
251
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
258
259         def get_addressbooks(self):
260                 """
261                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
262                 """
263                 yield self, "", ""
264
265         def open_addressbook(self, bookId):
266                 return self
267
268         @staticmethod
269         def contact_source_short_name(contactId):
270                 return "GC"
271
272         @staticmethod
273         def factory_name():
274                 return "Grand Central"
275
276         def get_contacts(self):
277                 """
278                 @returns Iterable of (contact id, contact name)
279                 """
280                 if self.__contacts is None:
281                         self.__contacts = []
282
283                         contactsPagesUrls = [self._contactsURL]
284                         for contactsPageUrl in contactsPagesUrls:
285                                 try:
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)
295                                         yield contact
296
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)
301                 else:
302                         for contact in self.__contacts:
303                                 yield contact
304
305         def get_contact_details(self, contactId):
306                 """
307                 @returns Iterable of (Phone Type, Phone Number)
308                 """
309                 try:
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)
314
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)
319
320         def get_messages(self):
321                 return ()
322
323         def _grab_token(self, data):
324                 "Pull the magic cookie from the datastream"
325                 atGroup = self._accessTokenRe.search(data)
326                 if atGroup is None:
327                         raise RuntimeError("Could not extract authentication token from GrandCentral")
328                 self._accessToken = atGroup.group(1)
329
330                 anGroup = self._accountNumRe.search(data)
331                 if anGroup is None:
332                         raise RuntimeError("Could not extract account number from GrandCentral")
333                 self._accountNum = anGroup.group(1)
334
335                 self._callbackNumbers = {}
336                 for match in self._callbackRe.finditer(data):
337                         self._callbackNumbers[match.group(1)] = match.group(2)
338
339
340 def test_backend(username, password):
341         import pprint
342         backend = GCDialer()
343         print "Authenticated: ", backend.is_authed()
344         print "Login?: ", backend.login(username, password)
345         print "Authenticated: ", backend.is_authed()
346         print "Token: ", backend._accessToken
347         print "Account: ", backend.get_account_number()
348         print "Callback: ", backend.get_callback_number()
349         # print "All Callback: ",
350         # pprint.pprint(backend.get_callback_numbers())
351         # print "Recent: ",
352         # pprint.pprint(list(backend.get_recent()))
353         # print "Contacts: ",
354         # for contact in backend.get_contacts():
355         #       print contact
356         #       pprint.pprint(list(backend.get_contact_details(contact[0])))
357
358         return backend