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