Backwards logic, whoops
[gc-dialer] / src / gv_backend.py
index 227838b..0d05d41 100644 (file)
 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
 
 """
-Grandcentral backend code
+Google Voice backend code
+
+Resources
+       http://thatsmith.com/2009/03/google-voice-addon-for-firefox/
+       http://posttopic.com/topic/google-voice-add-on-development
 """
 
 
@@ -28,61 +32,88 @@ import urllib
 import urllib2
 import time
 import warnings
+import traceback
+
+from xml.etree import ElementTree
 
 from browser_emu import MozillaEmulator
 
-import socket
+try:
+       import simplejson
+except ImportError:
+       simplejson = None
+
+
+_TRUE_REGEX = re.compile("true")
+_FALSE_REGEX = re.compile("false")
+
 
+def safe_eval(s):
+       s = _TRUE_REGEX.sub("True", s)
+       s = _FALSE_REGEX.sub("False", s)
+       return eval(s, {}, {})
 
-socket.setdefaulttimeout(5)
 
+if simplejson is None:
+       def parse_json(flattened):
+               return safe_eval(flattened)
+else:
+       def parse_json(flattened):
+               return simplejson.loads(flattened)
 
-class GCDialer(object):
+
+class GVDialer(object):
        """
        This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
        the functions include login, setting up a callback number, and initalting a callback
        """
 
-       _gcDialingStrRe = re.compile("This may take a few seconds", re.M)
-       _accessTokenRe = re.compile(r"""<input type="hidden" name="a_t" [^>]*value="(.*)"/>""")
-       _isLoginPageRe = re.compile(r"""<form method="post" action="https://www.grandcentral.com/mobile/account/login">""")
-       _callbackRe = re.compile(r"""name="default_number" value="(\d+)" />\s+(.*)\s$""", re.M)
-       _accountNumRe = re.compile(r"""<img src="/images/mobile/inbox_logo.gif" alt="GrandCentral" />\s*(.{14})\s*&nbsp""", re.M)
-       _inboxRe = re.compile(r"""<td>.*?(voicemail|received|missed|call return).*?</td>\s+<td>\s+<font size="2">\s+(.*?)\s+&nbsp;\|&nbsp;\s+<a href="/mobile/contacts/.*?">(.*?)\s?</a>\s+<br/>\s+(.*?)\s?<a href=""", re.S)
-       _contactsRe = re.compile(r"""<a href="/mobile/contacts/detail/(\d+)">(.*?)</a>""", re.S)
-       _contactsNextRe = re.compile(r""".*<a href="/mobile/contacts(\?page=\d+)">Next</a>""", re.S)
-       _contactDetailGroupRe = re.compile(r"""Group:\s*(\w*)""", re.S)
-       _contactDetailPhoneRe = re.compile(r"""(\w+):[0-9\-\(\) \t]*?<a href="/mobile/calls/click_to_call\?destno=(\d+).*?">call</a>""", re.S)
-
+       _isNotLoginPageRe = re.compile(r"""I cannot access my account""")
+       _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
+       _accountNumRe = re.compile(r"""<b class="ms2">(.{14})</b></div>""")
+       _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
        _validateRe = re.compile("^[0-9]{10,}$")
+       _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
+
+       _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
+       _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
+       _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
+
+       _clicktocallURL = "https://www.google.com/voice/m/sendcall"
+       _contactsURL = "https://www.google.com/voice/mobile/contacts"
+       _contactDetailURL = "https://www.google.com/voice/mobile/contact"
+
+       _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
+       _setforwardURL = "https://www.google.com//voice/m/setphone"
+       _accountNumberURL = "https://www.google.com/voice/mobile"
+       _forwardURL = "https://www.google.com/voice/mobile/phones"
 
