Added sorting to the recent view
[gc-dialer] / src / gv_backend.py
1 #!/usr/bin/python
2
3 """
4 DialCentral - Front end for Google's Grand Central service.
5 Copyright (C) 2008  Eric Warnke ericew AT gmail DOT com
6
7 This library is free software; you can redistribute it and/or
8 modify it under the terms of the GNU Lesser General Public
9 License as published by the Free Software Foundation; either
10 version 2.1 of the License, or (at your option) any later version.
11
12 This library is distributed in the hope that it will be useful,
13 but WITHOUT ANY WARRANTY; without even the implied warranty of
14 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
15 Lesser General Public License for more details.
16
17 You should have received a copy of the GNU Lesser General Public
18 License along with this library; if not, write to the Free Software
19 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
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 datetime
35 import warnings
36 import traceback
37 from xml.sax import saxutils
38
39 from xml.etree import ElementTree
40
41 import browser_emu
42
43 try:
44         import simplejson
45 except ImportError:
46         simplejson = None
47
48
49 _TRUE_REGEX = re.compile("true")
50 _FALSE_REGEX = re.compile("false")
51
52
53 def safe_eval(s):
54         s = _TRUE_REGEX.sub("True", s)
55         s = _FALSE_REGEX.sub("False", s)
56         return eval(s, {}, {})
57
58
59 if simplejson is None:
60         def parse_json(flattened):
61                 return safe_eval(flattened)
62 else:
63         def parse_json(flattened):
64                 return simplejson.loads(flattened)
65
66
67 class GVDialer(object):
68         """
69         This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
70         the functions include login, setting up a callback number, and initalting a callback
71         """
72
73         _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
74         _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
75         _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
76         _validateRe = re.compile("^[0-9]{10,}$")
77         _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
78
79         _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
80         _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
81         _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
82
83         _clicktocallURL = "https://www.google.com/voice/m/sendcall"
84         _contactsURL = "https://www.google.com/voice/mobile/contacts"
85         _contactDetailURL = "https://www.google.com/voice/mobile/contact"
86
87         _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
88         _setforwardURL = "https://www.google.com//voice/m/setphone"
89         _accountNumberURL = "https://www.google.com/voice/mobile"
90         _forwardURL = "https://www.google.com/voice/mobile/phones"
91
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         _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
97         _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
98
99         def __init__(self, cookieFile = None):
100                 # Important items in this function are the setup of the browser emulation and cookie file
101                 self._browser = browser_emu.MozillaEmulator(1)
102                 if cookieFile is None:
103                         cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
104                 self._browser.cookies.filename = cookieFile
105                 if os.path.isfile(cookieFile):
106                         self._browser.cookies.load()
107
108                 self._token = ""
109                 self._accountNum = None
110                 self._lastAuthed = 0.0
111                 self._callbackNumber = ""
112                 self._callbackNumbers = {}
113
114                 self.__contacts = None
115
116         def is_authed(self, force = False):
117                 """
118                 Attempts to detect a current session
119                 @note Once logged in try not to reauth more than once a minute.
120                 @returns If authenticated
121                 """
122
123                 if (time.time() - self._lastAuthed) < 60 and not force:
124                         return True
125
126                 try:
127                         self._grab_account_info()
128                 except StandardError, e:
129                         warnings.warn(traceback.format_exc())
130                         return False
131
132                 self._browser.cookies.save()
133                 self._lastAuthed = time.time()
134                 return True
135
136         def login(self, username, password):
137                 """
138                 Attempt to login to grandcentral
139                 @returns Whether login was successful or not
140                 """
141                 if self.is_authed():
142                         return True
143
144                 loginPostData = urllib.urlencode({
145                         'Email' : username,
146                         'Passwd' : password,
147                         'service': "grandcentral",
148                         "ltmpl": "mobile",
149                         "btmpl": "mobile",
150                         "PersistentCookie": "yes",
151                 })
152
153                 try:
154                         loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
155                 except urllib2.URLError, e:
156                         warnings.warn(traceback.format_exc())
157                         raise RuntimeError("%s is not accesible" % self._loginURL)
158
159                 return self.is_authed()
160
161         def logout(self):
162                 self._lastAuthed = 0.0
163                 self._browser.cookies.clear()
164                 self._browser.cookies.save()
165
166                 self.clear_caches()
167
168         def dial(self, number):
169                 """
170                 This is the main function responsible for initating the callback
171                 """
172                 if not self.is_valid_syntax(number):
173                         raise ValueError('Number is not valid: "%s"' % number)
174                 elif not self.is_authed():
175                         raise RuntimeError("Not Authenticated")
176
177                 if len(number) == 11 and number[0] == 1:
178                         # Strip leading 1 from 11 digit dialing
179                         number = number[1:]
180
181                 try:
182                         clickToCallData = urllib.urlencode({
183                                 "number": number,
184                                 "phone": self._callbackNumber,
185                                 "_rnr_se": self._token,
186                         })
187                         otherData = {
188                                 'Referer' : 'https://google.com/voice/m/callsms',
189                         }
190                         callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
191                 except urllib2.URLError, e:
192                         warnings.warn(traceback.format_exc())
193                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
194
195                 if self._gvDialingStrRe.search(callSuccessPage) is None:
196                         raise RuntimeError("Google Voice returned an error")
197
198                 return True
199
200         def clear_caches(self):
201                 self.__contacts = None
202
203         def is_valid_syntax(self, number):
204                 """
205                 @returns If This number be called ( syntax validation only )
206                 """
207                 return self._validateRe.match(number) is not None
208
209         def get_account_number(self):
210                 """
211                 @returns The grand central phone number
212                 """
213                 return self._accountNum
214
215         def set_sane_callback(self):
216                 """
217                 Try to set a sane default callback number on these preferences
218                 1) 1747 numbers ( Gizmo )
219                 2) anything with gizmo in the name
220                 3) anything with computer in the name
221                 4) the first value
222                 """
223                 numbers = self.get_callback_numbers()
224
225                 for number, description in numbers.iteritems():
226                         if re.compile(r"""1747""").match(number) is not None:
227                                 self.set_callback_number(number)
228                                 return
229
230                 for number, description in numbers.iteritems():
231                         if re.compile(r"""gizmo""", re.I).search(description) is not None:
232                                 self.set_callback_number(number)
233                                 return
234
235                 for number, description in numbers.iteritems():
236                         if re.compile(r"""computer""", re.I).search(description) is not None:
237                                 self.set_callback_number(number)
238                                 return
239
240                 for number, description in numbers.iteritems():
241                         self.set_callback_number(number)
242                         return
243
244         def get_callback_numbers(self):
245                 """
246                 @returns a dictionary mapping call back numbers to descriptions
247                 @note These results are cached for 30 minutes.
248                 """
249                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
250                         return self._callbackNumbers
251
252                 return {}
253
254         def set_callback_number(self, callbacknumber):
255                 """
256                 Set the number that grandcental calls
257                 @param callbacknumber should be a proper 10 digit number
258                 """
259                 self._callbackNumber = callbacknumber
260                 callbackPostData = urllib.urlencode({
261                         '_rnr_se': self._token,
262                         'phone': callbacknumber
263                 })
264                 try:
265                         callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
266                 except urllib2.URLError, e:
267                         warnings.warn(traceback.format_exc())
268                         raise RuntimeError("%s is not accesible" % self._setforwardURL)
269
270                 # @bug This does not seem to be keeping on my tablet (but works on the
271                 # desktop), or the reading isn't working too well
272                 self._browser.cookies.save()
273                 return True
274
275         def get_callback_number(self):
276                 """
277                 @returns Current callback number or None
278                 """
279                 for c in self._browser.cookies:
280                         if c.name == "gv-ph":
281                                 return c.value
282                 return self._callbackNumber
283
284         def get_recent(self):
285                 """
286                 @todo Sort this stuff
287                 @returns Iterable of (personsName, phoneNumber, date, action)
288                 """
289                 sortedRecent = [
290                         (exactDate, name, number, relativeDate, action)
291                         for (name, number, exactDate, relativeDate, action) in self._get_recent()
292                 ]
293                 sortedRecent.sort(reverse = True)
294                 for exactDate, name, number, relativeDate, action in sortedRecent:
295                         yield name, number, relativeDate, action
296
297         def get_addressbooks(self):
298                 """
299                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
300                 """
301                 yield self, "", ""
302
303         def open_addressbook(self, bookId):
304                 return self
305
306         @staticmethod
307         def contact_source_short_name(contactId):
308                 return "GV"
309
310         @staticmethod
311         def factory_name():
312                 return "Google Voice"
313
314         def get_contacts(self):
315                 """
316                 @returns Iterable of (contact id, contact name)
317                 """
318                 if self.__contacts is None:
319                         self.__contacts = []
320
321                         contactsPagesUrls = [self._contactsURL]
322                         for contactsPageUrl in contactsPagesUrls:
323                                 try:
324                                         contactsPage = self._browser.download(contactsPageUrl)
325                                 except urllib2.URLError, e:
326                                         warnings.warn(traceback.format_exc())
327                                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
328                                 for contact_match in self._contactsRe.finditer(contactsPage):
329                                         contactId = contact_match.group(1)
330                                         contactName = saxutils.unescape(contact_match.group(2))
331                                         contact = contactId, contactName
332                                         self.__contacts.append(contact)
333                                         yield contact
334
335                                 next_match = self._contactsNextRe.match(contactsPage)
336                                 if next_match is not None:
337                                         newContactsPageUrl = self._contactsURL + next_match.group(1)
338                                         contactsPagesUrls.append(newContactsPageUrl)
339                 else:
340                         for contact in self.__contacts:
341                                 yield contact
342
343         def get_contact_details(self, contactId):
344                 """
345                 @returns Iterable of (Phone Type, Phone Number)
346                 """
347                 try:
348                         detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
349                 except urllib2.URLError, e:
350                         warnings.warn(traceback.format_exc())
351                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
352
353                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
354                         phoneNumber = detail_match.group(1)
355                         phoneType = saxutils.unescape(detail_match.group(2))
356                         yield (phoneType, phoneNumber)
357
358         def get_messages(self):
359                 try:
360                         voicemailPage = self._browser.download(self._voicemailURL)
361                 except urllib2.URLError, e:
362                         warnings.warn(traceback.format_exc())
363                         raise RuntimeError("%s is not accesible" % self._voicemailURL)
364
365                 try:
366                         smsPage = self._browser.download(self._smsURL)
367                 except urllib2.URLError, e:
368                         warnings.warn(traceback.format_exc())
369                         raise RuntimeError("%s is not accesible" % self._smsURL)
370
371                 voicemailHtml = self._grab_html(voicemailPage)
372                 smsHtml = self._grab_html(smsPage)
373
374                 print "="*60
375                 print voicemailHtml
376                 print "-"*60
377                 print smsHtml
378                 print "="*60
379
380                 return ()
381
382         def _grab_json(self, flatXml):
383                 xmlTree = ElementTree.fromstring(flatXml)
384                 jsonElement = xmlTree.getchildren()[0]
385                 flatJson = jsonElement.text
386                 jsonTree = parse_json(flatJson)
387                 return jsonTree
388
389         def _grab_html(self, flatXml):
390                 xmlTree = ElementTree.fromstring(flatXml)
391                 htmlElement = xmlTree.getchildren()[1]
392                 flatHtml = htmlElement.text
393                 return flatHtml
394
395         def _grab_account_info(self):
396                 page = self._browser.download(self._forwardURL)
397
398                 tokenGroup = self._tokenRe.search(page)
399                 if tokenGroup is None:
400                         raise RuntimeError("Could not extract authentication token from GoogleVoice")
401                 self._token = tokenGroup.group(1)
402
403                 anGroup = self._accountNumRe.search(page)
404                 if anGroup is None:
405                         raise RuntimeError("Could not extract account number from GoogleVoice")
406                 self._accountNum = anGroup.group(1)
407
408                 self._callbackNumbers = {}
409                 for match in self._callbackRe.finditer(page):
410                         callbackNumber = match.group(2)
411                         callbackName = match.group(1)
412                         self._callbackNumbers[callbackNumber] = callbackName
413
414         def _get_recent(self):
415                 """
416                 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
417                 """
418                 for url in (
419                         self._receivedCallsURL,
420                         self._missedCallsURL,
421                         self._placedCallsURL,
422                 ):
423                         try:
424                                 flatXml = self._browser.download(url)
425                         except urllib2.URLError, e:
426                                 warnings.warn(traceback.format_exc())
427                                 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
428
429                         allRecentData = self._grab_json(flatXml)
430                         for recentCallData in allRecentData["messages"].itervalues():
431                                 number = recentCallData["displayNumber"]
432                                 exactDate = recentCallData["displayStartDateTime"]
433                                 relativeDate = recentCallData["relativeStartTime"]
434                                 action = ", ".join((
435                                         label.title()
436                                         for label in recentCallData["labels"]
437                                                 if label.lower() != "all" and label.lower() != "inbox"
438                                 ))
439                                 number = saxutils.unescape(number)
440                                 exactDate = saxutils.unescape(exactDate)
441                                 exactDate = datetime.datetime.strptime(exactDate, "%m/%d/%y %I:%M %p")
442                                 relativeDate = saxutils.unescape(relativeDate)
443                                 action = saxutils.unescape(action)
444                                 yield "", number, exactDate, relativeDate, action
445
446
447 def test_backend(username, password):
448         import pprint
449         backend = GVDialer()
450         print "Authenticated: ", backend.is_authed()
451         print "Login?: ", backend.login(username, password)
452         print "Authenticated: ", backend.is_authed()
453         print "Token: ", backend._token
454         print "Account: ", backend.get_account_number()
455         print "Callback: ", backend.get_callback_number()
456         print "All Callback: ",
457         pprint.pprint(backend.get_callback_numbers())
458         # print "Recent: ",
459         # pprint.pprint(list(backend.get_recent()))
460         # print "Contacts: ",
461         # for contact in backend.get_contacts():
462         #       print contact
463         #       pprint.pprint(list(backend.get_contact_details(contact[0])))
464
465         return backend