Adding gc_backend tests
[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._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 send_sms(self, number, message):
162                 raise NotImplementedError("SMS Is Not Supported by GrandCentral")
163
164         def clear_caches(self):
165                 self.__contacts = None
166
167         def is_valid_syntax(self, number):
168                 """
169                 @returns If This number be called ( syntax validation only )
170                 """
171                 return self._validateRe.match(number) is not None
172
173         def get_account_number(self):
174                 """
175                 @returns The grand central phone number
176                 """
177                 return self._accountNum
178
179         def set_sane_callback(self):
180                 """
181                 Try to set a sane default callback number on these preferences
182                 1) 1747 numbers ( Gizmo )
183                 2) anything with gizmo in the name
184                 3) anything with computer in the name
185                 4) the first value
186                 """
187                 numbers = self.get_callback_numbers()
188
189                 for number, description in numbers.iteritems():
190                         if re.compile(r"""1747""").match(number) is not None:
191                                 self.set_callback_number(number)
192                                 return
193
194                 for number, description in numbers.iteritems():
195                         if re.compile(r"""gizmo""", re.I).search(description) is not None:
196                                 self.set_callback_number(number)
197                                 return
198
199                 for number, description in numbers.iteritems():
200                         if re.compile(r"""computer""", re.I).search(description) is not None:
201                                 self.set_callback_number(number)
202                                 return
203
204                 for number, description in numbers.iteritems():
205                         self.set_callback_number(number)
206                         return
207
208         def get_callback_numbers(self):
209                 """
210                 @returns a dictionary mapping call back numbers to descriptions
211                 @note These results are cached for 30 minutes.
212                 """
213                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
214                         return self._callbackNumbers
215
216                 return {}
217
218         def set_callback_number(self, callbacknumber):
219                 """
220                 Set the number that grandcental calls
221                 @param callbacknumber should be a proper 10 digit number
222                 """
223                 callbackPostData = urllib.urlencode({
224                         'a_t': self._accessToken,
225                         'default_number': callbacknumber
226                 })
227                 try:
228                         callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
229                 except urllib2.URLError, e:
230                         warnings.warn(traceback.format_exc())
231                         raise RuntimeError("%s is not accesible" % self._setforwardURL)
232
233                 self._browser.cookies.save()
234                 return True
235
236         def get_callback_number(self):
237                 """
238                 @returns Current callback number or None
239                 """
240                 for c in self._browser.cookies:
241                         if c.name == "pda_forwarding_number":
242                                 return c.value
243                 return ""
244
245         def get_recent(self):
246                 """
247                 @returns Iterable of (personsName, phoneNumber, date, action)
248                 """
249                 try:
250                         recentCallsPage = self._browser.download(self._inboxallURL)
251                 except urllib2.URLError, e:
252                         warnings.warn(traceback.format_exc())
253                         raise RuntimeError("%s is not accesible" % self._inboxallURL)
254
255                 for match in self._inboxRe.finditer(recentCallsPage):
256                         phoneNumber = match.group(4)
257                         action = saxutils.unescape(match.group(1))
258                         date = saxutils.unescape(match.group(2))
259                         personsName = saxutils.unescape(match.group(3))
260                         yield personsName, phoneNumber, date, action
261
262         def get_addressbooks(self):
263                 """
264                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
265                 """
266                 yield self, "", ""
267
268         def open_addressbook(self, bookId):
269                 return self
270
271         @staticmethod
272         def contact_source_short_name(contactId):
273                 return "GC"
274
275         @staticmethod
276         def factory_name():
277                 return "Grand Central"
278
279         def get_contacts(self):
280                 """
281                 @returns Iterable of (contact id, contact name)
282                 """
283                 if self.__contacts is None:
284                         self.__contacts = []
285
286                         contactsPagesUrls = [self._contactsURL]
287                         for contactsPageUrl in contactsPagesUrls:
288                                 try:
289                                         contactsPage = self._browser.download(contactsPageUrl)
290                                 except urllib2.URLError, e:
291                                         warnings.warn(traceback.format_exc())
292                                         raise RuntimeError("%s is not accesible" % contactsPageUrl)
293                                 for contact_match in self._contactsRe.finditer(contactsPage):
294                                         contactId = contact_match.group(1)
295                                         contactName = contact_match.group(2)
296                                         contact = contactId, saxutils.unescape(contactName)
297                                         self.__contacts.append(contact)
298                                         yield contact
299
300                                 next_match = self._contactsNextRe.match(contactsPage)
301                                 if next_match is not None:
302                                         newContactsPageUrl = self._contactsURL + next_match.group(1)
303                                         contactsPagesUrls.append(newContactsPageUrl)
304                 else:
305                         for contact in self.__contacts:
306                                 yield contact
307
308         def get_contact_details(self, contactId):
309                 """
310                 @returns Iterable of (Phone Type, Phone Number)
311                 """
312                 try:
313                         detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
314                 except urllib2.URLError, e:
315                         warnings.warn(traceback.format_exc())
316                         raise RuntimeError("%s is not accesible" % self._contactDetailURL)
317
318                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
319                         phoneType = saxutils.unescape(detail_match.group(1))
320                         phoneNumber = detail_match.group(2)
321                         yield (phoneType, phoneNumber)
322
323         def get_messages(self):
324                 return ()
325
326         def _grab_token(self, data):
327                 "Pull the magic cookie from the datastream"
328                 atGroup = self._accessTokenRe.search(data)
329                 if atGroup is None:
330                         raise RuntimeError("Could not extract authentication token from GrandCentral")
331                 self._accessToken = atGroup.group(1)
332
333                 anGroup = self._accountNumRe.search(data)
334                 if anGroup is not None:
335                         self._accountNum = anGroup.group(1)
336                 else:
337                         warnings.warn("Could not extract account number from GrandCentral", UserWarning, 2)
338
339                 self._callbackNumbers = {}
340                 for match in self._callbackRe.finditer(data):
341                         self._callbackNumbers[match.group(1)] = match.group(2)
342
343
344 def test_backend(username, password):
345         import pprint
346         backend = GCDialer()
347         print "Authenticated: ", backend.is_authed()
348         print "Login?: ", backend.login(username, password)
349         print "Authenticated: ", backend.is_authed()
350         # print "Token: ", backend._accessToken
351         print "Account: ", backend.get_account_number()
352         print "Callback: ", backend.get_callback_number()
353         # print "All Callback: ",
354         # pprint.pprint(backend.get_callback_numbers())
355         # print "Recent: ",
356         # pprint.pprint(list(backend.get_recent()))
357         # print "Contacts: ",
358         # for contact in backend.get_contacts():
359         #       print contact
360         #       pprint.pprint(list(backend.get_contact_details(contact[0])))
361
362         return backend