Still some issues, but the start of SMS support
[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                 message = saxutils.escape(message)
196                 try:
197                         smsData = urllib.urlencode({
198                                 "number": number,
199                                 "smstext": message,
200                                 "_rnr_se": self._token,
201                                 "id": "undefined",
202                                 "c": "undefined",
203                         })
204                         otherData = {
205                                 'Referer' : 'https://google.com/voice/m/sms',
206                         }
207                         smsSuccessPage = self._browser.download(self._smsURL, smsData, None, otherData)
208                 except urllib2.URLError, e:
209                         warnings.warn(traceback.format_exc())
210                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
211
212                 return True
213
214         def clear_caches(self):
215                 self.__contacts = None
216
217         def is_valid_syntax(self, number):
218                 """
219                 @returns If This number be called ( syntax validation only )
220                 """
221                 return self._validateRe.match(number) is not None
222
223         def get_account_number(self):
224                 """
225                 @returns The grand central phone number
226                 """
227                 return self._accountNum
228
229         def set_sane_callback(self):
230                 """
231                 Try to set a sane default callback number on these preferences
232                 1) 1747 numbers ( Gizmo )
233                 2) anything with gizmo in the name
234                 3) anything with computer in the name
235                 4) the first value
236                 """
237                 numbers = self.get_callback_numbers()
238
239                 for number, description in numbers.iteritems():
240                         if re.compile(r"""1747""").match(number) is not None:
241                                 self.set_callback_number(number)
242                                 return
243
244                 for number, description in numbers.iteritems():
245                         if re.compile(r"""gizmo""", re.I).search(description) is not None:
246                                 self.set_callback_number(number)
247                                 return
248
249                 for number, description in numbers.iteritems():
250                         if re.compile(r"""computer""", re.I).search(description) is not None:
251                                 self.set_callback_number(number)
252                                 return
253
254                 for number, description in numbers.iteritems():
255                         self.set_callback_number(number)
256                         return
257
258         def get_callback_numbers(self):
259                 """
260                 @returns a dictionary mapping call back numbers to descriptions
261                 @note These results are cached for 30 minutes.
262                 """
263                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
264                         return self._callbackNumbers
265
266                 return {}
267
268         def set_callback_number(self, callbacknumber):
269                 """
270                 Set the number that grandcental calls
271                 @param callbacknumber should be a proper 10 digit number
272                 """
273                 self._callbackNumber = callbacknumber
274                 callbackPostData = urllib.urlencode({
275                         '_rnr_se': self._token,
276                         'phone': callbacknumber
277                 })
278                 try:
279                         callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
280                 except urllib2.URLError, e:
281                         warnings.warn(traceback.format_exc())
282                         raise RuntimeError("%s is not accesible" % self._setforwardURL)
283
284                 # @bug This does not seem to be keeping on my tablet (but works on the
285                 # desktop), or the reading isn't working too well
286                 self._browser.cookies.save()
287                 return True
288
289         def get_callback_number(self):
290                 """
291                 @returns Current callback number or None
292                 """
293                 for c in self._browser.cookies:
294                         if c.name == "gv-ph":
295                                 return c.value
296                 return self._callbackNumber
297
298         def get_recent(self):
299                 """
300                 @todo Sort this stuff
301                 @returns Iterable of (personsName, phoneNumber, date, action)
302                 """
303                 sortedRecent = [
304                         (exactDate, name, number, relativeDate, action)
305                         for (name, number, exactDate, relativeDate, action) in self._get_recent()
306                 ]
307                 sortedRecent.sort(reverse = True)
308                 for exactDate, name, number, relativeDate, action in sortedRecent:
309                         yield name, number, relativeDate, action
310
311         def get_addressbooks(self):
312                 """
313                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
314                 """
315                 yield self, "", ""
316
317         def open_addressbook(self, bookId):
318                 return self
319
320         @staticmethod
321         def contact_source_short_name(contactId):
322                 return "GV"
323
324         @staticmethod
325         def factory_name():
326                 return "Google Voice"
327
328         def get_contacts(self):
329                 """
330                 @returns Iterable of (contact id, contact name)
331                 """
332                 if self.__contacts is None:
333                         self.__contacts = []
334
335                         contactsPagesUrls = [self._contactsURL]
336                         for contactsPageUrl in contactsPagesUrls:
337                                 try:
338                                         contactsPage = self._browser.download(contactsPageUrl)
339                                 except urllib2.URLError, e:
340                                         warnings.warn(traceback.format_exc())
341                                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
342                                 for contact_match in self._contactsRe.finditer(contactsPage):
343                                         contactId = contact_match.group(1)
344                                         contactName = saxutils.unescape(contact_match.group(2))
345                                         contact = contactId, contactName
346                                         self.__contacts.append(contact)
347                                         yield contact
348
349                                 next_match = self._contactsNextRe.match(contactsPage)
350                                 if next_match is not None:
351                                         newContactsPageUrl = self._contactsURL + next_match.group(1)
352                                         contactsPagesUrls.append(newContactsPageUrl)
353                 else:
354                         for contact in self.__contacts:
355                                 yield contact
356
357         def get_contact_details(self, contactId):
358                 """
359                 @returns Iterable of (Phone Type, Phone Number)
360                 """
361                 try:
362                         detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
363                 except urllib2.URLError, e:
364                         warnings.warn(traceback.format_exc())
365                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
366
367                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
368                         phoneNumber = detail_match.group(1)
369                         phoneType = saxutils.unescape(detail_match.group(2))
370                         yield (phoneType, phoneNumber)
371
372         def get_messages(self):
373                 try:
374                         voicemailPage = self._browser.download(self._voicemailURL)
375                 except urllib2.URLError, e:
376                         warnings.warn(traceback.format_exc())
377                         raise RuntimeError("%s is not accesible" % self._voicemailURL)
378
379                 try:
380                         smsPage = self._browser.download(self._smsURL)
381                 except urllib2.URLError, e:
382                         warnings.warn(traceback.format_exc())
383                         raise RuntimeError("%s is not accesible" % self._smsURL)
384
385                 voicemailHtml = self._grab_html(voicemailPage)
386                 smsHtml = self._grab_html(smsPage)
387
388                 print "="*60
389                 print voicemailHtml
390                 print "-"*60
391                 print smsHtml
392                 print "="*60
393
394                 return ()
395
396         def _grab_json(self, flatXml):
397                 xmlTree = ElementTree.fromstring(flatXml)
398                 jsonElement = xmlTree.getchildren()[0]
399                 flatJson = jsonElement.text
400                 jsonTree = parse_json(flatJson)
401                 return jsonTree
402
403         def _grab_html(self, flatXml):
404                 xmlTree = ElementTree.fromstring(flatXml)
405                 htmlElement = xmlTree.getchildren()[1]
406                 flatHtml = htmlElement.text
407                 return flatHtml
408
409         def _grab_account_info(self):
410                 page = self._browser.download(self._forwardURL)
411
412                 tokenGroup = self._tokenRe.search(page)
413                 if tokenGroup is None:
414                         raise RuntimeError("Could not extract authentication token from GoogleVoice")
415                 self._token = tokenGroup.group(1)
416
417                 anGroup = self._accountNumRe.search(page)
418                 if anGroup is None:
419                         raise RuntimeError("Could not extract account number from GoogleVoice")
420                 self._accountNum = anGroup.group(1)
421
422                 self._callbackNumbers = {}
423                 for match in self._callbackRe.finditer(page):
424                         callbackNumber = match.group(2)
425                         callbackName = match.group(1)
426                         self._callbackNumbers[callbackNumber] = callbackName
427
428         def _send_validation(self, number):
429                 if not self.is_valid_syntax(number):
430                         raise ValueError('Number is not valid: "%s"' % number)
431                 elif not self.is_authed():
432                         raise RuntimeError("Not Authenticated")
433
434                 if len(number) == 11 and number[0] == 1:
435                         # Strip leading 1 from 11 digit dialing
436                         number = number[1:]
437                 return number
438
439         def _get_recent(self):
440                 """
441                 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
442                 """
443                 for url in (
444                         self._receivedCallsURL,
445                         self._missedCallsURL,
446                         self._placedCallsURL,
447                 ):
448                         try:
449                                 flatXml = self._browser.download(url)
450                         except urllib2.URLError, e:
451                                 warnings.warn(traceback.format_exc())
452                                 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
453
454                         allRecentData = self._grab_json(flatXml)
455                         for recentCallData in allRecentData["messages"].itervalues():
456                                 number = recentCallData["displayNumber"]
457                                 exactDate = recentCallData["displayStartDateTime"]
458                                 relativeDate = recentCallData["relativeStartTime"]
459                                 action = ", ".join((
460                                         label.title()
461                                         for label in recentCallData["labels"]
462                                                 if label.lower() != "all" and label.lower() != "inbox"
463                                 ))
464                                 number = saxutils.unescape(number)
465                                 exactDate = saxutils.unescape(exactDate)
466                                 exactDate = datetime.datetime.strptime(exactDate, "%m/%d/%y %I:%M %p")
467                                 relativeDate = saxutils.unescape(relativeDate)
468                                 action = saxutils.unescape(action)
469                                 yield "", number, exactDate, relativeDate, action
470
471
472 def test_backend(username, password):
473         import pprint
474         backend = GVDialer()
475         print "Authenticated: ", backend.is_authed()
476         print "Login?: ", backend.login(username, password)
477         print "Authenticated: ", backend.is_authed()
478         print "Token: ", backend._token
479         print "Account: ", backend.get_account_number()
480         print "Callback: ", backend.get_callback_number()
481         print "All Callback: ",
482         pprint.pprint(backend.get_callback_numbers())
483         # print "Recent: ",
484         # pprint.pprint(list(backend.get_recent()))
485         # print "Contacts: ",
486         # for contact in backend.get_contacts():
487         #       print contact
488         #       pprint.pprint(list(backend.get_contact_details(contact[0])))
489
490         return backend