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