Fixing a bug and rounding out the interfaces
[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         _smsURL = "https://www.google.com/voice/m/sendsms"
85         _contactsURL = "https://www.google.com/voice/mobile/contacts"
86         _contactDetailURL = "https://www.google.com/voice/mobile/contact"
87
88         _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
89         _setforwardURL = "https://www.google.com//voice/m/setphone"
90         _accountNumberURL = "https://www.google.com/voice/mobile"
91         _forwardURL = "https://www.google.com/voice/mobile/phones"
92
93         _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
94         _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
95         _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
96         _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
97         _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
98         _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
99
100         def __init__(self, cookieFile = None):
101                 # Important items in this function are the setup of the browser emulation and cookie file
102                 self._browser = browser_emu.MozillaEmulator(1)
103                 if cookieFile is None:
104                         cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
105                 self._browser.cookies.filename = cookieFile
106                 if os.path.isfile(cookieFile):
107                         self._browser.cookies.load()
108
109                 self._token = ""
110                 self._accountNum = None
111                 self._lastAuthed = 0.0
112                 self._callbackNumber = ""
113                 self._callbackNumbers = {}
114
115                 self.__contacts = None
116
117         def is_authed(self, force = False):
118                 """
119                 Attempts to detect a current session
120                 @note Once logged in try not to reauth more than once a minute.
121                 @returns If authenticated
122                 """
123
124                 if (time.time() - self._lastAuthed) < 60 and not force:
125                         return True
126
127                 try:
128                         self._grab_account_info()
129                 except StandardError, e:
130                         warnings.warn(traceback.format_exc())
131                         return False
132
133                 self._browser.cookies.save()
134                 self._lastAuthed = time.time()
135                 return True
136
137         def login(self, username, password):
138                 """
139                 Attempt to login to grandcentral
140                 @returns Whether login was successful or not
141                 """
142                 if self.is_authed():
143                         return True
144
145                 loginPostData = urllib.urlencode({
146                         'Email' : username,
147                         'Passwd' : password,
148                         'service': "grandcentral",
149                         "ltmpl": "mobile",
150                         "btmpl": "mobile",
151                         "PersistentCookie": "yes",
152                 })
153
154                 try:
155                         loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
156                 except urllib2.URLError, e:
157                         warnings.warn(traceback.format_exc())
158                         raise RuntimeError("%s is not accesible" % self._loginURL)
159
160                 return self.is_authed()
161
162         def logout(self):
163                 self._lastAuthed = 0.0
164                 self._browser.cookies.clear()
165                 self._browser.cookies.save()
166
167                 self.clear_caches()
168
169         def dial(self, number):
170                 """
171                 This is the main function responsible for initating the callback
172                 """
173                 number = self._send_validation(number)
174                 try:
175                         clickToCallData = urllib.urlencode({
176                                 "number": number,
177                                 "phone": self._callbackNumber,
178                                 "_rnr_se": self._token,
179                         })
180                         otherData = {
181                                 'Referer' : 'https://google.com/voice/m/callsms',
182                         }
183                         callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
184                 except urllib2.URLError, e:
185                         warnings.warn(traceback.format_exc())
186                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
187
188                 if self._gvDialingStrRe.search(callSuccessPage) is None:
189                         raise RuntimeError("Google Voice returned an error")
190
191                 return True
192
193         def send_sms(self, number, message):
194                 number = self._send_validation(number)
195                 try:
196                         smsData = urllib.urlencode({
197                                 "number": number,
198                                 "smstext": message,
199                                 "_rnr_se": self._token,
200                                 "id": "undefined",
201                                 "c": "undefined",
202                         })
203                         otherData = {
204                                 'Referer' : 'https://google.com/voice/m/sms',
205                         }
206                         smsSuccessPage = self._browser.download(self._smsURL, smsData, None, otherData)
207                 except urllib2.URLError, e:
208                         warnings.warn(traceback.format_exc())
209                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
210
211                 return True
212
213         def clear_caches(self):
214                 self.__contacts = None
215
216         def is_valid_syntax(self, number):
217                 """
218                 @returns If This number be called ( syntax validation only )
219                 """
220                 return self._validateRe.match(number) is not None
221
222         def get_account_number(self):
223                 """
224                 @returns The grand central phone number
225                 """
226                 return self._accountNum
227
228         def set_sane_callback(self):
229                 """
230                 Try to set a sane default callback number on these preferences
231                 1) 1747 numbers ( Gizmo )
232                 2) anything with gizmo in the name
233                 3) anything with computer in the name
234                 4) the first value
235                 """
236                 numbers = self.get_callback_numbers()
237
238                 for number, description in numbers.iteritems():
239                         if re.compile(r"""1747""").match(number) is not None:
240                                 self.set_callback_number(number)
241                                 return
242
243                 for number, description in numbers.iteritems():
244                         if re.compile(r"""gizmo""", re.I).search(description) is not None:
245                                 self.set_callback_number(number)
246                                 return
247
248                 for number, description in numbers.iteritems():
249                         if re.compile(r"""computer""", re.I).search(description) is not None:
250                                 self.set_callback_number(number)
251                                 return
252
253                 for number, description in numbers.iteritems():
254                         self.set_callback_number(number)
255                         return
256
257         def get_callback_numbers(self):
258                 """
259                 @returns a dictionary mapping call back numbers to descriptions
260                 @note These results are cached for 30 minutes.
261                 """
262                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
263                         return self._callbackNumbers
264
265                 return {}
266
267         def set_callback_number(self, callbacknumber):
268                 """
269                 Set the number that grandcental calls
270                 @param callbacknumber should be a proper 10 digit number
271                 """
272                 self._callbackNumber = callbacknumber
273                 callbackPostData = urllib.urlencode({
274                         '_rnr_se': self._token,
275                         'phone': callbacknumber
276                 })
277                 try:
278                         callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
279                 except urllib2.URLError, e:
280                         warnings.warn(traceback.format_exc())
281                         raise RuntimeError("%s is not accesible" % self._setforwardURL)
282
283                 # @bug This does not seem to be keeping on my tablet (but works on the
284                 # desktop), or the reading isn't working too well
285                 self._browser.cookies.save()
286                 return True
287
288         def get_callback_number(self):
289                 """
290                 @returns Current callback number or None
291                 """
292                 for c in self._browser.cookies:
293                         if c.name == "gv-ph":
294                                 return c.value
295                 return self._callbackNumber
296
297         def get_recent(self):
298                 """
299                 @todo Sort this stuff
300                 @returns Iterable of (personsName, phoneNumber, date, action)
301                 """
302                 sortedRecent = [
303                         (exactDate, name, number, relativeDate, action)
304                         for (name, number, exactDate, relativeDate, action) in self._get_recent()
305                 ]
306                 sortedRecent.sort(reverse = True)
307                 for exactDate, name, number, relativeDate, action in sortedRecent:
308                         yield name, number, relativeDate, action
309
310         def get_addressbooks(self):
311                 """
312                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
313                 """
314                 yield self, "", ""
315
316         def open_addressbook(self, bookId):
317                 return self
318
319         @staticmethod
320         def contact_source_short_name(contactId):
321                 return "GV"
322
323         @staticmethod
324         def factory_name():
325                 return "Google Voice"
326
327         def get_contacts(self):
328                 """
329                 @returns Iterable of (contact id, contact name)
330                 """
331                 if self.__contacts is None:
332                         self.__contacts = []
333
334                         contactsPagesUrls = [self._contactsURL]
335                         for contactsPageUrl in contactsPagesUrls:
336                                 try:
337                                         contactsPage = self._browser.download(contactsPageUrl)
338                                 except urllib2.URLError, e:
339                                         warnings.warn(traceback.format_exc())
340                                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
341                                 for contact_match in self._contactsRe.finditer(contactsPage):
342                                         contactId = contact_match.group(1)
343                                         contactName = saxutils.unescape(contact_match.group(2))
344                                         contact = contactId, contactName
345                                         self.__contacts.append(contact)
346                                         yield contact
347
348                                 next_match = self._contactsNextRe.match(contactsPage)
349                                 if next_match is not None:
350                                         newContactsPageUrl = self._contactsURL + next_match.group(1)
351                                         contactsPagesUrls.append(newContactsPageUrl)
352                 else:
353                         for contact in self.__contacts:
354                                 yield contact
355
356         def get_contact_details(self, contactId):
357                 """
358                 @returns Iterable of (Phone Type, Phone Number)
359                 """
360                 try:
361                         detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
362                 except urllib2.URLError, e:
363                         warnings.warn(traceback.format_exc())
364                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
365
366                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
367                         phoneNumber = detail_match.group(1)
368                         phoneType = saxutils.unescape(detail_match.group(2))
369                         yield (phoneType, phoneNumber)
370
371         def get_messages(self):
372                 try:
373                         voicemailPage = self._browser.download(self._voicemailURL)
374                 except urllib2.URLError, e:
375                         warnings.warn(traceback.format_exc())
376                         raise RuntimeError("%s is not accesible" % self._voicemailURL)
377
378                 try:
379                         smsPage = self._browser.download(self._smsURL)
380                 except urllib2.URLError, e:
381                         warnings.warn(traceback.format_exc())
382                         raise RuntimeError("%s is not accesible" % self._smsURL)
383
384                 voicemailHtml = self._grab_html(voicemailPage)
385                 smsHtml = self._grab_html(smsPage)
386
387                 print "="*60
388                 print voicemailHtml
389                 print "-"*60
390                 print smsHtml
391                 print "="*60
392
393                 return ()
394
395         def _grab_json(self, flatXml):
396                 xmlTree = ElementTree.fromstring(flatXml)
397                 jsonElement = xmlTree.getchildren()[0]
398                 flatJson = jsonElement.text
399                 jsonTree = parse_json(flatJson)
400                 return jsonTree
401
402         def _grab_html(self, flatXml):
403                 xmlTree = ElementTree.fromstring(flatXml)
404                 htmlElement = xmlTree.getchildren()[1]
405                 flatHtml = htmlElement.text
406                 return flatHtml
407
408         def _grab_account_info(self):
409                 page = self._browser.download(self._forwardURL)
410
411                 tokenGroup = self._tokenRe.search(page)
412                 if tokenGroup is None:
413                         raise RuntimeError("Could not extract authentication token from GoogleVoice")
414                 self._token = tokenGroup.group(1)
415
416                 anGroup = self._accountNumRe.search(page)
417                 if anGroup is None:
418                         raise RuntimeError("Could not extract account number from GoogleVoice")
419                 self._accountNum = anGroup.group(1)
420
421                 self._callbackNumbers = {}
422                 for match in self._callbackRe.finditer(page):
423                         callbackNumber = match.group(2)
424                         callbackName = match.group(1)
425                         self._callbackNumbers[callbackNumber] = callbackName
426
427         def _send_validation(self, number):
428                 if not self.is_valid_syntax(number):
429                         raise ValueError('Number is not valid: "%s"' % number)
430                 elif not self.is_authed():
431                         raise RuntimeError("Not Authenticated")
432
433                 if len(number) == 11 and number[0] == 1:
434                         # Strip leading 1 from 11 digit dialing
435                         number = number[1:]
436                 return number
437
438         def _get_recent(self):
439                 """
440                 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
441                 """
442                 for url in (
443                         self._receivedCallsURL,
444                         self._missedCallsURL,
445                         self._placedCallsURL,
446                 ):
447                         try:
448                                 flatXml = self._browser.download(url)
449                         except urllib2.URLError, e:
450                                 warnings.warn(traceback.format_exc())
451                                 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
452
453                         allRecentData = self._grab_json(flatXml)
454                         for recentCallData in allRecentData["messages"].itervalues():
455                                 number = recentCallData["displayNumber"]
456                                 exactDate = recentCallData["displayStartDateTime"]
457                                 relativeDate = recentCallData["relativeStartTime"]
458                                 action = ", ".join((
459                                         label.title()
460                                         for label in recentCallData["labels"]
461                                                 if label.lower() != "all" and label.lower() != "inbox"
462                                 ))
463                                 number = saxutils.unescape(number)
464                                 exactDate = saxutils.unescape(exactDate)
465                                 exactDate = datetime.datetime.strptime(exactDate, "%m/%d/%y %I:%M %p")
466                                 relativeDate = saxutils.unescape(relativeDate)
467                                 action = saxutils.unescape(action)
468                                 yield "", number, exactDate, relativeDate, action
469
470
471 def test_backend(username, password):
472         import pprint
473         backend = GVDialer()
474         print "Authenticated: ", backend.is_authed()
475         print "Login?: ", backend.login(username, password)
476         print "Authenticated: ", backend.is_authed()
477         print "Token: ", backend._token
478         print "Account: ", backend.get_account_number()
479         print "Callback: ", backend.get_callback_number()
480         print "All Callback: ",
481         pprint.pprint(backend.get_callback_numbers())
482         # print "Recent: ",
483         # pprint.pprint(list(backend.get_recent()))
484         # print "Contacts: ",
485         # for contact in backend.get_contacts():
486         #       print contact
487         #       pprint.pprint(list(backend.get_contact_details(contact[0])))
488
489         return backend