Merge gv_support changes r243:251 into the trunk
[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/m/i"
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._token = ""
107                 self._accountNum = None
108                 self._lastAuthed = 0.0
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                         return False
129
130                 if self._isNotLoginPageRe.search(inboxPage) is not None:
131                         return False
132
133                 try:
134                         self._grab_account_info()
135                 except StandardError, e:
136                         warnings.warn(traceback.format_exc())
137                         return False
138
139                 self._browser.cookies.save()
140                 self._lastAuthed = time.time()
141                 return True
142
143         def login(self, username, password):
144                 """
145                 Attempt to login to grandcentral
146                 @returns Whether login was successful or not
147                 """
148                 if self.is_authed():
149                         return True
150
151                 loginPostData = urllib.urlencode({
152                         'Email' : username,
153                         'Passwd' : password,
154                         'service': "grandcentral",
155                         "ltmpl": "mobile",
156                         "btmpl": "mobile",
157                         "PersistentCookie": "yes",
158                 })
159
160                 try:
161                         loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
162                 except urllib2.URLError, e:
163                         warnings.warn(traceback.format_exc())
164                         raise RuntimeError("%s is not accesible" % self._loginURL)
165
166                 return self.is_authed()
167
168         def logout(self):
169                 self._lastAuthed = 0.0
170                 self._browser.cookies.clear()
171                 self._browser.cookies.save()
172
173                 self.clear_caches()
174
175         def dial(self, number):
176                 """
177                 This is the main function responsible for initating the callback
178                 """
179                 if not self.is_valid_syntax(number):
180                         raise ValueError('Number is not valid: "%s"' % number)
181                 elif not self.is_authed():
182                         raise RuntimeError("Not Authenticated")
183
184                 if len(number) == 11 and number[0] == 1:
185                         # Strip leading 1 from 11 digit dialing
186                         number = number[1:]
187
188                 try:
189                         clickToCallData = urllib.urlencode({
190                                 "number": number,
191                                 "phone": self._callbackNumber,
192                                 "_rnr_se": self._token,
193                         })
194                         otherData = {
195                                 'Referer' : 'https://google.com/voice/m/callsms',
196                         }
197                         callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
198                 except urllib2.URLError, e:
199                         warnings.warn(traceback.format_exc())
200                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
201
202                 if self._gvDialingStrRe.search(callSuccessPage) is None:
203                         raise RuntimeError("Google Voice returned an error")
204
205                 return True
206
207         def clear_caches(self):
208                 self.__contacts = None
209
210         def is_valid_syntax(self, number):
211                 """
212                 @returns If This number be called ( syntax validation only )
213                 """
214                 return self._validateRe.match(number) is not None
215
216         def get_account_number(self):
217                 """
218                 @returns The grand central phone number
219                 """
220                 return self._accountNum
221
222         def set_sane_callback(self):
223                 """
224                 Try to set a sane default callback number on these preferences
225                 1) 1747 numbers ( Gizmo )
226                 2) anything with gizmo in the name
227                 3) anything with computer in the name
228                 4) the first value
229                 """
230                 numbers = self.get_callback_numbers()
231
232                 for number, description in numbers.iteritems():
233                         if re.compile(r"""1747""").match(number) is not None:
234                                 self.set_callback_number(number)
235                                 return
236
237                 for number, description in numbers.iteritems():
238                         if re.compile(r"""gizmo""", re.I).search(description) is not None:
239                                 self.set_callback_number(number)
240                                 return
241
242                 for number, description in numbers.iteritems():
243                         if re.compile(r"""computer""", re.I).search(description) is not None:
244                                 self.set_callback_number(number)
245                                 return
246
247                 for number, description in numbers.iteritems():
248                         self.set_callback_number(number)
249                         return
250
251         def get_callback_numbers(self):
252                 """
253                 @returns a dictionary mapping call back numbers to descriptions
254                 @note These results are cached for 30 minutes.
255                 """
256                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
257                         return self._callbackNumbers
258
259                 return {}
260
261         def set_callback_number(self, callbacknumber):
262                 """
263                 Set the number that grandcental calls
264                 @param callbacknumber should be a proper 10 digit number
265                 """
266                 self._callbackNumber = callbacknumber
267                 callbackPostData = urllib.urlencode({
268                         '_rnr_se': self._token,
269                         'phone': callbacknumber
270                 })
271                 try:
272                         callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
273                 except urllib2.URLError, e:
274                         warnings.warn(traceback.format_exc())
275                         raise RuntimeError("%s is not accesible" % self._setforwardURL)
276
277                 self._browser.cookies.save()
278                 return True
279
280         def get_callback_number(self):
281                 """
282                 @returns Current callback number or None
283                 """
284                 return self._callbackNumber
285
286         def get_recent(self):
287                 """
288                 @returns Iterable of (personsName, phoneNumber, date, action)
289                 """
290                 for url in (
291                         self._receivedCallsURL,
292                         self._missedCallsURL,
293                         self._placedCallsURL,
294                 ):
295                         try:
296                                 allRecentData = self._grab_json(url)
297                         except urllib2.URLError, e:
298                                 warnings.warn(traceback.format_exc())
299                                 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
300
301                         for recentCallData in allRecentData["messages"].itervalues():
302                                 number = recentCallData["displayNumber"]
303                                 date = recentCallData["relativeStartTime"]
304                                 action = ", ".join((
305                                         label.title()
306                                         for label in recentCallData["labels"]
307                                                 if label.lower() != "all" and label.lower() != "inbox"
308                                 ))
309                                 yield "", number, date, action
310
311         def get_addressbooks(self):
312                 """
313                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
314                 """
315                 yield self, "", ""
316
317         def open_addressbook(self, bookId):
318                 return self
319
320         @staticmethod
321         def contact_source_short_name(contactId):
322                 return "GV"
323
324         @staticmethod
325         def factory_name():
326                 return "Google Voice"
327
328         def get_contacts(self):
329                 """
330                 @returns Iterable of (contact id, contact name)
331                 """
332                 if self.__contacts is None:
333                         self.__contacts = []
334
335                         contactsPagesUrls = [self._contactsURL]
336                         for contactsPageUrl in contactsPagesUrls:
337                                 try:
338                                         contactsPage = self._browser.download(contactsPageUrl)
339                                 except urllib2.URLError, e:
340                                         warnings.warn(traceback.format_exc())
341                                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
342                                 for contact_match in self._contactsRe.finditer(contactsPage):
343                                         contactId = contact_match.group(1)
344                                         contactName = contact_match.group(2)
345                                         contact = contactId, contactName
346                                         self.__contacts.append(contact)
347                                         yield contact
348
349                                 next_match = self._contactsNextRe.match(contactsPage)
350                                 if next_match is not None:
351                                         newContactsPageUrl = self._contactsURL + next_match.group(1)
352                                         contactsPagesUrls.append(newContactsPageUrl)
353                 else:
354                         for contact in self.__contacts:
355                                 yield contact
356
357         def get_contact_details(self, contactId):
358                 """
359                 @returns Iterable of (Phone Type, Phone Number)
360                 """
361                 try:
362                         detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
363                 except urllib2.URLError, e:
364                         warnings.warn(traceback.format_exc())
365                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
366
367                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
368                         phoneNumber = detail_match.group(1)
369                         phoneType = detail_match.group(2)
370                         yield (phoneType, phoneNumber)
371
372         def _grab_json(self, url):
373                 flatXml = self._browser.download(url)
374                 xmlTree = ElementTree.fromstring(flatXml)
375                 jsonElement = xmlTree.getchildren()[0]
376                 flatJson = jsonElement.text
377                 jsonTree = parse_json(flatJson)
378                 return jsonTree
379
380         def _grab_account_info(self):
381                 page = self._browser.download(self._forwardURL)
382
383                 tokenGroup = self._tokenRe.search(page)
384                 if tokenGroup is None:
385                         raise RuntimeError("Could not extract authentication token from GoogleVoice")
386                 self._token = tokenGroup.group(1)
387
388                 anGroup = self._accountNumRe.search(page)
389                 if anGroup is None:
390                         raise RuntimeError("Could not extract account number from GoogleVoice")
391                 self._accountNum = anGroup.group(1)
392
393                 self._callbackNumbers = {}
394                 for match in self._callbackRe.finditer(page):
395                         callbackNumber = match.group(2)
396                         callbackName = match.group(1)
397                         self._callbackNumbers[callbackNumber] = callbackName
398
399
400 def test_backend(username, password):
401         import pprint
402         backend = GVDialer()
403         print "Authenticated: ", backend.is_authed()
404         print "Login?: ", backend.login(username, password)
405         print "Authenticated: ", backend.is_authed()
406         print "Token: ", backend._token
407         print "Account: ", backend.get_account_number()
408         print "Callback: ", backend.get_callback_number()
409         print "All Callback: ",
410         pprint.pprint(backend.get_callback_numbers())
411         # print "Recent: ",
412         # pprint.pprint(list(backend.get_recent()))
413         # print "Contacts: ",
414         # for contact in backend.get_contacts():
415         #       print contact
416         #       pprint.pprint(list(backend.get_contact_details(contact[0])))
417
418         return backend