Changing some references to GC Dialer in the code
[gc-dialer] / src / dialcentral / 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
32 from browser_emu import MozillaEmulator
33
34 import socket
35
36
37 socket.setdefaulttimeout(5)
38
39
40 class GCDialer(object):
41         """
42         This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
43         the functions include login, setting up a callback number, and initalting a callback
44         """
45
46         _gcDialingStrRe = re.compile("This may take a few seconds", re.M)
47         _accessTokenRe = re.compile(r"""<input type="hidden" name="a_t" [^>]*value="(.*)"/>""")
48         _isLoginPageRe = re.compile(r"""<form method="post" action="https://www.grandcentral.com/mobile/account/login">""")
49         _callbackRe = re.compile(r"""name="default_number" value="(\d+)" />\s+(.*)\s$""", re.M)
50         _accountNumRe = re.compile(r"""<img src="/images/mobile/inbox_logo.gif" alt="GrandCentral" />\s*(.{14})\s*&nbsp""", re.M)
51         _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)
52         _contactsRe = re.compile(r"""<a href="/mobile/contacts/detail/(\d+)">(.*?)</a>""", re.S)
53         _contactsNextRe = re.compile(r""".*<a href="/mobile/contacts(\?page=\d+)">Next</a>""", re.S)
54         _contactDetailGroupRe   = re.compile(r"""Group:\s*(\w*)""", 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._msg = ""
70
71                 self._browser = MozillaEmulator(None, 0)
72                 if cookieFile is None:
73                         cookieFile = os.path.join(os.path.expanduser("~"), ".gc_dialer_cookies.txt")
74                 self._browser.cookies.filename = cookieFile
75                 if os.path.isfile(cookieFile):
76                         self._browser.cookies.load()
77
78                 self._accessToken = None
79                 self._accountNum = None
80                 self._callbackNumbers = {}
81                 self._lastAuthed = 0.0
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(GCDialer._forwardselectURL)
95                 except urllib2.URLError, e:
96                         warnings.warn("%s is not accesible" % GCDialer._forwardselectURL, UserWarning, 2)
97                         return False
98
99                 self._browser.cookies.save()
100                 if GCDialer._isLoginPageRe.search(forwardSelectionPage) is None:
101                         self._grab_token(forwardSelectionPage)
102                         self._lastAuthed = time.time()
103                         return True
104
105                 return False
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(GCDialer._loginURL, loginPostData)
119                 except urllib2.URLError, e:
120                         warnings.warn("%s is not accesible" % GCDialer._loginURL, UserWarning, 2)
121                         return False
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         def dial(self, number):
131                 """
132                 This is the main function responsible for initating the callback
133                 """
134                 self._msg = ""
135
136                 # If the number is not valid throw exception
137                 if not self.is_valid_syntax(number):
138                         raise ValueError('number is not valid')
139
140                 # No point if we don't have the magic cookie
141                 if not self.is_authed():
142                         self._msg = "Not authenticated"
143                         return False
144
145                 # Strip leading 1 from 11 digit dialing
146                 if len(number) == 11 and number[0] == 1:
147                         number = number[1:]
148
149                 try:
150                         callSuccessPage = self._browser.download(
151                                 GCDialer._clicktocallURL % (self._accessToken, number),
152                                 None,
153                                 {'Referer' : 'http://www.grandcentral.com/mobile/messages'}
154                         )
155                 except urllib2.URLError, e:
156                         warnings.warn("%s is not accesible" % GCDialer._clicktocallURL, UserWarning, 2)
157                         return False
158
159                 if GCDialer._gcDialingStrRe.search(callSuccessPage) is not None:
160                         return True
161                 else:
162                         self._msg = "Grand Central returned an error"
163                         return False
164
165                 self._msg = "Unknown Error"
166                 return False
167
168         def clear_caches(self):
169                 pass
170
171         def is_valid_syntax(self, number):
172                 """
173                 @returns If This number be called ( syntax validation only )
174                 """
175                 return self._validateRe.match(number) is not None
176
177         def get_account_number(self):
178                 """
179                 @returns The grand central phone number
180                 """
181                 return self._accountNum
182
183         def set_sane_callback(self):
184                 """
185                 Try to set a sane default callback number on these preferences
186                 1) 1747 numbers ( Gizmo )
187                 2) anything with gizmo in the name
188                 3) anything with computer in the name
189                 4) the first value
190                 """
191                 numbers = self.get_callback_numbers()
192
193                 for number, description in numbers.iteritems():
194                         if not re.compile(r"""1747""").match(number) is None:
195                                 self.set_callback_number(number)
196                                 return
197
198                 for number, description in numbers.iteritems():
199                         if not re.compile(r"""gizmo""", re.I).search(description) is None:
200                                 self.set_callback_number(number)
201                                 return
202
203                 for number, description in numbers.iteritems():
204                         if not re.compile(r"""computer""", re.I).search(description) is None:
205                                 self.set_callback_number(number)
206                                 return
207
208                 for number, description in numbers.iteritems():
209                         self.set_callback_number(number)
210                         return
211
212         def get_callback_numbers(self):
213                 """
214                 @returns a dictionary mapping call back numbers to descriptions
215                 @note These results are cached for 30 minutes.
216                 """
217                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
218                         return self._callbackNumbers
219
220                 return {}
221
222         def set_callback_number(self, callbacknumber):
223                 """
224                 Set the number that grandcental calls
225                 @param callbacknumber should be a proper 10 digit number
226                 """
227                 callbackPostData = urllib.urlencode({
228                         'a_t': self._accessToken,
229                         'default_number': callbacknumber
230                 })
231                 try:
232                         callbackSetPage = self._browser.download(GCDialer._setforwardURL, callbackPostData)
233                 except urllib2.URLError, e:
234                         warnings.warn("%s is not accesible" % GCDialer._setforwardURL, UserWarning, 2)
235                         return False
236
237                 self._browser.cookies.save()
238                 return True
239
240         def get_callback_number(self):
241                 """
242                 @returns Current callback number or None
243                 """
244                 for c in self._browser.cookies:
245                         if c.name == "pda_forwarding_number":
246                                 return c.value
247                 return None
248
249         def get_recent(self):
250                 """
251                 @returns Iterable of (personsName, phoneNumber, date, action)
252                 """
253                 try:
254                         recentCallsPage = self._browser.download(GCDialer._inboxallURL)
255                 except urllib2.URLError, e:
256                         warnings.warn("%s is not accesible" % GCDialer._inboxallURL, UserWarning, 2)
257                         return
258
259                 for match in self._inboxRe.finditer(recentCallsPage):
260                         phoneNumber = match.group(4)
261                         action = match.group(1)
262                         date = match.group(2)
263                         personsName = match.group(3)
264                         yield personsName, phoneNumber, date, action
265
266         def get_addressbooks(self):
267                 """
268                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
269                 """
270                 yield self, "", ""
271         
272         def open_addressbook(self, bookId):
273                 return self
274
275         @staticmethod
276         def contact_source_short_name(contactId):
277                 return "GC"
278
279         @staticmethod
280         def factory_name():
281                 return "Grand Central"
282
283         def get_contacts(self):
284                 """
285                 @returns Iterable of (contact id, contact name)
286                 """
287                 contactsPagesUrls = [GCDialer._contactsURL]
288                 for contactsPageUrl in contactsPagesUrls:
289                         contactsPage = self._browser.download(contactsPageUrl)
290                         for contact_match in self._contactsRe.finditer(contactsPage):
291                                 contactId = contact_match.group(1)
292                                 contactName = contact_match.group(2)
293                                 yield contactId, contactName
294
295                         next_match = self._contactsNextRe.match(contactsPage)
296                         if next_match is not None:
297                                 newContactsPageUrl = self._contactsURL + next_match.group(1)
298                                 contactsPagesUrls.append(newContactsPageUrl)
299         
300         def get_contact_details(self, contactId):
301                 """
302                 @returns Iterable of (Phone Type, Phone Number)
303                 """
304                 detailPage = self._browser.download(GCDialer._contactDetailURL + '/' + contactId)
305                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
306                         phoneType = detail_match.group(1)
307                         phoneNumber = detail_match.group(2)
308                         yield (phoneType, phoneNumber)
309
310         def _grab_token(self, data):
311                 "Pull the magic cookie from the datastream"
312                 atGroup = GCDialer._accessTokenRe.search(data)
313                 self._accessToken = atGroup.group(1)
314
315                 anGroup = GCDialer._accountNumRe.search(data)
316                 self._accountNum = anGroup.group(1)
317
318                 self._callbackNumbers = {}
319                 for match in GCDialer._callbackRe.finditer(data):
320                         self._callbackNumbers[match.group(1)] = match.group(2)