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