4d974c7e94deb8236453520af7c38d9ee52188ed
[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         _isNotLoginPageRe = re.compile(r"""I cannot access my account""")
63         _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
64         _accountNumRe = re.compile(r"""<b class="ms2">(.{14})</b></div>""")
65         _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
66         _validateRe = re.compile("^[0-9]{10,}$")
67         _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
68
69         _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
70         _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
71         _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
72
73         _clicktocallURL = "https://www.google.com/voice/m/sendcall"
74         _contactsURL = "https://www.google.com/voice/mobile/contacts"
75         _contactDetailURL = "https://www.google.com/voice/mobile/contact"
76
77         _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
78         _setforwardURL = "https://www.google.com//voice/m/setphone"
79         _accountNumberURL = "https://www.google.com/voice/mobile"
80         _forwardURL = "https://www.google.com/voice/mobile/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                         })
176                         otherData = {
177                                 'Referer' : 'https://google.com/voice/m/callsms',
178                         }
179                         callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
180                 except urllib2.URLError, e:
181                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
182
183                 if self._gvDialingStrRe.search(callSuccessPage) is None:
184                         raise RuntimeError("Google Voice returned an error")
185
186                 return True
187
188         def clear_caches(self):
189                 self.__contacts = None
190
191         def is_valid_syntax(self, number):
192                 """
193                 @returns If This number be called ( syntax validation only )
194                 """
195                 return self._validateRe.match(number) is not None
196
197         def get_account_number(self):
198                 """
199                 @returns The grand central phone number
200                 """
201                 return self._accountNum
202
203         def set_sane_callback(self):
204                 """
205                 Try to set a sane default callback number on these preferences
206                 1) 1747 numbers ( Gizmo )
207                 2) anything with gizmo in the name
208                 3) anything with computer in the name
209                 4) the first value
210                 """
211                 numbers = self.get_callback_numbers()
212
213                 for number, description in numbers.iteritems():
214                         if re.compile(r"""1747""").match(number) is not None:
215                                 self.set_callback_number(number)
216                                 return
217
218                 for number, description in numbers.iteritems():
219                         if re.compile(r"""gizmo""", re.I).search(description) is not None:
220                                 self.set_callback_number(number)
221                                 return
222
223                 for number, description in numbers.iteritems():
224                         if re.compile(r"""computer""", re.I).search(description) is not None:
225                                 self.set_callback_number(number)
226                                 return
227
228                 for number, description in numbers.iteritems():
229                         self.set_callback_number(number)
230                         return
231
232         def get_callback_numbers(self):
233                 """
234                 @returns a dictionary mapping call back numbers to descriptions
235                 @note These results are cached for 30 minutes.
236                 """
237                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
238                         return self._callbackNumbers
239
240                 return {}
241
242         def set_callback_number(self, callbacknumber):
243                 """
244                 Set the number that grandcental calls
245                 @param callbacknumber should be a proper 10 digit number
246                 """
247                 self._callbackNumber = callbacknumber
248                 callbackPostData = urllib.urlencode({
249                         '_rnr_se': self._token,
250                         'phone': callbacknumber
251                 })
252                 try:
253                         callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
254                 except urllib2.URLError, e:
255                         raise RuntimeError("%s is not accesible" % self._setforwardURL)
256
257                 self._browser.cookies.save()
258                 return True
259
260         def get_callback_number(self):
261                 """
262                 @returns Current callback number or None
263                 """
264                 return self._callbackNumber
265
266         def get_recent(self):
267                 """
268                 @returns Iterable of (personsName, phoneNumber, date, action)
269                 """
270                 for url in (
271                         self._receivedCallsURL,
272                         self._missedCallsURL,
273                         self._placedCallsURL,
274                 ):
275                         try:
276                                 allRecentData = self._grab_json(url)
277                         except urllib2.URLError, e:
278                                 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
279
280                         for recentCallData in allRecentData["messages"].itervalues():
281                                 number = recentCallData["displayNumber"]
282                                 date = recentCallData["relativeStartTime"]
283                                 action = ", ".join((
284                                         label.title()
285                                         for label in recentCallData["labels"]
286                                                 if label.lower() != "all" and label.lower() != "inbox"
287                                 ))
288                                 yield "", number, date, action
289
290         def get_addressbooks(self):
291                 """
292                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
293                 """
294                 yield self, "", ""
295
296         def open_addressbook(self, bookId):
297                 return self
298
299         @staticmethod
300         def contact_source_short_name(contactId):
301                 return "GV"
302
303         @staticmethod
304         def factory_name():
305                 return "Google Voice"
306
307         def get_contacts(self):
308                 """
309                 @returns Iterable of (contact id, contact name)
310                 """
311                 if self.__contacts is None:
312                         self.__contacts = []
313
314                         contactsPagesUrls = [self._contactsURL]
315                         for contactsPageUrl in contactsPagesUrls:
316                                 try:
317                                         contactsPage = self._browser.download(contactsPageUrl)
318                                 except urllib2.URLError, e:
319                                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
320                                 for contact_match in self._contactsRe.finditer(contactsPage):
321                                         contactId = contact_match.group(1)
322                                         contactName = contact_match.group(2)
323                                         contact = contactId, contactName
324                                         self.__contacts.append(contact)
325                                         yield contact
326
327                                 next_match = self._contactsNextRe.match(contactsPage)
328                                 if next_match is not None:
329                                         newContactsPageUrl = self._contactsURL + next_match.group(1)
330                                         contactsPagesUrls.append(newContactsPageUrl)
331                 else:
332                         for contact in self.__contacts:
333                                 yield contact
334
335         def get_contact_details(self, contactId):
336                 """
337                 @returns Iterable of (Phone Type, Phone Number)
338                 """
339                 try:
340                         detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
341                 except urllib2.URLError, e:
342                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
343
344                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
345                         phoneNumber = detail_match.group(1)
346                         phoneType = detail_match.group(2)
347                         yield (phoneType, phoneNumber)
348
349         def _grab_json(self, url):
350                 flatXml = self._browser.download(url)
351                 xmlTree = ElementTree.fromstring(flatXml)
352                 jsonElement = xmlTree.getchildren()[0]
353                 flatJson = jsonElement.text
354                 jsonTree = parse_json(flatJson)
355                 return jsonTree
356
357         def _grab_account_info(self, loginPage = None):
358                 if loginPage is None:
359                         accountNumberPage = self._browser.download(self._accountNumberURL)
360                 else:
361                         accountNumberPage = loginPage
362                 tokenGroup = self._tokenRe.search(accountNumberPage)
363                 if tokenGroup is not None:
364                         self._token = tokenGroup.group(1)
365                 anGroup = self._accountNumRe.search(accountNumberPage)
366                 if anGroup is not None:
367                         self._accountNum = anGroup.group(1)
368
369                 callbackPage = self._browser.download(self._forwardURL)
370                 self._callbackNumbers = {}
371                 for match in self._callbackRe.finditer(callbackPage):
372                         self._callbackNumbers[match.group(2)] = match.group(1)
373                 if len(self._callbackNumber) == 0:
374                         self.set_sane_callback()
375
376
377 def test_backend(username, password):
378         import pprint
379         backend = GVDialer()
380         print "Authenticated: ", backend.is_authed()
381         print "Login?: ", backend.login(username, password)
382         print "Authenticated: ", backend.is_authed()
383         print "Token: ", backend._token
384         print "Account: ", backend.get_account_number()
385         print "Callback: ", backend.get_callback_number()
386         # print "All Callback: ",
387         # pprint.pprint(backend.get_callback_numbers())
388         # print "Recent: ",
389         # pprint.pprint(list(backend.get_recent()))
390         # print "Contacts: ",
391         # for contact in backend.get_contacts():
392         #       print contact
393         #       pprint.pprint(list(backend.get_contact_details(contact[0])))
394
395         return backend