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