First step towards displaying of voicemail message
[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 itertools
36 import warnings
37 import traceback
38 from xml.sax import saxutils
39
40 from xml.etree import ElementTree
41
42 import browser_emu
43
44 try:
45         import simplejson
46 except ImportError:
47         simplejson = None
48
49
50 _TRUE_REGEX = re.compile("true")
51 _FALSE_REGEX = re.compile("false")
52
53
54 def safe_eval(s):
55         s = _TRUE_REGEX.sub("True", s)
56         s = _FALSE_REGEX.sub("False", s)
57         return eval(s, {}, {})
58
59
60 if simplejson is None:
61         def parse_json(flattened):
62                 return safe_eval(flattened)
63 else:
64         def parse_json(flattened):
65                 return simplejson.loads(flattened)
66
67
68 def itergroup(iterator, count, padValue = None):
69         """
70         Iterate in groups of 'count' values. If there
71         aren't enough values, the last result is padded with
72         None.
73
74         >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
75         ...     print tuple(val)
76         (1, 2, 3)
77         (4, 5, 6)
78         >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
79         ...     print list(val)
80         [1, 2, 3]
81         [4, 5, 6]
82         >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3):
83         ...     print tuple(val)
84         (1, 2, 3)
85         (4, 5, 6)
86         (7, None, None)
87         >>> for val in itergroup("123456", 3):
88         ...     print tuple(val)
89         ('1', '2', '3')
90         ('4', '5', '6')
91         >>> for val in itergroup("123456", 3):
92         ...     print repr("".join(val))
93         '123'
94         '456'
95         """
96         paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1))
97         nIterators = (paddedIterator, ) * count
98         return itertools.izip(*nIterators)
99
100
101 class GVDialer(object):
102         """
103         This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
104         the functions include login, setting up a callback number, and initalting a callback
105         """
106
107         _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
108         _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
109         _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
110         _validateRe = re.compile("^[0-9]{10,}$")
111         _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
112
113         _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
114         _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
115         _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
116
117         _clicktocallURL = "https://www.google.com/voice/m/sendcall"
118         _smsURL = "https://www.google.com/voice/m/sendsms"
119         _contactsURL = "https://www.google.com/voice/mobile/contacts"
120         _contactDetailURL = "https://www.google.com/voice/mobile/contact"
121
122         _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
123         _setforwardURL = "https://www.google.com//voice/m/setphone"
124         _accountNumberURL = "https://www.google.com/voice/mobile"
125         _forwardURL = "https://www.google.com/voice/mobile/phones"
126
127         _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
128         _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
129         _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
130         _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
131         _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
132         _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
133
134         _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class="gc-message.*?">""", re.MULTILINE | re.DOTALL)
135         _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
136         _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
137         _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
138         _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
139         _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">(.*?)</span>""", re.MULTILINE)
140         _voicemailMessageRegex = re.compile(r"""<span class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
141
142         def __init__(self, cookieFile = None):
143                 # Important items in this function are the setup of the browser emulation and cookie file
144                 self._browser = browser_emu.MozillaEmulator(1)
145                 if cookieFile is None:
146                         cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
147                 self._browser.cookies.filename = cookieFile
148                 if os.path.isfile(cookieFile):
149                         self._browser.cookies.load()
150
151                 self._token = ""
152                 self._accountNum = None
153                 self._lastAuthed = 0.0
154                 self._callbackNumber = ""
155                 self._callbackNumbers = {}
156
157                 self.__contacts = None
158
159         def is_authed(self, force = False):
160                 """
161                 Attempts to detect a current session
162                 @note Once logged in try not to reauth more than once a minute.
163                 @returns If authenticated
164                 """
165
166                 if (time.time() - self._lastAuthed) < 60 and not force:
167                         return True
168
169                 try:
170                         self._grab_account_info()
171                 except StandardError, e:
172                         warnings.warn(traceback.format_exc())
173                         return False
174
175                 self._browser.cookies.save()
176                 self._lastAuthed = time.time()
177                 return True
178
179         def login(self, username, password):
180                 """
181                 Attempt to login to grandcentral
182                 @returns Whether login was successful or not
183                 """
184                 if self.is_authed():
185                         return True
186
187                 loginPostData = urllib.urlencode({
188                         'Email' : username,
189                         'Passwd' : password,
190                         'service': "grandcentral",
191                         "ltmpl": "mobile",
192                         "btmpl": "mobile",
193                         "PersistentCookie": "yes",
194                 })
195
196                 try:
197                         loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
198                 except urllib2.URLError, e:
199                         warnings.warn(traceback.format_exc())
200                         raise RuntimeError("%s is not accesible" % self._loginURL)
201
202                 return self.is_authed()
203
204         def logout(self):
205                 self._lastAuthed = 0.0
206                 self._browser.cookies.clear()
207                 self._browser.cookies.save()
208
209                 self.clear_caches()
210
211         def dial(self, number):
212                 """
213                 This is the main function responsible for initating the callback
214                 """
215                 number = self._send_validation(number)
216                 try:
217                         clickToCallData = urllib.urlencode({
218                                 "number": number,
219                                 "phone": self._callbackNumber,
220                                 "_rnr_se": self._token,
221                         })
222                         otherData = {
223                                 'Referer' : 'https://google.com/voice/m/callsms',
224                         }
225                         callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
226                 except urllib2.URLError, e:
227                         warnings.warn(traceback.format_exc())
228                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
229
230                 if self._gvDialingStrRe.search(callSuccessPage) is None:
231                         raise RuntimeError("Google Voice returned an error")
232
233                 return True
234
235         def send_sms(self, number, message):
236                 number = self._send_validation(number)
237                 try:
238                         smsData = urllib.urlencode({
239                                 "number": number,
240                                 "smstext": message,
241                                 "_rnr_se": self._token,
242                                 "id": "undefined",
243                                 "c": "undefined",
244                         })
245                         otherData = {
246                                 'Referer' : 'https://google.com/voice/m/sms',
247                         }
248                         smsSuccessPage = self._browser.download(self._smsURL, smsData, None, otherData)
249                 except urllib2.URLError, e:
250                         warnings.warn(traceback.format_exc())
251                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
252
253                 return True
254
255         def clear_caches(self):
256                 self.__contacts = None
257
258         def is_valid_syntax(self, number):
259                 """
260                 @returns If This number be called ( syntax validation only )
261                 """
262                 return self._validateRe.match(number) is not None
263
264         def get_account_number(self):
265                 """
266                 @returns The grand central phone number
267                 """
268                 return self._accountNum
269
270         def set_sane_callback(self):
271                 """
272                 Try to set a sane default callback number on these preferences
273                 1) 1747 numbers ( Gizmo )
274                 2) anything with gizmo in the name
275                 3) anything with computer in the name
276                 4) the first value
277                 """
278                 numbers = self.get_callback_numbers()
279
280                 for number, description in numbers.iteritems():
281                         if re.compile(r"""1747""").match(number) is not None:
282                                 self.set_callback_number(number)
283                                 return
284
285                 for number, description in numbers.iteritems():
286                         if re.compile(r"""gizmo""", re.I).search(description) is not None:
287                                 self.set_callback_number(number)
288                                 return
289
290                 for number, description in numbers.iteritems():
291                         if re.compile(r"""computer""", re.I).search(description) is not None:
292                                 self.set_callback_number(number)
293                                 return
294
295                 for number, description in numbers.iteritems():
296                         self.set_callback_number(number)
297                         return
298
299         def get_callback_numbers(self):
300                 """
301                 @returns a dictionary mapping call back numbers to descriptions
302                 @note These results are cached for 30 minutes.
303                 """
304                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
305                         return self._callbackNumbers
306
307                 return {}
308
309         def set_callback_number(self, callbacknumber):
310                 """
311                 Set the number that grandcental calls
312                 @param callbacknumber should be a proper 10 digit number
313                 """
314                 self._callbackNumber = callbacknumber
315                 callbackPostData = urllib.urlencode({
316                         '_rnr_se': self._token,
317                         'phone': callbacknumber
318                 })
319                 try:
320                         callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
321                 except urllib2.URLError, e:
322                         warnings.warn(traceback.format_exc())
323                         raise RuntimeError("%s is not accesible" % self._setforwardURL)
324
325                 # @bug This does not seem to be keeping on my tablet (but works on the
326                 # desktop), or the reading isn't working too well
327                 self._browser.cookies.save()
328                 return True
329
330         def get_callback_number(self):
331                 """
332                 @returns Current callback number or None
333                 """
334                 for c in self._browser.cookies:
335                         if c.name == "gv-ph":
336                                 return c.value
337                 return self._callbackNumber
338
339         def get_recent(self):
340                 """
341                 @todo Sort this stuff
342                 @returns Iterable of (personsName, phoneNumber, date, action)
343                 """
344                 sortedRecent = [
345                         (exactDate, name, number, relativeDate, action)
346                         for (name, number, exactDate, relativeDate, action) in self._get_recent()
347                 ]
348                 sortedRecent.sort(reverse = True)
349                 for exactDate, name, number, relativeDate, action in sortedRecent:
350                         yield name, number, relativeDate, action
351
352         def get_addressbooks(self):
353                 """
354                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
355                 """
356                 yield self, "", ""
357
358         def open_addressbook(self, bookId):
359                 return self
360
361         @staticmethod
362         def contact_source_short_name(contactId):
363                 return "GV"
364
365         @staticmethod
366         def factory_name():
367                 return "Google Voice"
368
369         def get_contacts(self):
370                 """
371                 @returns Iterable of (contact id, contact name)
372                 """
373                 if self.__contacts is None:
374                         self.__contacts = []
375
376                         contactsPagesUrls = [self._contactsURL]
377                         for contactsPageUrl in contactsPagesUrls:
378                                 try:
379                                         contactsPage = self._browser.download(contactsPageUrl)
380                                 except urllib2.URLError, e:
381                                         warnings.warn(traceback.format_exc())
382                                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
383                                 for contact_match in self._contactsRe.finditer(contactsPage):
384                                         contactId = contact_match.group(1)
385                                         contactName = saxutils.unescape(contact_match.group(2))
386                                         contact = contactId, contactName
387                                         self.__contacts.append(contact)
388                                         yield contact
389
390                                 next_match = self._contactsNextRe.match(contactsPage)
391                                 if next_match is not None:
392                                         newContactsPageUrl = self._contactsURL + next_match.group(1)
393                                         contactsPagesUrls.append(newContactsPageUrl)
394                 else:
395                         for contact in self.__contacts:
396                                 yield contact
397
398         def get_contact_details(self, contactId):
399                 """
400                 @returns Iterable of (Phone Type, Phone Number)
401                 """
402                 try:
403                         detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
404                 except urllib2.URLError, e:
405                         warnings.warn(traceback.format_exc())
406                         raise RuntimeError("%s is not accesible" % self._clicktocallURL)
407
408                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
409                         phoneNumber = detail_match.group(1)
410                         phoneType = saxutils.unescape(detail_match.group(2))
411                         yield (phoneType, phoneNumber)
412
413         def get_messages(self):
414                 try:
415                         voicemailPage = self._browser.download(self._voicemailURL)
416                 except urllib2.URLError, e:
417                         warnings.warn(traceback.format_exc())
418                         raise RuntimeError("%s is not accesible" % self._voicemailURL)
419
420                 try:
421                         smsPage = self._browser.download(self._smsURL)
422                 except urllib2.URLError, e:
423                         warnings.warn(traceback.format_exc())
424                         raise RuntimeError("%s is not accesible" % self._smsURL)
425
426                 voicemailHtml = self._grab_html(voicemailPage)
427                 parsedVoicemail = self._parse_voicemail(voicemailHtml)
428                 decoratedVoicemails = self._decorated_voicemail(parsedVoicemail)
429
430                 # @todo Parse this
431                 # smsHtml = self._grab_html(smsPage)
432
433                 allMessages = itertools.chain(decoratedVoicemails)
434                 sortedMessages = list(allMessages)
435                 for exactDate, header, number, relativeDate, message in sortedMessages:
436                         yield header, number, relativeDate, message
437
438         def _grab_json(self, flatXml):
439                 xmlTree = ElementTree.fromstring(flatXml)
440                 jsonElement = xmlTree.getchildren()[0]
441                 flatJson = jsonElement.text
442                 jsonTree = parse_json(flatJson)
443                 return jsonTree
444
445         def _grab_html(self, flatXml):
446                 xmlTree = ElementTree.fromstring(flatXml)
447                 htmlElement = xmlTree.getchildren()[1]
448                 flatHtml = htmlElement.text
449                 return flatHtml
450
451         def _grab_account_info(self):
452                 page = self._browser.download(self._forwardURL)
453
454                 tokenGroup = self._tokenRe.search(page)
455                 if tokenGroup is None:
456                         raise RuntimeError("Could not extract authentication token from GoogleVoice")
457                 self._token = tokenGroup.group(1)
458
459                 anGroup = self._accountNumRe.search(page)
460                 if anGroup is None:
461                         raise RuntimeError("Could not extract account number from GoogleVoice")
462                 self._accountNum = anGroup.group(1)
463
464                 self._callbackNumbers = {}
465                 for match in self._callbackRe.finditer(page):
466                         callbackNumber = match.group(2)
467                         callbackName = match.group(1)
468                         self._callbackNumbers[callbackNumber] = callbackName
469
470         def _send_validation(self, number):
471                 if not self.is_valid_syntax(number):
472                         raise ValueError('Number is not valid: "%s"' % number)
473                 elif not self.is_authed():
474                         raise RuntimeError("Not Authenticated")
475
476                 if len(number) == 11 and number[0] == 1:
477                         # Strip leading 1 from 11 digit dialing
478                         number = number[1:]
479                 return number
480
481         def _get_recent(self):
482                 """
483                 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
484                 """
485                 for url in (
486                         self._receivedCallsURL,
487                         self._missedCallsURL,
488                         self._placedCallsURL,
489                 ):
490                         try:
491                                 flatXml = self._browser.download(url)
492                         except urllib2.URLError, e:
493                                 warnings.warn(traceback.format_exc())
494                                 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
495
496                         allRecentData = self._grab_json(flatXml)
497                         for recentCallData in allRecentData["messages"].itervalues():
498                                 number = recentCallData["displayNumber"]
499                                 exactDate = recentCallData["displayStartDateTime"]
500                                 relativeDate = recentCallData["relativeStartTime"]
501                                 action = ", ".join((
502                                         label.title()
503                                         for label in recentCallData["labels"]
504                                                 if label.lower() != "all" and label.lower() != "inbox"
505                                 ))
506                                 number = saxutils.unescape(number)
507                                 exactDate = saxutils.unescape(exactDate)
508                                 exactDate = datetime.datetime.strptime(exactDate, "%m/%d/%y %I:%M %p")
509                                 relativeDate = saxutils.unescape(relativeDate)
510                                 action = saxutils.unescape(action)
511                                 yield "", number, exactDate, relativeDate, action
512
513         def _parse_voicemail(self, voicemailHtml):
514                 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
515                 for id, messageHtml in itergroup(splitVoicemail[1:], 2):
516                         exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
517                         exactTime = exactTimeGroup.group(1) if exactTimeGroup else ""
518                         relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
519                         relativeTime = relativeTimeGroup.group(1) if relativeTimeGroup else ""
520                         locationGroup = self._voicemailLocationRegex.search(messageHtml)
521                         location = locationGroup.group(1) if locationGroup else ""
522                         numberGroup = self._voicemailNumberRegex.search(messageHtml)
523                         number = numberGroup.group(1) if numberGroup else ""
524                         prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
525                         prettyNumber = prettyNumberGroup.group(1) if prettyNumberGroup else ""
526                         messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
527                         messageParts = (
528                                 (group.group(1), group.group(2))
529                                 for group in messageGroups
530                         ) if messageGroups else ()
531                         yield {
532                                 "id": id,
533                                 "time": exactTime,
534                                 "relTime": relativeTime,
535                                 "prettyNumber": prettyNumber,
536                                 "number": number,
537                                 "location": location,
538                                 "messageParts": messageParts,
539                         }
540
541         def _decorated_voicemail(self, parsedVoicemail):
542                 messagePartFormat = {
543                         "med1": "<i>%s</i>",
544                         "med2": "%s",
545                         "high": "<b>%s</b>",
546                 }
547                 for voicemailData in parsedVoicemail:
548                         exactTime = voicemailData["time"] # @todo Parse This
549                         header = "%s %s" % (voicemailData["prettyNumber"], voicemailData["location"])
550                         message = " ".join((
551                                 messagePartFormat[quality] % part
552                                 for (quality, part) in voicemailData["messageParts"]
553                         )).strip()
554                         if not message:
555                                 message = "No Transcription"
556                         yield exactTime, header, voicemailData["number"], voicemailData["relTime"], message
557
558
559 def test_backend(username, password):
560         import pprint
561         backend = GVDialer()
562         print "Authenticated: ", backend.is_authed()
563         print "Login?: ", backend.login(username, password)
564         print "Authenticated: ", backend.is_authed()
565         print "Token: ", backend._token
566         print "Account: ", backend.get_account_number()
567         print "Callback: ", backend.get_callback_number()
568         # print "All Callback: ",
569         # pprint.pprint(backend.get_callback_numbers())
570         # print "Recent: ",
571         # pprint.pprint(list(backend.get_recent()))
572         # print "Contacts: ",
573         # for contact in backend.get_contacts():
574         #       print contact
575         #       pprint.pprint(list(backend.get_contact_details(contact[0])))
576
577         return backend