Hopefully testing and fixes for forwarding numbers for GrandCentral
[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"""<input type="hidden" name="gcentral_num" [^>]*value="(.*)"/>""")
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 = ""
74                 self._lastAuthed = 0.0
75                 self._callbackNumber = ""
76                 self._callbackNumbers = {}
77
78                 self.__contacts = None
79
80         def is_authed(self, force = False):
81                 """
82                 Attempts to detect a current session and pull the auth token ( a_t ) from the page.
83                 @note Once logged in try not to reauth more than once a minute.
84                 @returns If authenticated
85                 """
86
87                 if (time.time() - self._lastAuthed) < 60 and not force:
88                         return True
89
90                 try:
91                         forwardSelectionPage = self._browser.download(self._forwardselectURL)
92                 except urllib2.URLError, e:
93                         warnings.warn(traceback.format_exc())
94                         return False
95
96                 if self._isLoginPageRe.search(forwardSelectionPage) is not None:
97                         return False
98
99                 try:
100                         self._grab_token(forwardSelectionPage)
101                 except StandardError, e:
102                         warnings.warn(traceback.format_exc())
103                         return False
104
105                 self._browser.cookies.save()
106                 self._lastAuthed = time.time()
107                 return True
108
109         def login(self, username, password):
110                 """
111                 Attempt to login to grandcentral
112                 @returns Whether login was successful or not
113                 """
114                 if self.is_authed():
115                         return True
116
117                 loginPostData = urllib.urlencode( {'username' : username , 'password' : password } )
118
119                 try:
120                         loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
121                 except urllib2.URLError, e:
122                         warnings.warn(traceback.format_exc())
123                         raise RuntimeError("%s is not accesible" % self._loginURL)
124
125                 return self.is_authed()
126
127         def logout(self):
128                 self._lastAuthed = 0.0
129                 self._browser.cookies.clear()
130                 self._browser.cookies.save()
131
132                 self.clear_caches()
133
134         def dial(self, number):
135                 """
136                 This is the main function responsible for initating the callback
137                 """
138                 if not self.is_valid_syntax(number):
139                         raise ValueError('Number is not valid: "%s"' % number)
140                 elif not self.is_authed():
141                         raise RuntimeError("Not Authenticated")
142
143                 if len(number) == 11 and number[0] == 1:
144                         # Strip leading 1 from 11 digit dialing
145                         number = number[1:]
146
147                 try:
148                         callSuccessPage = self._browser.download(
149                                 self._clicktocallURL % (self._accessToken, number),
150                                 None,
151                                 {'Referer' : 'http://www.grandcentral.com/mobile/messages'}
152                         )
153                 except urllib2.URLError, e:
154                         warnings.warn(traceback.format_exc())
155                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
156
157                 if self._gcDialingStrRe.search(callSuccessPage) is None:
158                         raise RuntimeError("Grand Central returned an error")
159
160                 return True
161
162         def send_sms(self, number, message):
163                 raise NotImplementedError("SMS Is Not Supported by GrandCentral")
164
165         def clear_caches(self):
166                 self.__contacts = None
167
168         def is_valid_syntax(self, number):
169                 """
170                 @returns If This number be called ( syntax validation only )
171                 """
172                 return self._validateRe.match(number) is not None
173
174         def get_account_number(self):
175                 """
176                 @returns The grand central phone number
177                 """
178                 return self._accountNum
179
180         def set_sane_callback(self):
181                 """
182                 Try to set a sane default callback number on these preferences
183                 1) 1747 numbers ( Gizmo )
184                 2) anything with gizmo in the name
185                 3) anything with computer in the name
186                 4) the first value
187                 """
188                 numbers = self.get_callback_numbers()
189
190                 for number, description in numbers.iteritems():
191                         if re.compile(r"""1747""").match(number) is not None:
192                                 self.set_callback_number(number)
193                                 return
194
195                 for number, description in numbers.iteritems():
196                         if re.compile(r"""gizmo""", re.I).search(description) is not None:
197                                 self.set_callback_number(number)
198                                 return
199
200                 for number, description in numbers.iteritems():
201                         if re.compile(r"""computer""", re.I).search(description) is not None:
202                                 self.set_callback_number(number)
203                                 return
204
205                 for number, description in numbers.iteritems():
206                         self.set_callback_number(number)
207                         return
208
209         def get_callback_numbers(self):
210                 """
211                 @returns a dictionary mapping call back numbers to descriptions
212                 @note These results are cached for 30 minutes.
213                 """
214                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
215                         return self._callbackNumbers
216
217                 return {}
218
219         def set_callback_number(self, callbacknumber):
220                 """
221                 Set the number that grandcental calls
222                 @param callbacknumber should be a proper 10 digit number
223                 """
224                 self._callbackNumber = callbacknumber
225                 callbackPostData = urllib.urlencode({
226                         'a_t': self._accessToken,
227                         'default_number': callbacknumber
228                 })
229                 try:
230                         callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
231                 except urllib2.URLError, e:
232                         warnings.warn(traceback.format_exc())
233                         raise RuntimeError("%s is not accesible" % self._setforwardURL)
234
235                 self._browser.cookies.save()
236                 return True
237
238         def get_callback_number(self):
239                 """
240                 @returns Current callback number or None
241                 """
242                 for c in self._browser.cookies:
243                         if c.name == "pda_forwarding_number":
244                                 return c.value
245                 return self._callbackNumber
246
247         def get_recent(self):
248                 """
249                 @returns Iterable of (personsName, phoneNumber, date, action)
250                 """
251                 try:
252                         recentCallsPage = self._browser.download(self._inboxallURL)
253                 except urllib2.URLError, e:
254                         warnings.warn(traceback.format_exc())
255                         raise RuntimeError("%s is not accesible" % self._inboxallURL)
256
257                 for match in self._inboxRe.finditer(recentCallsPage):
258                         phoneNumber = match.group(4)
259                         action = saxutils.unescape(match.group(1))
260                         date = saxutils.unescape(match.group(2))
261                         personsName = saxutils.unescape(match.group(3))
262                         yield personsName, phoneNumber, date, action
263
264         def get_addressbooks(self):
265                 """
266                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
267                 """
268                 yield self, "", ""
269
270         def open_addressbook(self, bookId):
271                 return self
272
273         @staticmethod
274         def contact_source_short_name(contactId):
275                 return "GC"
276
277         @staticmethod
278         def factory_name():
279                 return "Grand Central"
280
281         def get_contacts(self):
282                 """
283                 @returns Iterable of (contact id, contact name)
284                 """
285                 if self.__contacts is None:
286                         self.__contacts = []
287
288                         contactsPagesUrls = [self._contactsURL]
289                         for contactsPageUrl in contactsPagesUrls:
290                                 try:
291                                         contactsPage = self._browser.download(contactsPageUrl)
292                                 except urllib2.URLError, e:
293                                         warnings.warn(traceback.format_exc())
294                                         raise RuntimeError("%s is not accesible" % contactsPageUrl)
295                                 for contact_match in self._contactsRe.finditer(contactsPage):
296                                         contactId = contact_match.group(1)
297                                         contactName = contact_match.group(2)
298                                         contact = contactId, saxutils.unescape(contactName)
299                                         self.__contacts.append(contact)
300                                         yield contact
301
302                                 next_match = self._contactsNextRe.match(contactsPage)
303                                 if next_match is not None:
304                                         newContactsPageUrl = self._contactsURL + next_match.group(1)
305                                         contactsPagesUrls.append(newContactsPageUrl)
306                 else:
307                         for contact in self.__contacts:
308                                 yield contact
309
310         def get_contact_details(self, contactId):
311                 """
312                 @returns Iterable of (Phone Type, Phone Number)
313                 """
314                 try:
315                         detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
316                 except urllib2.URLError, e:
317                         warnings.warn(traceback.format_exc())
318                         raise RuntimeError("%s is not accesible" % self._contactDetailURL)
319
320                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
321                         phoneType = saxutils.unescape(detail_match.group(1))
322                         phoneNumber = detail_match.group(2)
323                         yield (phoneType, phoneNumber)
324
325         def get_messages(self):
326                 return ()
327
328         def _grab_token(self, data):
329                 "Pull the magic cookie from the datastream"
330                 atGroup = self._accessTokenRe.search(data)
331                 if atGroup is None:
332                         raise RuntimeError("Could not extract authentication token from GrandCentral")
333                 self._accessToken = atGroup.group(1)
334
335                 anGroup = self._accountNumRe.search(data)
336                 if anGroup is not None:
337                         self._accountNum = anGroup.group(1)
338                 else:
339                         warnings.warn("Could not extract account number from GrandCentral", UserWarning, 2)
340
341                 self._callbackNumbers = {}
342                 for match in self._callbackRe.finditer(data):
343                         self._callbackNumbers[match.group(1)] = match.group(2)
344
345
346 def test_backend(username, password):
347         import pprint
348         backend = GCDialer()
349         print "Authenticated: ", backend.is_authed()
350         print "Login?: ", backend.login(username, password)
351         print "Authenticated: ", backend.is_authed()
352         # print "Token: ", backend._accessToken
353         print "Account: ", backend.get_account_number()
354         print "Callback: ", backend.get_callback_number()
355         # print "All Callback: ",
356         # pprint.pprint(backend.get_callback_numbers())
357         # print "Recent: ",
358         # pprint.pprint(list(backend.get_recent()))
359         # print "Contacts: ",
360         # for contact in backend.get_contacts():
361         #       print contact
362         #       pprint.pprint(list(backend.get_contact_details(contact[0])))
363
364         return backend