Fixing some error reporting
[gc-dialer] / src / gv_backend.py
1 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
2 # Lesser General Public License for more details.
3
4 # You should have received a copy of the GNU Lesser General Public
5 # License along with this library; if not, write to the Free Software
6 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
7
8 """
9 Google Voice backend code
10
11 Resources
12         http://thatsmith.com/2009/03/google-voice-addon-for-firefox/
13         http://posttopic.com/topic/google-voice-add-on-development
14 """
15
16
17 import os
18 import re
19 import urllib
20 import urllib2
21 import time
22 import warnings
23
24 from xml.etree import ElementTree
25
26 from browser_emu import MozillaEmulator
27
28 import socket
29
30 try:
31         import simplejson
32 except ImportError:
33         simplejson = None
34
35 socket.setdefaulttimeout(5)
36
37
38 _TRUE_REGEX = re.compile("true")
39 _FALSE_REGEX = re.compile("false")
40
41
42 def safe_eval(s):
43         s = _TRUE_REGEX.sub("True", s)
44         s = _FALSE_REGEX.sub("False", s)
45         return eval(s, {}, {})
46
47
48 if simplejson is None:
49         def parse_json(flattened):
50                 return safe_eval(flattened)
51 else:
52         def parse_json(flattened):
53                 return simplejson.loads(json)
54
55
56 class GVDialer(object):
57         """
58         This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
59         the functions include login, setting up a callback number, and initalting a callback
60         """
61
62         _contactsRe = re.compile(r"""<a href="/mobile/contacts/detail/(\d+)">(.*?)</a>""", re.S)
63         _contactsNextRe = re.compile(r""".*<a href="/mobile/contacts(\?page=\d+)">Next</a>""", re.S)
64         _contactDetailGroupRe = re.compile(r"""Group:\s*(\w*)""", re.S)
65         _contactDetailPhoneRe = re.compile(r"""(\w+):[0-9\-\(\) \t]*?<a href="/mobile/calls/click_to_call\?destno=(\d+).*?">call</a>""", re.S)
66
67         _isNotLoginPageRe = re.compile(r"""I cannot access my account""")
68         _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
69         _accountNumRe = re.compile(r"""<b class="ms2">(.{14})</b></div>""")
70         _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
71         _validateRe = re.compile("^[0-9]{10,}$")
72         _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
73
74         _clicktocallURL = "http://www.google.com/voice/m/sendcall"
75         _contactsURL = "http://www.google.com/voice/m/contacts"
76         _contactDetailURL = "http://www.google.com/voice/m/contact"
77
78         _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
79         _accountNumberURL = "https://www.google.com/voice/mobile"
80         _forwardURL = "https://www.google.com/voice/m/phones"
81
82         _inboxURL = "https://www.google.com/voice/inbox/"
83         _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
84         _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
85         _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
86         _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
87
88         def __init__(self, cookieFile = None):
89                 # Important items in this function are the setup of the browser emulation and cookie file
90                 self._browser = MozillaEmulator(None, 0)
91                 if cookieFile is None:
92                         cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
93                 self._browser.cookies.filename = cookieFile
94                 if os.path.isfile(cookieFile):
95                         self._browser.cookies.load()
96
97                 self._accountNum = None
98                 self._lastAuthed = 0.0
99                 self._token = ""
100                 self._callbackNumber = ""
101                 self._callbackNumbers = {}
102
103                 self.__contacts = None
104
105         def is_authed(self, force = False):
106                 """
107                 Attempts to detect a current session
108                 @note Once logged in try not to reauth more than once a minute.
109                 @returns If authenticated
110                 """
111
112                 if (time.time() - self._lastAuthed) < 60 and not force:
113                         return True
114
115                 try:
116                         inboxPage = self._browser.download(self._inboxURL)
117                 except urllib2.URLError, e:
118                         raise RuntimeError("%s is not accesible" % self._inboxURL)
119
120                 self._browser.cookies.save()
121                 if self._isNotLoginPageRe.search(inboxPage) is not None:
122                         return False
123
124                 self._lastAuthed = time.time()
125                 return True
126
127         def login(self, username, password):
128                 """
129                 Attempt to login to grandcentral
130                 @returns Whether login was successful or not
131                 """
132                 #if self.is_authed():
133                 #       return True
134
135                 loginPostData = urllib.urlencode({
136                         'Email' : username,
137                         'Passwd' : password,
138                         'service': "grandcentral",
139                 })
140
141                 try:
142                         loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
143                 except urllib2.URLError, e:
144                         raise RuntimeError("%s is not accesible" % self._loginURL)
145
146                 #self._grab_account_info(loginSuccessOrFailurePage)
147                 self._grab_account_info()
148                 return self.is_authed()
149
150         def logout(self):
151                 self._lastAuthed = 0.0
152                 self._browser.cookies.clear()
153                 self._browser.cookies.save()
154
155                 self.clear_caches()
156
157         def dial(self, number):
158                 """
159                 This is the main function responsible for initating the callback
160                 """
161                 if not self.is_valid_syntax(number):
162                         raise ValueError('Number is not valid: "%s"' % number)
163                 elif not self.is_authed():
164                         raise RuntimeError("Not Authenticated")
165
166                 if len(number) == 11 and number[0] == 1:
167                         # Strip leading 1 from 11 digit dialing
168                         number = number[1:]
169
170                 try:
171                         clickToCallData = urllib.urlencode({
172                                 "number": number,
173                                 "phone": self._callbackNumber,
174                                 "_rnr_se": self._token,
175                                 "submit": "Call",
176                         })
177                         otherData = {
178                                 'Referer': 'https://www.google.com/voice/m/callsms',
179                         }
180                         callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, otherData)
181                 except urllib2.URLError, e:
182                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
183
184                 if self._gvDialingStrRe.search(callSuccessPage) is None:
185                         raise RuntimeError("Grand Central returned an error")
186
187                 return True
188
189         def clear_caches(self):
190                 self.__contacts = None
191
192         def is_valid_syntax(self, number):
193                 """
194                 @returns If This number be called ( syntax validation only )
195                 """
196                 return self._validateRe.match(number) is not None
197
198         def get_account_number(self):
199                 """
200                 @returns The grand central phone number
201                 """
202                 return self._accountNum
203
204         def set_sane_callback(self):
205                 """
206                 Try to set a sane default callback number on these preferences
207                 1) 1747 numbers ( Gizmo )
208                 2) anything with gizmo in the name
209                 3) anything with computer in the name
210                 4) the first value
211                 """
212                 numbers = self.get_callback_numbers()
213
214                 for number, description in numbers.iteritems():
215                         if not re.compile(r"""1747""").match(number) is None:
216                                 self.set_callback_number(number)
217                                 return
218
219                 for number, description in numbers.iteritems():
220                         if not re.compile(r"""gizmo""", re.I).search(description) is None:
221                                 self.set_callback_number(number)
222                                 return
223
224                 for number, description in numbers.iteritems():
225                         if not re.compile(r"""computer""", re.I).search(description) is None:
226                                 self.set_callback_number(number)
227                                 return
228
229                 for number, description in numbers.iteritems():
230                         self.set_callback_number(number)
231                         return
232
233         def get_callback_numbers(self):
234                 """
235                 @returns a dictionary mapping call back numbers to descriptions
236                 @note These results are cached for 30 minutes.
237                 """
238                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
239                         return self._callbackNumbers
240
241                 return {}
242
243         def set_callback_number(self, callbacknumber):
244                 """
245                 Set the number that grandcental calls
246                 @param callbacknumber should be a proper 10 digit number
247                 """
248                 self._callbackNumber = callbacknumber
249                 return True
250
251         def get_callback_number(self):
252                 """
253                 @returns Current callback number or None
254                 """
255                 return self._callbackNumber
256
257         def get_recent(self):
258                 """
259                 @returns Iterable of (personsName, phoneNumber, date, action)
260                 """
261                 for url in (
262                         self._receivedCallsURL,
263                         self._missedCallsURL,
264                         self._placedCallsURL,
265                 ):
266                         try:
267                                 allRecentData = self._grab_json(url)
268                         except urllib2.URLError, e:
269                                 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
270
271                         for recentCallData in allRecentData["messages"].itervalues():
272                                 number = recentCallData["displayNumber"]
273                                 date = recentCallData["relativeStartTime"]
274                                 action = ", ".join((
275                                         label.title()
276                                         for label in recentCallData["labels"]
277                                                 if label.lower() != "all" and label.lower() != "inbox"
278                                 ))
279                                 yield "", number, date, action
280
281         def get_addressbooks(self):
282                 """
283                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
284                 """
285                 yield self, "", ""
286
287         def open_addressbook(self, bookId):
288                 return self
289
290         @staticmethod
291         def contact_source_short_name(contactId):
292                 return "GV"
293
294         @staticmethod
295         def factory_name():
296                 return "Google Voice"
297
298         def get_contacts(self):
299                 """
300                 @returns Iterable of (contact id, contact name)
301                 """
302                 if self.__contacts is None:
303                         self.__contacts = []
304
305                         contactsPagesUrls = [self._contactsURL]
306                         for contactsPageUrl in contactsPagesUrls:
307                                 try:
308                                         contactsPage = self._browser.download(contactsPageUrl)
309                                 except urllib2.URLError, e:
310                                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
311                                 for contact_match in self._contactsRe.finditer(contactsPage):
312                                         contactId = contact_match.group(1)
313                                         contactName = contact_match.group(2)
314                                         contact = contactId, contactName
315                                         self.__contacts.append(contact)
316                                         yield contact
317
318                                 next_match = self._contactsNextRe.match(contactsPage)
319                                 if next_match is not None:
320                                         newContactsPageUrl = self._contactsURL + next_match.group(1)
321                                         contactsPagesUrls.append(newContactsPageUrl)
322                 else:
323                         for contact in self.__contacts:
324                                 yield contact
325
326         def get_contact_details(self, contactId):
327                 """
328                 @returns Iterable of (Phone Type, Phone Number)
329                 """
330                 try:
331                         detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
332                 except urllib2.URLError, e:
333                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
334
335                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
336                         phoneType = detail_match.group(1)
337                         phoneNumber = detail_match.group(2)
338                         yield (phoneType, phoneNumber)
339
340         def _grab_json(self, url):
341                 flatXml = self._browser.download(url)
342                 xmlTree = ElementTree.fromstring(flatXml)
343                 jsonElement = xmlTree.getchildren()[0]
344                 flatJson = jsonElement.text
345                 jsonTree = parse_json(flatJson)
346                 return jsonTree
347
348         def _grab_account_info(self, loginPage = None):
349                 if loginPage is None:
350                         accountNumberPage = self._browser.download(self._accountNumberURL)
351                 else:
352                         accountNumberPage = loginPage
353                 tokenGroup = self._tokenRe.search(accountNumberPage)
354                 if tokenGroup is not None:
355                         self._token = tokenGroup.group(1)
356                 anGroup = self._accountNumRe.search(accountNumberPage)
357                 if anGroup is not None:
358                         self._accountNum = anGroup.group(1)
359
360                 callbackPage = self._browser.download(self._forwardURL)
361                 self._callbackNumbers = {}
362                 for match in self._callbackRe.finditer(callbackPage):
363                         self._callbackNumbers[match.group(2)] = match.group(1)
364                 if len(self._callbackNumber) == 0:
365                         self.set_sane_callback()
366
367
368 def test_backend(username, password):
369         import pprint
370         backend = GVDialer()
371         print "Authenticated: ", backend.is_authed()
372         print "Login?: ", backend.login(username, password)
373         print "Authenticated: ", backend.is_authed()
374         print "Token: ", backend._token
375         print "Account: ", backend.get_account_number()
376         print "Callback: ", backend.get_callback_number()
377         print "All Callback: ",
378         pprint.pprint(backend.get_callback_numbers())
379         print "Recent: ",
380         pprint.pprint(list(backend.get_recent()))
381
382         return backend