Consolidating timeout location and increasing it for slower connections
[gc-dialer] / src / gv_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 Google Voice backend code
22
23 Resources
24         http://thatsmith.com/2009/03/google-voice-addon-for-firefox/
25         http://posttopic.com/topic/google-voice-add-on-development
26 """
27
28
29 import os
30 import re
31 import urllib
32 import urllib2
33 import time
34 import warnings
35 import traceback
36
37 from xml.etree import ElementTree
38
39 from browser_emu import MozillaEmulator
40
41 try:
42         import simplejson
43 except ImportError:
44         simplejson = None
45
46
47 _TRUE_REGEX = re.compile("true")
48 _FALSE_REGEX = re.compile("false")
49
50
51 def safe_eval(s):
52         s = _TRUE_REGEX.sub("True", s)
53         s = _FALSE_REGEX.sub("False", s)
54         return eval(s, {}, {})
55
56
57 if simplejson is None:
58         def parse_json(flattened):
59                 return safe_eval(flattened)
60 else:
61         def parse_json(flattened):
62                 return simplejson.loads(flattened)
63
64
65 class GVDialer(object):
66         """
67         This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
68         the functions include login, setting up a callback number, and initalting a callback
69         """
70
71         _isNotLoginPageRe = re.compile(r"""I cannot access my account""")
72         _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
73         _accountNumRe = re.compile(r"""<b class="ms2">(.{14})</b></div>""")
74         _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
75         _validateRe = re.compile("^[0-9]{10,}$")
76         _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
77
78         _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
79         _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
80         _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
81
82         _clicktocallURL = "https://www.google.com/voice/m/sendcall"
83         _contactsURL = "https://www.google.com/voice/mobile/contacts"
84         _contactDetailURL = "https://www.google.com/voice/mobile/contact"
85
86         _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
87         _setforwardURL = "https://www.google.com//voice/m/setphone"
88         _accountNumberURL = "https://www.google.com/voice/mobile"
89         _forwardURL = "https://www.google.com/voice/mobile/phones"
90
91         _inboxURL = "https://www.google.com/voice/inbox/"
92         _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
93         _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
94         _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
95         _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
96
97         def __init__(self, cookieFile = None):
98                 # Important items in this function are the setup of the browser emulation and cookie file
99                 self._browser = MozillaEmulator(None, 0)
100                 if cookieFile is None:
101                         cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
102                 self._browser.cookies.filename = cookieFile
103                 if os.path.isfile(cookieFile):
104                         self._browser.cookies.load()
105
106                 self._accountNum = None
107                 self._lastAuthed = 0.0
108                 self._token = ""
109                 self._callbackNumber = ""
110                 self._callbackNumbers = {}
111
112                 self.__contacts = None
113
114         def is_authed(self, force = False):
115                 """
116                 Attempts to detect a current session
117                 @note Once logged in try not to reauth more than once a minute.
118                 @returns If authenticated
119                 """
120
121                 if (time.time() - self._lastAuthed) < 60 and not force:
122                         return True
123
124                 try:
125                         inboxPage = self._browser.download(self._inboxURL)
126                 except urllib2.URLError, e:
127                         warnings.warn(traceback.format_exc())
128                         raise RuntimeError("%s is not accesible" % self._inboxURL)
129
130                 self._browser.cookies.save()
131                 if self._isNotLoginPageRe.search(inboxPage) is not None:
132                         return False
133
134                 self._grab_account_info()
135                 self._lastAuthed = time.time()
136                 return True
137
138         def login(self, username, password):
139                 """
140                 Attempt to login to grandcentral
141                 @returns Whether login was successful or not
142                 """
143                 if self.is_authed():
144                         return True
145
146                 loginPostData = urllib.urlencode({
147                         'Email' : username,
148                         'Passwd' : password,
149                         'service': "grandcentral",
150                 })
151
152                 try:
153                         loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
154                 except urllib2.URLError, e:
155                         warnings.warn(traceback.format_exc())
156                         raise RuntimeError("%s is not accesible" % self._loginURL)
157
158                 return self.is_authed()
159
160         def logout(self):
161                 self._lastAuthed = 0.0
162                 self._browser.cookies.clear()
163                 self._browser.cookies.save()
164
165                 self.clear_caches()
166
167         def dial(self, number):
168                 """
169                 This is the main function responsible for initating the callback
170                 """
171                 if not self.is_valid_syntax(number):
172                         raise ValueError('Number is not valid: "%s"' % number)
173                 elif not self.is_authed():
174                         raise RuntimeError("Not Authenticated")
175
176                 if len(number) == 11 and number[0] == 1:
177                         # Strip leading 1 from 11 digit dialing
178                         number = number[1:]
179
180                 try:
181                         clickToCallData = urllib.urlencode({
182                                 "number": number,
183                                 "phone": self._callbackNumber,
184                                 "_rnr_se": self._token,
185                         })
186                         otherData = {
187                                 'Referer' : 'https://google.com/voice/m/callsms',
188                         }
189                         callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
190                 except urllib2.URLError, e:
191                         warnings.warn(traceback.format_exc())
192                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
193
194                 if self._gvDialingStrRe.search(callSuccessPage) is None:
195                         raise RuntimeError("Google Voice returned an error")
196
197                 return True
198
199         def clear_caches(self):
200                 self.__contacts = None
201
202         def is_valid_syntax(self, number):
203                 """
204                 @returns If This number be called ( syntax validation only )
205                 """
206                 return self._validateRe.match(number) is not None
207
208         def get_account_number(self):
209                 """
210                 @returns The grand central phone number
211                 """
212                 return self._accountNum
213
214         def set_sane_callback(self):
215                 """
216                 Try to set a sane default callback number on these preferences
217                 1) 1747 numbers ( Gizmo )
218                 2) anything with gizmo in the name
219                 3) anything with computer in the name
220                 4) the first value
221                 """
222                 numbers = self.get_callback_numbers()
223
224                 for number, description in numbers.iteritems():
225                         if re.compile(r"""1747""").match(number) is not None:
226                                 self.set_callback_number(number)
227                                 return
228
229                 for number, description in numbers.iteritems():
230                         if re.compile(r"""gizmo""", re.I).search(description) is not None:
231                                 self.set_callback_number(number)
232                                 return
233
234                 for number, description in numbers.iteritems():
235                         if re.compile(r"""computer""", re.I).search(description) is not None:
236                                 self.set_callback_number(number)
237                                 return
238
239                 for number, description in numbers.iteritems():
240                         self.set_callback_number(number)
241                         return
242
243         def get_callback_numbers(self):
244                 """
245                 @returns a dictionary mapping call back numbers to descriptions
246                 @note These results are cached for 30 minutes.
247                 """
248                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
249                         return self._callbackNumbers
250
251                 return {}
252
253         def set_callback_number(self, callbacknumber):
254                 """
255                 Set the number that grandcental calls
256                 @param callbacknumber should be a proper 10 digit number
257                 """
258                 self._callbackNumber = callbacknumber
259                 callbackPostData = urllib.urlencode({
260                         '_rnr_se': self._token,
261                         'phone': callbacknumber
262                 })
263                 try:
264                         callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
265                 except urllib2.URLError, e:
266                         warnings.warn(traceback.format_exc())
267                         raise RuntimeError("%s is not accesible" % self._setforwardURL)
268
269                 self._browser.cookies.save()
270                 return True
271
272         def get_callback_number(self):
273                 """
274                 @returns Current callback number or None
275                 """
276                 return self._callbackNumber
277
278         def get_recent(self):
279                 """
280                 @returns Iterable of (personsName, phoneNumber, date, action)
281                 """
282                 for url in (
283                         self._receivedCallsURL,
284                         self._missedCallsURL,
285                         self._placedCallsURL,
286                 ):
287                         try:
288                                 allRecentData = self._grab_json(url)
289                         except urllib2.URLError, e:
290                                 warnings.warn(traceback.format_exc())
291                                 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
292
293                         for recentCallData in allRecentData["messages"].itervalues():
294                                 number = recentCallData["displayNumber"]
295                                 date = recentCallData["relativeStartTime"]
296                                 action = ", ".join((
297                                         label.title()
298                                         for label in recentCallData["labels"]
299                                                 if label.lower() != "all" and label.lower() != "inbox"
300                                 ))
301                                 yield "", number, date, action
302
303         def get_addressbooks(self):
304                 """
305                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
306                 """
307                 yield self, "", ""
308
309         def open_addressbook(self, bookId):
310                 return self
311
312         @staticmethod
313         def contact_source_short_name(contactId):
314                 return "GV"
315
316         @staticmethod
317         def factory_name():
318                 return "Google Voice"
319
320         def get_contacts(self):
321                 """
322                 @returns Iterable of (contact id, contact name)
323                 """
324                 if self.__contacts is None:
325                         self.__contacts = []
326
327                         contactsPagesUrls = [self._contactsURL]
328                         for contactsPageUrl in contactsPagesUrls:
329                                 try:
330                                         contactsPage = self._browser.download(contactsPageUrl)
331                                 except urllib2.URLError, e:
332                                         warnings.warn(traceback.format_exc())
333                                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
334                                 for contact_match in self._contactsRe.finditer(contactsPage):
335                                         contactId = contact_match.group(1)
336                                         contactName = contact_match.group(2)
337                                         contact = contactId, contactName
338                                         self.__contacts.append(contact)
339                                         yield contact
340
341                                 next_match = self._contactsNextRe.match(contactsPage)
342                                 if next_match is not None:
343                                         newContactsPageUrl = self._contactsURL + next_match.group(1)
344                                         contactsPagesUrls.append(newContactsPageUrl)
345                 else:
346                         for contact in self.__contacts:
347                                 yield contact
348
349         def get_contact_details(self, contactId):
350                 """
351                 @returns Iterable of (Phone Type, Phone Number)
352                 """
353                 try:
354                         detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
355                 except urllib2.URLError, e:
356                         warnings.warn(traceback.format_exc())
357                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
358
359                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
360                         phoneNumber = detail_match.group(1)
361                         phoneType = detail_match.group(2)
362                         yield (phoneType, phoneNumber)
363
364         def _grab_json(self, url):
365                 flatXml = self._browser.download(url)
366                 xmlTree = ElementTree.fromstring(flatXml)
367                 jsonElement = xmlTree.getchildren()[0]
368                 flatJson = jsonElement.text
369                 jsonTree = parse_json(flatJson)
370                 return jsonTree
371
372         def _grab_account_info(self, loginPage = None):
373                 if loginPage is None:
374                         accountNumberPage = self._browser.download(self._accountNumberURL)
375                 else:
376                         accountNumberPage = loginPage
377                 tokenGroup = self._tokenRe.search(accountNumberPage)
378                 if tokenGroup is not None:
379                         self._token = tokenGroup.group(1)
380                 anGroup = self._accountNumRe.search(accountNumberPage)
381                 if anGroup is not None:
382                         self._accountNum = anGroup.group(1)
383
384                 callbackPage = self._browser.download(self._forwardURL)
385                 self._callbackNumbers = {}
386                 for match in self._callbackRe.finditer(callbackPage):
387                         self._callbackNumbers[match.group(2)] = match.group(1)
388
389                 if len(self._callbackNumber) == 0:
390                         self.set_sane_callback()
391
392
393 def test_backend(username, password):
394         import pprint
395         backend = GVDialer()
396         print "Authenticated: ", backend.is_authed()
397         print "Login?: ", backend.login(username, password)
398         print "Authenticated: ", backend.is_authed()
399         print "Token: ", backend._token
400         print "Account: ", backend.get_account_number()
401         print "Callback: ", backend.get_callback_number()
402         # print "All Callback: ",
403         # pprint.pprint(backend.get_callback_numbers())
404         # print "Recent: ",
405         # pprint.pprint(list(backend.get_recent()))
406         # print "Contacts: ",
407         # for contact in backend.get_contacts():
408         #       print contact
409         #       pprint.pprint(list(backend.get_contact_details(contact[0])))
410
411         return backend