-       _forwardselectURL = "http://www.grandcentral.com/mobile/settings/forwarding_select"
-       _loginURL = "https://www.grandcentral.com/mobile/account/login"
-       _setforwardURL = "http://www.grandcentral.com/mobile/settings/set_forwarding?from=settings"
-       _clicktocallURL = "http://www.grandcentral.com/mobile/calls/click_to_call?a_t=%s&destno=%s"
-       _inboxallURL = "http://www.grandcentral.com/mobile/messages/inbox?types=all"
-       _contactsURL = "http://www.grandcentral.com/mobile/contacts"
-       _contactDetailURL = "http://www.grandcentral.com/mobile/contacts/detail"
+       _inboxURL = "https://www.google.com/voice/inbox/"
+       _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
+       _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
+       _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
+       _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
 
        def __init__(self, cookieFile = None):
                # Important items in this function are the setup of the browser emulation and cookie file
                self._browser = MozillaEmulator(None, 0)
                if cookieFile is None:
-                       cookieFile = os.path.join(os.path.expanduser("~"), ".gc_cookies.txt")
+                       cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
                self._browser.cookies.filename = cookieFile
                if os.path.isfile(cookieFile):
                        self._browser.cookies.load()
 
-               self._accessToken = None
                self._accountNum = None
-               self._callbackNumbers = {}
                self._lastAuthed = 0.0
+               self._token = ""
+               self._callbackNumber = ""
+               self._callbackNumbers = {}
 
                self.__contacts = None
 
        def is_authed(self, force = False):
                """
-               Attempts to detect a current session and pull the auth token ( a_t ) from the page.
+               Attempts to detect a current session
                @note Once logged in try not to reauth more than once a minute.
                @returns If authenticated
                """
@@ -91,17 +122,18 @@ class GCDialer(object):
                        return True
 
                try:
-                       forwardSelectionPage = self._browser.download(GCDialer._forwardselectURL)
+                       inboxPage = self._browser.download(self._inboxURL)
                except urllib2.URLError, e:
-                       raise RuntimeError("%s is not accesible" % GCDialer._clicktocallURL)
+                       warnings.warn(traceback.format_exc())
+                       raise RuntimeError("%s is not accesible" % self._inboxURL)
 
                self._browser.cookies.save()
-               if GCDialer._isLoginPageRe.search(forwardSelectionPage) is None:
-                       self._grab_token(forwardSelectionPage)
-                       self._lastAuthed = time.time()
-                       return True
+               if self._isNotLoginPageRe.search(inboxPage) is not None:
+                       return False
 
-               return False
+               self._grab_account_info()
+               self._lastAuthed = time.time()
+               return True
 
        def login(self, username, password):
                """
@@ -111,12 +143,17 @@ class GCDialer(object):
                if self.is_authed():
                        return True
 
-               loginPostData = urllib.urlencode( {'username' : username , 'password' : password } )
+               loginPostData = urllib.urlencode({
+                       'Email' : username,
+                       'Passwd' : password,
+                       'service': "grandcentral",
+               })
 
                try:
-                       loginSuccessOrFailurePage = self._browser.download(GCDialer._loginURL, loginPostData)
+                       loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
                except urllib2.URLError, e:
-                       raise RuntimeError("%s is not accesible" % GCDialer._clicktocallURL)
+                       warnings.warn(traceback.format_exc())
+                       raise RuntimeError("%s is not accesible" % self._loginURL)
 
                return self.is_authed()
 
@@ -131,29 +168,31 @@ class GCDialer(object):
                """
                This is the main function responsible for initating the callback
                """
-               # If the number is not valid throw exception
                if not self.is_valid_syntax(number):
-                       raise ValueError('number is not valid')
-
-               # No point if we don't have the magic cookie
-               if not self.is_authed():
+                       raise ValueError('Number is not valid: "%s"' % number)
+               elif not self.is_authed():
                        raise RuntimeError("Not Authenticated")
 
-               # Strip leading 1 from 11 digit dialing
                if len(number) == 11 and number[0] == 1:
+                       # Strip leading 1 from 11 digit dialing
                        number = number[1:]
 
                try:
-                       callSuccessPage = self._browser.download(
-                               GCDialer._clicktocallURL % (self._accessToken, number),
-                               None,
-                               {'Referer' : 'http://www.grandcentral.com/mobile/messages'}
-                       )
+                       clickToCallData = urllib.urlencode({
+                               "number": number,
+                               "phone": self._callbackNumber,
+                               "_rnr_se": self._token,
+                       })
+                       otherData = {
+                               'Referer' : 'https://google.com/voice/m/callsms',
+                       }
+                       callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
                except urllib2.URLError, e:
-                       raise RuntimeError("%s is not accesible" % GCDialer._clicktocallURL)
+                       warnings.warn(traceback.format_exc())
+                       raise RuntimeError("%s is not accesible" % self._clicktocallURL)
 
-               if GCDialer._gcDialingStrRe.search(callSuccessPage) is None:
-                       raise RuntimeError("Grand Central returned an error")
+               if self._gvDialingStrRe.search(callSuccessPage) is None:
+                       raise RuntimeError("Google Voice returned an error")
 
                return True
 
@@ -183,17 +222,17 @@ class GCDialer(object):
                numbers = self.get_callback_numbers()
 
                for number, description in numbers.iteritems():
-                       if not re.compile(r"""1747""").match(number) is None:
+                       if re.compile(r"""1747""").match(number) is not None:
                                self.set_callback_number(number)
                                return
 
                for number, description in numbers.iteritems():
-                       if not re.compile(r"""gizmo""", re.I).search(description) is None:
+                       if re.compile(r"""gizmo""", re.I).search(description) is not None:
                                self.set_callback_number(number)
                                return
 
                for number, description in numbers.iteritems():
-                       if not re.compile(r"""computer""", re.I).search(description) is None:
+                       if re.compile(r"""computer""", re.I).search(description) is not None:
                                self.set_callback_number(number)
                                return
 
@@ -216,14 +255,16 @@ class GCDialer(object):
                Set the number that grandcental calls
                @param callbacknumber should be a proper 10 digit number
                """
