50f2ece7ce5b5ba38bfa20c52a692413200b5fa1
[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
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._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(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                 self.clear_caches()
131
132         def dial(self, number):
133                 """
134                 This is the main function responsible for initating the callback
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                         raise RunetimeError("Not Authenticated")
143
144                 # Strip leading 1 from 11 digit dialing
145                 if len(number) == 11 and number[0] == 1:
146                         number = number[1:]
147
148                 try:
149                         callSuccessPage = self._browser.download(
150                                 GCDialer._clicktocallURL % (self._accessToken, number),
151                                 None,
152                                 {'Referer' : 'http://www.grandcentral.com/mobile/messages'}
153                         )
154                 except urllib2.URLError, e:
155                         warnings.warn("%s is not accesible" % GCDialer._clicktocallURL, UserWarning, 2)
156                         raise RunetimeError("%s is not accesible" % GCDialer._clicktocallURL)
157
158                 if GCDialer._gcDialingStrRe.search(callSuccessPage) is None:
159                         raise RuntimeError("Grand Central returned an error")
160
161                 return True
162
163         def clear_caches(self):
164                 self.__contacts = None
165
166         def is_valid_syntax(self, number):
167                 """
168                 @returns If This number be called ( syntax validation only )
169                 """
170                 return self._validateRe.match(number) is not None
171
172         def get_account_number(self):
173                 """
174                 @returns The grand central phone number
175                 """
176                 return self._accountNum
177
178         def set_sane_callback(self):
179                 """
180                 Try to set a sane default callback number on these preferences
181                 1) 1747 numbers ( Gizmo )
182                 2) anything with gizmo in the name
183                 3) anything with computer in the name
184                 4) the first value
185                 """
186                 numbers = self.get_callback_numbers()
187
188                 for number, description in numbers.iteritems():
189                         if not re.compile(r"""1747""").match(number) is None:
190                                 self.set_callback_number(number)
191                                 return
192
193                 for number, description in numbers.iteritems():
194                         if not re.compile(r"""gizmo""", re.I).search(description) is None:
195                                 self.set_callback_number(number)
196                                 return
197
198                 for number, description in numbers.iteritems():
199                         if not re.compile(r"""computer""", re.I).search(description) is None:
200                                 self.set_callback_number(number)
201                                 return
202
203                 for number, description in numbers.iteritems():
204                         self.set_callback_number(number)
205                         return
206
207         def get_callback_numbers(self):
208                 """
209                 @returns a dictionary mapping call back numbers to descriptions
210                 @note These results are cached for 30 minutes.
211                 """
212                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
213                         return self._callbackNumbers
214
215                 return {}
216
217         def set_callback_number(self, callbacknumber):
218                 """
219                 Set the number that grandcental calls
220                 @param callbacknumber should be a proper 10 digit number
221                 """
222                 callbackPostData = urllib.urlencode({
223                         'a_t': self._accessToken,
224                         'default_number': callbacknumber
225                 })
226                 try:
227                         callbackSetPage = self._browser.download(GCDialer._setforwardURL, callbackPostData)
228                 except urllib2.URLError, e:
229                         warnings.warn("%s is not accesible" % GCDialer._setforwardURL, UserWarning, 2)
230                         return False
231
232                 self._browser.cookies.save()
233                 return True
234
235         def get_callback_number(self):
236                 """
237                 @returns Current callback number or None
238                 """
239                 for c in self._browser.cookies:
240                         if c.name == "pda_forwarding_number":
241                                 return c.value
242                 return None
243
244         def get_recent(self):
245                 """
246                 @returns Iterable of (personsName, phoneNumber, date, action)
247                 """
248                 try:
249                         recentCallsPage = self._browser.download(GCDialer._inboxallURL)
250                 except urllib2.URLError, e:
251                         warnings.warn("%s is not accesible" % GCDialer._inboxallURL, UserWarning, 2)
252                         return
253
254                 for match in self._inboxRe.finditer(recentCallsPage):
255                         phoneNumber = match.group(4)
256                         action = match.group(1)
257                         date = match.group(2)
258                         personsName = match.group(3)
259                         yield personsName, phoneNumber, date, action
260
261         def get_addressbooks(self):
262                 """
263                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
264                 """
265                 yield self, "", ""
266
267         def open_addressbook(self, bookId):
268                 return self
269
270         @staticmethod
271         def contact_source_short_name(contactId):
272                 return "GC"
273
274         @staticmethod
275         def factory_name():
276                 return "Grand Central"
277
278         def get_contacts(self):
279                 """
280                 @returns Iterable of (contact id, contact name)
281                 """
282                 if self.__contacts is None:
283                         self.__contacts = []
284
285                         contactsPagesUrls = [GCDialer._contactsURL]
286                         for contactsPageUrl in contactsPagesUrls:
287                                 contactsPage = self._browser.download(contactsPageUrl)
288                                 for contact_match in self._contactsRe.finditer(contactsPage):
289                                         contactId = contact_match.group(1)
290                                         contactName = contact_match.group(2)
291                                         contact = contactId, contactName
292                                         self.__contacts.append(contact)
293                                         yield contact
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                 else:
300                         for contact in self.__contacts:
301                                 yield contact
302
303         def get_contact_details(self, contactId):
304                 """
305                 @returns Iterable of (Phone Type, Phone Number)
306                 """
307                 detailPage = self._browser.download(GCDialer._contactDetailURL + '/' + contactId)
308                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
309                         phoneType = detail_match.group(1)
310                         phoneNumber = detail_match.group(2)
311                         yield (phoneType, phoneNumber)
312
313         def _grab_token(self, data):
314                 "Pull the magic cookie from the datastream"
315                 atGroup = GCDialer._accessTokenRe.search(data)
316                 self._accessToken = atGroup.group(1)
317
318                 anGroup = GCDialer._accountNumRe.search(data)
319                 self._accountNum = anGroup.group(1)
320
321                 self._callbackNumbers = {}
322                 for match in GCDialer._callbackRe.finditer(data):
323                         self._callbackNumbers[match.group(1)] = match.group(2)