Fixing some rendering bugs
[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="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         _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(None, 0)
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                 self._browser.cookies.save()
268                 return True
269
270         def get_callback_number(self):
271                 """
272                 @returns Current callback number or None
273                 """
274                 return self._callbackNumber
275
276         def get_recent(self):
277                 """
278                 @returns Iterable of (personsName, phoneNumber, date, action)
279                 """
280                 for url in (
281                         self._receivedCallsURL,
282                         self._missedCallsURL,
283                         self._placedCallsURL,
284                 ):
285                         try:
286                                 allRecentData = self._grab_json(url)
287                         except urllib2.URLError, e:
288                                 warnings.warn(traceback.format_exc())
289                                 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
290
291                         for recentCallData in allRecentData["messages"].itervalues():
292                                 number = recentCallData["displayNumber"]
293                                 date = recentCallData["relativeStartTime"]
294                                 action = ", ".join((
295                                         label.title()
296                                         for label in recentCallData["labels"]
297                                                 if label.lower() != "all" and label.lower() != "inbox"
298                                 ))
299                                 number = saxutils.unescape(number)
300                                 date = saxutils.unescape(date)
301                                 action = saxutils.unescape(action)
302                                 yield "", number, date, action
303
304         def get_addressbooks(self):
305                 """
306                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
307                 """
308                 yield self, "", ""
309
310         def open_addressbook(self, bookId):
311                 return self
312
313         @staticmethod
314         def contact_source_short_name(contactId):
315                 return "GV"
316
317         @staticmethod
318         def factory_name():
319                 return "Google Voice"
320
321         def get_contacts(self):
322                 """
323                 @returns Iterable of (contact id, contact name)
324                 """
325                 if self.__contacts is None:
326                         self.__contacts = []
327
328                         contactsPagesUrls = [self._contactsURL]
329                         for contactsPageUrl in contactsPagesUrls:
330                                 try:
331                                         contactsPage = self._browser.download(contactsPageUrl)
332                                 except urllib2.URLError, e:
333                                         warnings.warn(traceback.format_exc())
334                                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
335                                 for contact_match in self._contactsRe.finditer(contactsPage):
336                                         contactId = contact_match.group(1)
337                                         contactName = saxutils.unescape(contact_match.group(2))
338                                         contact = contactId, contactName
339                                         self.__contacts.append(contact)
340                                         yield contact
341
342                                 next_match = self._contactsNextRe.match(contactsPage)
343                                 if next_match is not None:
344                                         newContactsPageUrl = self._contactsURL + next_match.group(1)
345                                         contactsPagesUrls.append(newContactsPageUrl)
346                 else:
347                         for contact in self.__contacts:
348                                 yield contact
349
350         def get_contact_details(self, contactId):
351                 """
352                 @returns Iterable of (Phone Type, Phone Number)
353                 """
354                 try:
355                         detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
356                 except urllib2.URLError, e:
357                         warnings.warn(traceback.format_exc())
358                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
359
360                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
361                         phoneNumber = detail_match.group(1)
362                         phoneType = saxutils.unescape(detail_match.group(2))
363                         yield (phoneType, phoneNumber)
364
365         def _grab_json(self, url):
366                 flatXml = self._browser.download(url)
367                 xmlTree = ElementTree.fromstring(flatXml)
368                 jsonElement = xmlTree.getchildren()[0]
369                 flatJson = jsonElement.text
370                 jsonTree = parse_json(flatJson)
371                 return jsonTree
372
373         def _grab_account_info(self):
374                 page = self._browser.download(self._forwardURL)
375
376                 tokenGroup = self._tokenRe.search(page)
377                 if tokenGroup is None:
378                         raise RuntimeError("Could not extract authentication token from GoogleVoice")
379                 self._token = tokenGroup.group(1)
380
381                 anGroup = self._accountNumRe.search(page)
382                 if anGroup is None:
383                         raise RuntimeError("Could not extract account number from GoogleVoice")
384                 self._accountNum = anGroup.group(1)
385
386                 self._callbackNumbers = {}
387                 for match in self._callbackRe.finditer(page):
388                         callbackNumber = match.group(2)
389                         callbackName = match.group(1)
390                         self._callbackNumbers[callbackNumber] = callbackName
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