+               self._callbackNumber = callbacknumber
                callbackPostData = urllib.urlencode({
-                       'a_t': self._accessToken,
-                       'default_number': callbacknumber
+                       '_rnr_se': self._token,
+                       'phone': callbacknumber
                })
                try:
-                       callbackSetPage = self._browser.download(GCDialer._setforwardURL, callbackPostData)
+                       callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
                except urllib2.URLError, e:
-                       raise RuntimeError("%s is not accesible" % GCDialer._clicktocallURL)
+                       warnings.warn(traceback.format_exc())
+                       raise RuntimeError("%s is not accesible" % self._setforwardURL)
 
                self._browser.cookies.save()
                return True
@@ -232,26 +273,32 @@ class GCDialer(object):
                """
                @returns Current callback number or None
                """
-               for c in self._browser.cookies:
-                       if c.name == "pda_forwarding_number":
-                               return c.value
-               return None
+               return self._callbackNumber
 
        def get_recent(self):
                """
                @returns Iterable of (personsName, phoneNumber, date, action)
                """
-               try:
-                       recentCallsPage = self._browser.download(GCDialer._inboxallURL)
-               except urllib2.URLError, e:
-                       raise RuntimeError("%s is not accesible" % GCDialer._clicktocallURL)
-
-               for match in self._inboxRe.finditer(recentCallsPage):
-                       phoneNumber = match.group(4)
-                       action = match.group(1)
-                       date = match.group(2)
-                       personsName = match.group(3)
-                       yield personsName, phoneNumber, date, action
+               for url in (
+                       self._receivedCallsURL,
+                       self._missedCallsURL,
+                       self._placedCallsURL,
+               ):
+                       try:
+                               allRecentData = self._grab_json(url)
+                       except urllib2.URLError, e:
+                               warnings.warn(traceback.format_exc())
+                               raise RuntimeError("%s is not accesible" % self._clicktocallURL)
+
+                       for recentCallData in allRecentData["messages"].itervalues():
+                               number = recentCallData["displayNumber"]
+                               date = recentCallData["relativeStartTime"]
+                               action = ", ".join((
+                                       label.title()
+                                       for label in recentCallData["labels"]
+                                               if label.lower() != "all" and label.lower() != "inbox"
+                               ))
+                               yield "", number, date, action
 
        def get_addressbooks(self):
                """
@@ -264,11 +311,11 @@ class GCDialer(object):
 
        @staticmethod
        def contact_source_short_name(contactId):
-               return "GC"
+               return "GV"
 
        @staticmethod
        def factory_name():
-               return "Grand Central"
+               return "Google Voice"
 
        def get_contacts(self):
                """
