Removed bad code
[gc-dialer] / src / gv_backend.py
1 #!/usr/bin/python
2
3 """
4 DialCentral - Front end for Google's Grand Central service.
5 Copyright (C) 2008  Eric Warnke ericew AT gmail DOT com
6
7 This library is free software; you can redistribute it and/or
8 modify it under the terms of the GNU Lesser General Public
9 License as published by the Free Software Foundation; either
10 version 2.1 of the License, or (at your option) any later version.
11
12 This library is distributed in the hope that it will be useful,
13 but WITHOUT ANY WARRANTY; without even the implied warranty of
14 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
15 Lesser General Public License for more details.
16
17 You should have received a copy of the GNU Lesser General Public
18 License along with this library; if not, write to the Free Software
19 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
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         _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
96         _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
97
98         def __init__(self, cookieFile = None):
99                 # Important items in this function are the setup of the browser emulation and cookie file
100                 self._browser = browser_emu.MozillaEmulator(1)
101                 if cookieFile is None:
102                         cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
103                 self._browser.cookies.filename = cookieFile
104                 if os.path.isfile(cookieFile):
105                         self._browser.cookies.load()
106
107                 self._token = ""
108                 self._accountNum = None
109                 self._lastAuthed = 0.0
110                 self._callbackNumber = ""
111                 self._callbackNumbers = {}
112
113                 self.__contacts = None
114
115         def is_authed(self, force = False):
116                 """
117                 Attempts to detect a current session
118                 @note Once logged in try not to reauth more than once a minute.
119                 @returns If authenticated
120                 """
121
122                 if (time.time() - self._lastAuthed) < 60 and not force:
123                         return True
124
125                 try:
126                         self._grab_account_info()
127                 except StandardError, e:
128                         warnings.warn(traceback.format_exc())
129                         return False
130
131                 self._browser.cookies.save()
132                 self._lastAuthed = time.time()
133                 return True
134
135         def login(self, username, password):
136                 """
137                 Attempt to login to grandcentral
138                 @returns Whether login was successful or not
139                 """
140                 if self.is_authed():
141                         return True
142
143                 loginPostData = urllib.urlencode({
144                         'Email' : username,
145                         'Passwd' : password,
146                         'service': "grandcentral",
147                         "ltmpl": "mobile",
148                         "btmpl": "mobile",
149                         "PersistentCookie": "yes",
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                 # @bug This does not seem to be keeping on my tablet (but works on the
270                 # desktop), or the reading isn't working too well
271                 self._browser.cookies.save()
272                 return True
273
274         def get_callback_number(self):
275                 """
276                 @returns Current callback number or None
277                 """
278                 for c in self._browser.cookies:
279                         if c.name == "gv-ph":
280                                 return c.value
281                 return self._callbackNumber
282
283         def get_recent(self):
284                 """
285                 @returns Iterable of (personsName, phoneNumber, date, action)
286                 """
287                 for url in (
288                         self._receivedCallsURL,
289                         self._missedCallsURL,
290                         self._placedCallsURL,
291                 ):
292                         try:
293                                 flatXml = self._browser.download(url)
294                         except urllib2.URLError, e:
295                                 warnings.warn(traceback.format_exc())
296                                 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
297
298                         allRecentData = self._grab_json(flatXml)
299                         for recentCallData in allRecentData["messages"].itervalues():
300                                 number = recentCallData["displayNumber"]
301                                 date = recentCallData["relativeStartTime"]
302                                 action = ", ".join((
303                                         label.title()
304                                         for label in recentCallData["labels"]
305                                                 if label.lower() != "all" and label.lower() != "inbox"
306                                 ))
307                                 number = saxutils.unescape(number)
308                                 date = saxutils.unescape(date)
309                                 action = saxutils.unescape(action)
310                                 yield "", number, date, action
311
312         def get_addressbooks(self):
313                 """
314                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
315                 """
316                 yield self, "", ""
317
318         def open_addressbook(self, bookId):
319                 return self
320
321         @staticmethod
322         def contact_source_short_name(contactId):
323                 return "GV"
324
325         @staticmethod
326         def factory_name():
327                 return "Google Voice"
328
329         def get_contacts(self):
330                 """
331                 @returns Iterable of (contact id, contact name)
332                 """
333                 if self.__contacts is None:
334                         self.__contacts = []
335
336                         contactsPagesUrls = [self._contactsURL]
337                         for contactsPageUrl in contactsPagesUrls:
338                                 try:
339                                         contactsPage = self._browser.download(contactsPageUrl)
340                                 except urllib2.URLError, e:
341                                         warnings.warn(traceback.format_exc())
342                                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
343                                 for contact_match in self._contactsRe.finditer(contactsPage):
344                                         contactId = contact_match.group(1)
345                                         contactName = saxutils.unescape(contact_match.group(2))
346                                         contact = contactId, contactName
347                                         self.__contacts.append(contact)
348                                         yield contact
349
350                                 next_match = self._contactsNextRe.match(contactsPage)
351                                 if next_match is not None:
352                                         newContactsPageUrl = self._contactsURL + next_match.group(1)
353                                         contactsPagesUrls.append(newContactsPageUrl)
354                 else:
355                         for contact in self.__contacts:
356                                 yield contact
357
358         def get_contact_details(self, contactId):
359                 """
360                 @returns Iterable of (Phone Type, Phone Number)
361                 """
362                 try:
363                         detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
364                 except urllib2.URLError, e:
365                         warnings.warn(traceback.format_exc())
366                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
367
368                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
369                         phoneNumber = detail_match.group(1)
370                         phoneType = saxutils.unescape(detail_match.group(2))
371                         yield (phoneType, phoneNumber)
372
373         def get_messages(self):
374                 try:
375                         voicemailPage = self._browser.download(self._voicemailURL)
376                 except urllib2.URLError, e:
377                         warnings.warn(traceback.format_exc())
378                         raise RuntimeError("%s is not accesible" % self._voicemailURL)
379
380                 try:
381                         smsPage = self._browser.download(self._smsURL)
382                 except urllib2.URLError, e:
383                         warnings.warn(traceback.format_exc())
384                         raise RuntimeError("%s is not accesible" % self._smsURL)
385
386                 voicemailHtml = self._grab_html(voicemailPage)
387                 smsHtml = self._grab_html(smsPage)
388
389                 print "="*60
390                 print voicemailHtml
391                 print "-"*60
392                 print smsHtml
393                 print "="*60
394
395                 return ()
396
397         def _grab_json(self, flatXml):
398                 xmlTree = ElementTree.fromstring(flatXml)
399                 jsonElement = xmlTree.getchildren()[0]
400                 flatJson = jsonElement.text
401                 jsonTree = parse_json(flatJson)
402                 return jsonTree
403
404         def _grab_html(self, flatXml):
405                 xmlTree = ElementTree.fromstring(flatXml)
406                 htmlElement = xmlTree.getchildren()[1]
407                 flatHtml = htmlElement.text
408                 return flatHtml
409
410         def _grab_account_info(self):
411                 page = self._browser.download(self._forwardURL)
412
413                 tokenGroup = self._tokenRe.search(page)
414                 if tokenGroup is None:
415                         raise RuntimeError("Could not extract authentication token from GoogleVoice")
416                 self._token = tokenGroup.group(1)
417
418                 anGroup = self._accountNumRe.search(page)
419                 if anGroup is None:
420                         raise RuntimeError("Could not extract account number from GoogleVoice")
421                 self._accountNum = anGroup.group(1)
422
423                 self._callbackNumbers = {}
424                 for match in self._callbackRe.finditer(page):
425                         callbackNumber = match.group(2)
426                         callbackName = match.group(1)
427                         self._callbackNumbers[callbackNumber] = callbackName
428
429
430 def test_backend(username, password):
431         import pprint
432         backend = GVDialer()
433         print "Authenticated: ", backend.is_authed()
434         print "Login?: ", backend.login(username, password)
435         print "Authenticated: ", backend.is_authed()
436         print "Token: ", backend._token
437         print "Account: ", backend.get_account_number()
438         print "Callback: ", backend.get_callback_number()
439         print "All Callback: ",
440         pprint.pprint(backend.get_callback_numbers())
441         # print "Recent: ",
442         # pprint.pprint(list(backend.get_recent()))
443         # print "Contacts: ",
444         # for contact in backend.get_contacts():
445         #       print contact
446         #       pprint.pprint(list(backend.get_contact_details(contact[0])))
447
448         return backend