* More debug info printed when users have issues
[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 import socket
42
43 try:
44         import simplejson
45 except ImportError:
46         simplejson = None
47
48 socket.setdefaulttimeout(5)
49
50
51 _TRUE_REGEX = re.compile("true")
52 _FALSE_REGEX = re.compile("false")
53
54
55 def safe_eval(s):
56         s = _TRUE_REGEX.sub("True", s)
57         s = _FALSE_REGEX.sub("False", s)
58         return eval(s, {}, {})
59
60
61 if simplejson is None:
62         def parse_json(flattened):
63                 return safe_eval(flattened)
64 else:
65         def parse_json(flattened):
66                 return simplejson.loads(flattened)
67
68
69 class GVDialer(object):
70         """
71         This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
72         the functions include login, setting up a callback number, and initalting a callback
73         """
74
75         _isNotLoginPageRe = re.compile(r"""I cannot access my account""")
76         _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
77         _accountNumRe = re.compile(r"""<b class="ms2">(.{14})</b></div>""")
78         _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
79         _validateRe = re.compile("^[0-9]{10,}$")
80         _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
81
82         _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
83         _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
84         _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
85
86         _clicktocallURL = "https://www.google.com/voice/m/sendcall"
87         _contactsURL = "https://www.google.com/voice/mobile/contacts"
88         _contactDetailURL = "https://www.google.com/voice/mobile/contact"
89
90         _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
91         _setforwardURL = "https://www.google.com//voice/m/setphone"
92         _accountNumberURL = "https://www.google.com/voice/mobile"
93         _forwardURL = "https://www.google.com/voice/mobile/phones"
94
95         _inboxURL = "https://www.google.com/voice/inbox/"
96         _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
97         _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
98         _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
99         _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
100
101         def __init__(self, cookieFile = None):
102                 # Important items in this function are the setup of the browser emulation and cookie file
103                 self._browser = MozillaEmulator(None, 0)
104                 if cookieFile is None:
105                         cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
106                 self._browser.cookies.filename = cookieFile
107                 if os.path.isfile(cookieFile):
108                         self._browser.cookies.load()
109
110                 self._accountNum = None
111                 self._lastAuthed = 0.0
112                 self._token = ""
113                 self._callbackNumber = ""
114                 self._callbackNumbers = {}
115
116                 self.__contacts = None
117
118         def is_authed(self, force = False):
119                 """
120                 Attempts to detect a current session
121                 @note Once logged in try not to reauth more than once a minute.
122                 @returns If authenticated
123                 """
124
125                 if (time.time() - self._lastAuthed) < 60 and not force:
126                         return True
127
128                 try:
129                         inboxPage = self._browser.download(self._inboxURL)
130                 except urllib2.URLError, e:
131                         warnings.warn(traceback.format_exc())
132                         raise RuntimeError("%s is not accesible" % self._inboxURL)
133
134                 self._browser.cookies.save()
135                 if self._isNotLoginPageRe.search(inboxPage) is not None:
136                         return False
137
138                 self._grab_account_info()
139                 self._lastAuthed = time.time()
140                 return True
141
142         def login(self, username, password):
143                 """
144                 Attempt to login to grandcentral
145                 @returns Whether login was successful or not
146                 """
147                 if self.is_authed():
148                         return True
149
150                 loginPostData = urllib.urlencode({
151                         'Email' : username,
152                         'Passwd' : password,
153                         'service': "grandcentral",
154                 })
155
156                 try:
157                         loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
158                 except urllib2.URLError, e:
159                         warnings.warn(traceback.format_exc())
160                         raise RuntimeError("%s is not accesible" % self._loginURL)
161
162                 return self.is_authed()
163
164         def logout(self):
165                 self._lastAuthed = 0.0
166                 self._browser.cookies.clear()
167                 self._browser.cookies.save()
168
169                 self.clear_caches()
170
171         def dial(self, number):
172                 """
173                 This is the main function responsible for initating the callback
174                 """
175                 if not self.is_valid_syntax(number):
176                         raise ValueError('Number is not valid: "%s"' % number)
177                 elif not self.is_authed():
178                         raise RuntimeError("Not Authenticated")
179
180                 if len(number) == 11 and number[0] == 1:
181                         # Strip leading 1 from 11 digit dialing
182                         number = number[1:]
183
184                 try:
185                         clickToCallData = urllib.urlencode({
186                                 "number": number,
187                                 "phone": self._callbackNumber,
188                                 "_rnr_se": self._token,
189                         })
190                         otherData = {
191                                 'Referer' : 'https://google.com/voice/m/callsms',
192                         }
193                         callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
194                 except urllib2.URLError, e:
195                         warnings.warn(traceback.format_exc())
196                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
197
198                 if self._gvDialingStrRe.search(callSuccessPage) is None:
199                         raise RuntimeError("Google Voice returned an error")
200
201                 return True
202
203         def clear_caches(self):
204                 self.__contacts = None
205
206         def is_valid_syntax(self, number):
207                 """
208                 @returns If This number be called ( syntax validation only )
209                 """
210                 return self._validateRe.match(number) is not None
211
212         def get_account_number(self):
213                 """
214                 @returns The grand central phone number
215                 """
216                 return self._accountNum
217
218         def set_sane_callback(self):
219                 """
220                 Try to set a sane default callback number on these preferences
221                 1) 1747 numbers ( Gizmo )
222                 2) anything with gizmo in the name
223                 3) anything with computer in the name
224                 4) the first value
225                 """
226                 numbers = self.get_callback_numbers()
227
228                 for number, description in numbers.iteritems():
229                         if re.compile(r"""1747""").match(number) is not None:
230                                 self.set_callback_number(number)
231                                 return
232
233                 for number, description in numbers.iteritems():
234                         if re.compile(r"""gizmo""", re.I).search(description) is not None:
235                                 self.set_callback_number(number)
236                                 return
237
238                 for number, description in numbers.iteritems():
239                         if re.compile(r"""computer""", re.I).search(description) is not None:
240                                 self.set_callback_number(number)
241                                 return
242
243                 for number, description in numbers.iteritems():
244                         self.set_callback_number(number)
245                         return
246
247         def get_callback_numbers(self):
248                 """
249                 @returns a dictionary mapping call back numbers to descriptions
250                 @note These results are cached for 30 minutes.
251                 """
252                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
253                         return self._callbackNumbers
254
255                 return {}
256
257         def set_callback_number(self, callbacknumber):
258                 """
259                 Set the number that grandcental calls
260                 @param callbacknumber should be a proper 10 digit number
261                 """
262                 self._callbackNumber = callbacknumber
263                 callbackPostData = urllib.urlencode({
264                         '_rnr_se': self._token,
265                         'phone': callbacknumber
266                 })
267                 try:
268                         callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
269                 except urllib2.URLError, e:
270                         warnings.warn(traceback.format_exc())
271                         raise RuntimeError("%s is not accesible" % self._setforwardURL)
272
273                 self._browser.cookies.save()
274                 return True
275
276         def get_callback_number(self):
277                 """
278                 @returns Current callback number or None
279                 """
280                 return self._callbackNumber
281
282         def get_recent(self):
283                 """
284                 @returns Iterable of (personsName, phoneNumber, date, action)
285                 """
286                 for url in (
287                         self._receivedCallsURL,
288                         self._missedCallsURL,
289                         self._placedCallsURL,
290                 ):
291                         try:
292                                 allRecentData = self._grab_json(url)
293                         except urllib2.URLError, e:
294                                 warnings.warn(traceback.format_exc())
295                                 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
296
297                         for recentCallData in allRecentData["messages"].itervalues():
298                                 number = recentCallData["displayNumber"]
299                                 date = recentCallData["relativeStartTime"]
300                                 action = ", ".join((
301                                         label.title()
302                                         for label in recentCallData["labels"]
303                                                 if label.lower() != "all" and label.lower() != "inbox"
304                                 ))
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 = 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 = 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, loginPage = None):
377                 if loginPage is None:
378                         accountNumberPage = self._browser.download(self._accountNumberURL)
379                 else:
380                         accountNumberPage = loginPage
381                 tokenGroup = self._tokenRe.search(accountNumberPage)
382                 if tokenGroup is not None:
383                         self._token = tokenGroup.group(1)
384                 anGroup = self._accountNumRe.search(accountNumberPage)
385                 if anGroup is not None:
386                         self._accountNum = anGroup.group(1)
387
388                 callbackPage = self._browser.download(self._forwardURL)
389                 self._callbackNumbers = {}
390                 for match in self._callbackRe.finditer(callbackPage):
391                         self._callbackNumbers[match.group(2)] = match.group(1)
392
393                 if len(self._callbackNumber) == 0:
394                         self.set_sane_callback()
395
396
397 def test_backend(username, password):
398         import pprint
399         backend = GVDialer()
400         print "Authenticated: ", backend.is_authed()
401         print "Login?: ", backend.login(username, password)
402         print "Authenticated: ", backend.is_authed()
403         print "Token: ", backend._token
404         print "Account: ", backend.get_account_number()
405         print "Callback: ", backend.get_callback_number()
406         # print "All Callback: ",
407         # pprint.pprint(backend.get_callback_numbers())
408         # print "Recent: ",
409         # pprint.pprint(list(backend.get_recent()))
410         # print "Contacts: ",
411         # for contact in backend.get_contacts():
412         #       print contact
413         #       pprint.pprint(list(backend.get_contact_details(contact[0])))
414
415         return backend