Consolidating timeout location and increasing it for slower connections
[gc-dialer] / src / gc_backend.py
1 #!/usr/bin/python
2
3 # DialCentral - Front end for Google's Grand Central service.
4 # Copyright (C) 2008  Eric Warnke ericew AT gmail DOT com
5
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.
10
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.
15
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
19
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
33 from browser_emu import MozillaEmulator
34
35
36 class GCDialer(object):
37         """
38         This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
39         the functions include login, setting up a callback number, and initalting a callback
40         """
41
42         _gcDialingStrRe = re.compile("This may take a few seconds", re.M)
43         _accessTokenRe = re.compile(r"""<input type="hidden" name="a_t" [^>]*value="(.*)"/>""")
44         _isLoginPageRe = re.compile(r"""<form method="post" action="https://www.grandcentral.com/mobile/account/login">""")
45         _callbackRe = re.compile(r"""name="default_number" value="(\d+)" />\s+(.*)\s$""", re.M)
46         _accountNumRe = re.compile(r"""<img src="/images/mobile/inbox_logo.gif" alt="GrandCentral" />\s*(.{14})\s*&nbsp""", re.M)
47         _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)
48         _contactsRe = re.compile(r"""<a href="/mobile/contacts/detail/(\d+)">(.*?)</a>""", re.S)
49         _contactsNextRe = re.compile(r""".*<a href="/mobile/contacts(\?page=\d+)">Next</a>""", re.S)
50         _contactDetailPhoneRe = re.compile(r"""(\w+):[0-9\-\(\) \t]*?<a href="/mobile/calls/click_to_call\?destno=(\d+).*?">call</a>""", re.S)
51
52         _validateRe = re.compile("^[0-9]{10,}$")
53
54         _forwardselectURL = "http://www.grandcentral.com/mobile/settings/forwarding_select"
55         _loginURL = "https://www.grandcentral.com/mobile/account/login"
56         _setforwardURL = "http://www.grandcentral.com/mobile/settings/set_forwarding?from=settings"
57         _clicktocallURL = "http://www.grandcentral.com/mobile/calls/click_to_call?a_t=%s&destno=%s"
58         _inboxallURL = "http://www.grandcentral.com/mobile/messages/inbox?types=all"
59         _contactsURL = "http://www.grandcentral.com/mobile/contacts"
60         _contactDetailURL = "http://www.grandcentral.com/mobile/contacts/detail"
61
62         def __init__(self, cookieFile = None):
63                 # Important items in this function are the setup of the browser emulation and cookie file
64                 self._browser = MozillaEmulator(None, 0)
65                 if cookieFile is None:
66                         cookieFile = os.path.join(os.path.expanduser("~"), ".gc_cookies.txt")
67                 self._browser.cookies.filename = cookieFile
68                 if os.path.isfile(cookieFile):
69                         self._browser.cookies.load()
70
71                 self._accessToken = None
72                 self._accountNum = None
73                 self._callbackNumbers = {}
74                 self._lastAuthed = 0.0
75
76                 self.__contacts = None
77
78         def is_authed(self, force = False):
79                 """
80                 Attempts to detect a current session and pull the auth token ( a_t ) from the page.
81                 @note Once logged in try not to reauth more than once a minute.
82                 @returns If authenticated
83                 """
84
85                 if (time.time() - self._lastAuthed) < 60 and not force:
86                         return True
87
88                 try:
89                         forwardSelectionPage = self._browser.download(self._forwardselectURL)
90                 except urllib2.URLError, e:
91                         warnings.warn(traceback.format_exc())
92                         raise RuntimeError("%s is not accesible" % self._forwardselectURL)
93
94                 self._browser.cookies.save()
95                 if self._isLoginPageRe.search(forwardSelectionPage) is not None:
96                         return False
97
98                 self._grab_token(forwardSelectionPage)
99                 self._lastAuthed = time.time()
100                 return True
101
102         def login(self, username, password):
103                 """
104                 Attempt to login to grandcentral
105                 @returns Whether login was successful or not
106                 """
107                 if self.is_authed():
108                         return True
109
110                 loginPostData = urllib.urlencode( {'username' : username , 'password' : password } )
111
112                 try:
113                         loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
114                 except urllib2.URLError, e:
115                         warnings.warn(traceback.format_exc())
116                         raise RuntimeError("%s is not accesible" % self._loginURL)
117
118                 return self.is_authed()
119
120         def logout(self):
121                 self._lastAuthed = 0.0
122                 self._browser.cookies.clear()
123                 self._browser.cookies.save()
124
125                 self.clear_caches()
126
127         def dial(self, number):
128                 """
129                 This is the main function responsible for initating the callback
130                 """
131                 if not self.is_valid_syntax(number):
132                         raise ValueError('Number is not valid: "%s"' % number)
133                 elif not self.is_authed():
134                         raise RuntimeError("Not Authenticated")
135
136                 if len(number) == 11 and number[0] == 1:
137                         # Strip leading 1 from 11 digit dialing
138                         number = number[1:]
139
140                 try:
141                         callSuccessPage = self._browser.download(
142                                 self._clicktocallURL % (self._accessToken, number),
143                                 None,
144                                 {'Referer' : 'http://www.grandcentral.com/mobile/messages'}
145                         )
146                 except urllib2.URLError, e:
147                         warnings.warn(traceback.format_exc())
148                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
149
150                 if self._gcDialingStrRe.search(callSuccessPage) is None:
151                         raise RuntimeError("Grand Central returned an error")
152
153                 return True
154
155         def clear_caches(self):
156                 self.__contacts = None
157
158         def is_valid_syntax(self, number):
159                 """
160                 @returns If This number be called ( syntax validation only )
161                 """
162                 return self._validateRe.match(number) is not None
163
164         def get_account_number(self):
165                 """
166                 @returns The grand central phone number
167                 """
168                 return self._accountNum
169
170         def set_sane_callback(self):
171                 """
172                 Try to set a sane default callback number on these preferences
173                 1) 1747 numbers ( Gizmo )
174                 2) anything with gizmo in the name
175                 3) anything with computer in the name
176                 4) the first value
177                 """
178                 numbers = self.get_callback_numbers()
179
180                 for number, description in numbers.iteritems():
181                         if re.compile(r"""1747""").match(number) is not None:
182                                 self.set_callback_number(number)
183                                 return
184
185                 for number, description in numbers.iteritems():
186                         if re.compile(r"""gizmo""", re.I).search(description) is not None:
187                                 self.set_callback_number(number)
188                                 return
189
190                 for number, description in numbers.iteritems():
191                         if re.compile(r"""computer""", re.I).search(description) is not None:
192                                 self.set_callback_number(number)
193                                 return
194
195                 for number, description in numbers.iteritems():
196                         self.set_callback_number(number)
197                         return
198
199         def get_callback_numbers(self):
200                 """
201                 @returns a dictionary mapping call back numbers to descriptions
202                 @note These results are cached for 30 minutes.
203                 """
204                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
205                         return self._callbackNumbers
206
207                 return {}
208
209         def set_callback_number(self, callbacknumber):
210                 """
211                 Set the number that grandcental calls
212                 @param callbacknumber should be a proper 10 digit number
213                 """
214                 callbackPostData = urllib.urlencode({
215                         'a_t': self._accessToken,
216                         'default_number': callbacknumber
217                 })
218                 try:
219                         callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
220                 except urllib2.URLError, e:
221                         warnings.warn(traceback.format_exc())
222                         raise RuntimeError("%s is not accesible" % self._setforwardURL)
223
224                 self._browser.cookies.save()
225                 return True
226
227         def get_callback_number(self):
228                 """
229                 @returns Current callback number or None
230                 """
231                 for c in self._browser.cookies:
232                         if c.name == "pda_forwarding_number":
233                                 return c.value
234                 return None
235
236         def get_recent(self):
237                 """
238                 @returns Iterable of (personsName, phoneNumber, date, action)
239                 """
240                 try:
241                         recentCallsPage = self._browser.download(self._inboxallURL)
242                 except urllib2.URLError, e:
243                         warnings.warn(traceback.format_exc())
244                         raise RuntimeError("%s is not accesible" % self._inboxallURL)
245
246                 for match in self._inboxRe.finditer(recentCallsPage):
247                         phoneNumber = match.group(4)
248                         action = match.group(1)
249                         date = match.group(2)
250                         personsName = match.group(3)
251                         yield personsName, phoneNumber, date, action
252
253         def get_addressbooks(self):
254                 """
255                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
256                 """
257                 yield self, "", ""
258
259         def open_addressbook(self, bookId):
260                 return self
261
262         @staticmethod
263         def contact_source_short_name(contactId):
264                 return "GC"
265
266         @staticmethod
267         def factory_name():
268                 return "Grand Central"
269
270         def get_contacts(self):
271                 """
272                 @returns Iterable of (contact id, contact name)
273                 """
274                 if self.__contacts is None:
275                         self.__contacts = []
276
277                         contactsPagesUrls = [self._contactsURL]
278                         for contactsPageUrl in contactsPagesUrls:
279                                 try:
280                                         contactsPage = self._browser.download(contactsPageUrl)
281                                 except urllib2.URLError, e:
282                                         warnings.warn(traceback.format_exc())
283                                         raise RuntimeError("%s is not accesible" % contactsPageUrl)
284                                 for contact_match in self._contactsRe.finditer(contactsPage):
285                                         contactId = contact_match.group(1)
286                                         contactName = contact_match.group(2)
287                                         contact = contactId, contactName
288                                         self.__contacts.append(contact)
289                                         yield contact
290
291                                 next_match = self._contactsNextRe.match(contactsPage)
292                                 if next_match is not None:
293                                         newContactsPageUrl = self._contactsURL + next_match.group(1)
294                                         contactsPagesUrls.append(newContactsPageUrl)
295                 else:
296                         for contact in self.__contacts:
297                                 yield contact
298
299         def get_contact_details(self, contactId):
300                 """
301                 @returns Iterable of (Phone Type, Phone Number)
302                 """
303                 try:
304                         detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
305                 except urllib2.URLError, e:
306                         warnings.warn(traceback.format_exc())
307                         raise RuntimeError("%s is not accesible" % self._contactDetailURL)
308
309                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
310                         phoneType = detail_match.group(1)
311                         phoneNumber = detail_match.group(2)
312                         yield (phoneType, phoneNumber)
313
314         def _grab_token(self, data):
315                 "Pull the magic cookie from the datastream"
316                 atGroup = self._accessTokenRe.search(data)
317                 self._accessToken = atGroup.group(1)
318
319                 anGroup = self._accountNumRe.search(data)
320                 self._accountNum = anGroup.group(1)
321
322                 self._callbackNumbers = {}
323                 for match in self._callbackRe.finditer(data):
324                         self._callbackNumbers[match.group(1)] = match.group(2)
325
326
327 def test_backend(username, password):
328         import pprint
329         backend = GCDialer()
330         print "Authenticated: ", backend.is_authed()
331         print "Login?: ", backend.login(username, password)
332         print "Authenticated: ", backend.is_authed()
333         print "Token: ", backend._token
334         print "Account: ", backend.get_account_number()
335         print "Callback: ", backend.get_callback_number()
336         # print "All Callback: ",
337         # pprint.pprint(backend.get_callback_numbers())
338         # print "Recent: ",
339         # pprint.pprint(list(backend.get_recent()))
340         # print "Contacts: ",
341         # for contact in backend.get_contacts():
342         #       print contact
343         #       pprint.pprint(list(backend.get_contact_details(contact[0])))
344
345         return backend