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