@@ -277,12 +324,13 @@ class GCDialer(object):
                if self.__contacts is None:
                        self.__contacts = []
 
-                       contactsPagesUrls = [GCDialer._contactsURL]
+                       contactsPagesUrls = [self._contactsURL]
                        for contactsPageUrl in contactsPagesUrls:
                                try:
                                        contactsPage = self._browser.download(contactsPageUrl)
                                except urllib2.URLError, e:
-                                       raise RuntimeError("%s is not accesible" % GCDialer._clicktocallURL)
+                                       warnings.warn(traceback.format_exc())
+                                       raise RuntimeError("%s is not accesible" % self._clicktocallURL)
                                for contact_match in self._contactsRe.finditer(contactsPage):
                                        contactId = contact_match.group(1)
                                        contactName = contact_match.group(2)
@@ -303,23 +351,63 @@ class GCDialer(object):
                @returns Iterable of (Phone Type, Phone Number)
                """
                try:
-                       detailPage = self._browser.download(GCDialer._contactDetailURL + '/' + contactId)
+                       detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
                except urllib2.URLError, e:
-                       raise RuntimeError("%s is not accesible" % GCDialer._clicktocallURL)
+                       warnings.warn(traceback.format_exc())
+                       raise RuntimeError("%s is not accesible" % self._clicktocallURL)
 
                for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
-                       phoneType = detail_match.group(1)
-                       phoneNumber = detail_match.group(2)
+                       phoneNumber = detail_match.group(1)
+                       phoneType = detail_match.group(2)
                        yield (phoneType, phoneNumber)
 
-       def _grab_token(self, data):
-               "Pull the magic cookie from the datastream"
-               atGroup = GCDialer._accessTokenRe.search(data)
-               self._accessToken = atGroup.group(1)
-
-               anGroup = GCDialer._accountNumRe.search(data)
+       def _grab_json(self, url):
+               flatXml = self._browser.download(url)
+               xmlTree = ElementTree.fromstring(flatXml)
+               jsonElement = xmlTree.getchildren()[0]
+               flatJson = jsonElement.text
+               jsonTree = parse_json(flatJson)
+               return jsonTree
+
+       def _grab_account_info(self, accountNumberPage = None):
+               if accountNumberPage is None:
+                       accountNumberPage = self._browser.download(self._accountNumberURL)
+
+               tokenGroup = self._tokenRe.search(accountNumberPage)
+               if tokenGroup is None:
+                       raise RuntimeError("Could not extract authentication token from GrandCentral")
+               self._token = tokenGroup.group(1)
+
+               anGroup = self._accountNumRe.search(accountNumberPage)
+               if atGroup is None:
+                       raise RuntimeError("Could not extract account number from GrandCentral")
                self._accountNum = anGroup.group(1)
 
+               callbackPage = self._browser.download(self._forwardURL)
                self._callbackNumbers = {}
-               for match in GCDialer._callbackRe.finditer(data):
-                       self._callbackNumbers[match.group(1)] = match.group(2)
+               for match in self._callbackRe.finditer(callbackPage):
+                       self._callbackNumbers[match.group(2)] = match.group(1)
+
+               if len(self._callbackNumber) == 0:
+                       self.set_sane_callback()
+
+
+def test_backend(username, password):
+       import pprint
+       backend = GVDialer()
+       print "Authenticated: ", backend.is_authed()
+       print "Login?: ", backend.login(username, password)
+       print "Authenticated: ", backend.is_authed()
+       print "Token: ", backend._token
+       print "Account: ", backend.get_account_number()
+       print "Callback: ", backend.get_callback_number()
+       # print "All Callback: ",
+       # pprint.pprint(backend.get_callback_numbers())
+       # print "Recent: ",
+       # pprint.pprint(list(backend.get_recent()))
+       # print "Contacts: ",
+       # for contact in backend.get_contacts():
+       #       print contact
+       #       pprint.pprint(list(backend.get_contact_details(contact[0])))
+
+       return backend