Pulling in the latest of brontides manually
[gc-dialer] / src / dialcentral / gc_backend.py
1 #!/usr/bin/python
2
3 # GC Dialer - Front end for Google's Grand Central service.
4 # Copyright (C) 2008  Eric Warnke ericew AT gmail DOT com
5
6 # This library is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU Lesser General Public
8 # License as published by the Free Software Foundation; either
9 # version 2.1 of the License, or (at your option) any later version.
10
11 # This library is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14 # Lesser General Public License for more details.
15
16 # You should have received a copy of the GNU Lesser General Public
17 # License along with this library; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
19
20 """
21 Grandcentral Dialer backend code
22 """
23
24
25 import os
26 import re
27 import urllib
28 import urllib2
29 import time
30 import warnings
31
32 from browser_emu import MozillaEmulator
33
34
35 class GCDialer(object):
36         """
37         This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
38         the functions include login, setting up a callback number, and initalting a callback
39         """
40
41         _gcDialingStrRe = re.compile("This may take a few seconds", re.M)
42         _accessTokenRe = re.compile(r"""<input type="hidden" name="a_t" [^>]*value="(.*)"/>""")
43         _isLoginPageRe = re.compile(r"""<form method="post" action="https://www.grandcentral.com/mobile/account/login">""")
44         _callbackRe = re.compile(r"""name="default_number" value="(\d+)" />\s+(.*)\s$""", re.M)
45         _accountNumRe = re.compile(r"""<img src="/images/mobile/inbox_logo.gif" alt="GrandCentral" />\s*(.{14})\s*&nbsp""", re.M)
46         _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)
47         _contactsRe = re.compile(r"""<a href="/mobile/contacts/detail/(\d+)">(.*?)</a>""", re.S)
48         _contactsNextRe = re.compile(r""".*<a href="/mobile/contacts(\?page=\d+)">Next</a>""", re.S)
49         _contactDetailGroupRe   = re.compile(r"""Group:\s*(\w*)""", re.S)
50         _contactDetailPhoneRe   = re.compile(r"""(\w+):[0-9\-\(\) \t]*?<a href="/mobile/calls/click_to_call\?destno=(\d+).*?">call</a>""", re.S)
51
52         _validateRe = re.compile("^[0-9]{10,}$")
53
54         _forwardselectURL = "http://www.grandcentral.com/mobile/settings/forwarding_select"
55         _loginURL = "https://www.grandcentral.com/mobile/account/login"
56         _setforwardURL = "http://www.grandcentral.com/mobile/settings/set_forwarding?from=settings"
57         _clicktocallURL = "http://www.grandcentral.com/mobile/calls/click_to_call?a_t=%s&destno=%s"
58         _inboxallURL = "http://www.grandcentral.com/mobile/messages/inbox?types=all"
59         _contactsURL = "http://www.grandcentral.com/mobile/contacts"
60         _contactDetailURL = "http://www.grandcentral.com/mobile/contacts/detail"
61
62         def __init__(self, cookieFile = None):
63                 # Important items in this function are the setup of the browser emulation and cookie file
64                 self._msg = ""
65
66                 self._browser = MozillaEmulator(None, 0)
67                 if cookieFile is None:
68                         cookieFile = os.path.join(os.path.expanduser("~"), ".gc_dialer_cookies.txt")
69                 self._browser.cookies.filename = cookieFile
70                 if os.path.isfile(cookieFile):
71                         self._browser.cookies.load()
72
73                 self._accessToken = None
74                 self._accountNum = None
75                 self._callbackNumbers = {}
76                 self._lastAuthed = 0.0
77
78         def is_authed(self, force = False):
79                 """
80                 Attempts to detect a current session and pull the auth token ( a_t ) from the page.
81                 @note Once logged in try not to reauth more than once a minute.
82                 @returns If authenticated
83                 """
84
85                 if (time.time() - self._lastAuthed) < 60 and not force:
86                         return True
87
88                 try:
89                         forwardSelectionPage = self._browser.download(GCDialer._forwardselectURL)
90                 except urllib2.URLError, e:
91                         warnings.warn("%s is not accesible" % GCDialer._forwardselectURL, UserWarning, 2)
92                         return False
93
94                 self._browser.cookies.save()
95                 if GCDialer._isLoginPageRe.search(forwardSelectionPage) is None:
96                         self._grab_token(forwardSelectionPage)
97                         self._lastAuthed = time.time()
98                         return True
99
100                 return False
101
102         def login(self, username, password):
103                 """
104                 Attempt to login to grandcentral
105                 @returns Whether login was successful or not
106                 """
107                 if self.is_authed():
108                         return True
109
110                 loginPostData = urllib.urlencode( {'username' : username , 'password' : password } )
111
112                 try:
113                         loginSuccessOrFailurePage = self._browser.download(GCDialer._loginURL, loginPostData)
114                 except urllib2.URLError, e:
115                         warnings.warn("%s is not accesible" % GCDialer._loginURL, UserWarning, 2)
116                         return False
117
118                 return self.is_authed()
119
120         def logout(self):
121                 self._lastAuthed = 0.0
122                 self._browser.cookies.clear()
123                 self._browser.cookies.save()
124
125         def dial(self, number):
126                 """
127                 This is the main function responsible for initating the callback
128                 """
129                 self._msg = ""
130
131                 # If the number is not valid throw exception
132                 if not self.is_valid_syntax(number):
133                         raise ValueError('number is not valid')
134
135                 # No point if we don't have the magic cookie
136                 if not self.is_authed():
137                         self._msg = "Not authenticated"
138                         return False
139
140                 # Strip leading 1 from 11 digit dialing
141                 if len(number) == 11 and number[0] == 1:
142                         number = number[1:]
143
144                 try:
145                         callSuccessPage = self._browser.download(
146                                 GCDialer._clicktocallURL % (self._accessToken, number),
147                                 None,
148                                 {'Referer' : 'http://www.grandcentral.com/mobile/messages'}
149                         )
150                 except urllib2.URLError, e:
151                         warnings.warn("%s is not accesible" % GCDialer._clicktocallURL, UserWarning, 2)
152                         return False
153
154                 if GCDialer._gcDialingStrRe.search(callSuccessPage) is not None:
155                         return True
156                 else:
157                         self._msg = "Grand Central returned an error"
158                         return False
159
160                 self._msg = "Unknown Error"
161                 return False
162
163         def clear_caches(self):
164                 pass
165
166         def is_valid_syntax(self, number):
167                 """
168                 @returns If This number be called ( syntax validation only )
169                 """
170                 return self._validateRe.match(number) is not None
171
172         def get_account_number(self):
173                 """
174                 @returns The grand central phone number
175                 """
176                 return self._accountNum
177
178         def set_sane_callback(self):
179                 """
180                 Try to set a sane default callback number on these preferences
181                 1) 1747 numbers ( Gizmo )
182                 2) anything with gizmo in the name
183                 3) anything with computer in the name
184                 4) the first value
185                 """
186                 numbers = self.get_callback_numbers()
187
188                 for number, description in numbers.iteritems():
189                         if not re.compile(r"""1747""").match(number) is None:
190                                 self.set_callback_number(number)
191                                 return
192
193                 for number, description in numbers.iteritems():
194                         if not re.compile(r"""gizmo""", re.I).search(description) is None:
195                                 self.set_callback_number(number)
196                                 return
197
198                 for number, description in numbers.iteritems():
199                         if not re.compile(r"""computer""", re.I).search(description) is None:
200                                 self.set_callback_number(number)
201                                 return
202
203                 for number, description in numbers.iteritems():
204                         self.set_callback_number(number)
205                         return
206
207         def get_callback_numbers(self):
208                 """
209                 @returns a dictionary mapping call back numbers to descriptions
210                 @note These results are cached for 30 minutes.
211                 """
212                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
213                         return self._callbackNumbers
214
215                 return {}
216
217         def set_callback_number(self, callbacknumber):
218                 """
219                 Set the number that grandcental calls
220                 @param callbacknumber should be a proper 10 digit number
221                 """
222                 callbackPostData = urllib.urlencode({
223                         'a_t': self._accessToken,
224                         'default_number': callbacknumber
225                 })
226                 try:
227                         callbackSetPage = self._browser.download(GCDialer._setforwardURL, callbackPostData)
228                 except urllib2.URLError, e:
229                         warnings.warn("%s is not accesible" % GCDialer._setforwardURL, UserWarning, 2)
230                         return False
231
232                 self._browser.cookies.save()
233                 return True
234
235         def get_callback_number(self):
236                 """
237                 @returns Current callback number or None
238                 """
239                 for c in self._browser.cookies:
240                         if c.name == "pda_forwarding_number":
241                                 return c.value
242                 return None
243
244         def get_recent(self):
245                 """
246                 @returns Iterable of (personsName, phoneNumber, date, action)
247                 """
248                 try:
249                         recentCallsPage = self._browser.download(GCDialer._inboxallURL)
250                 except urllib2.URLError, e:
251                         warnings.warn("%s is not accesible" % GCDialer._inboxallURL, UserWarning, 2)
252                         return
253
254                 for match in self._inboxRe.finditer(recentCallsPage):
255                         phoneNumber = match.group(4)
256                         action = match.group(1)
257                         date = match.group(2)
258                         personsName = match.group(3)
259                         yield personsName, phoneNumber, date, action
260
261         def get_addressbooks(self):
262                 """
263                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
264                 """
265                 yield self, "", ""
266         
267         def open_addressbook(self, bookId):
268                 return self
269
270         @staticmethod
271         def factory_short_name():
272                 return "GC"
273
274         @staticmethod
275         def factory_name():
276                 return "Grand Central"
277
278         def get_contacts(self):
279                 """
280                 @returns Iterable of (contact id, contact name)
281                 """
282                 contactsPagesUrls = [GCDialer._contactsURL]
283                 for contactsPageUrl in contactsPagesUrls:
284                         contactsPage = self._browser.download(contactsPageUrl)
285                         for contact_match in self._contactsRe.finditer(contactsPage):
286                                 contactId = contact_match.group(1)
287                                 contactName = contact_match.group(2)
288                                 yield contactId, contactName
289
290                         next_match = self._contactsNextRe.match(contactsPage)
291                         if next_match is not None:
292                                 newContactsPageUrl = self._contactsURL + next_match.group(1)
293                                 contactsPagesUrls.append(newContactsPageUrl)
294         
295         def get_contact_details(self, contactId):
296                 """
297                 @returns Iterable of (Phone Type, Phone Number)
298                 """
299                 detailPage = self._browser.download(GCDialer._contactDetailURL + '/' + contactId)
300                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
301                         phoneType = detail_match.group(1)
302                         phoneNumber = detail_match.group(2)
303                         yield (phoneType, phoneNumber)
304
305         def _grab_token(self, data):
306                 "Pull the magic cookie from the datastream"
307                 atGroup = GCDialer._accessTokenRe.search(data)
308                 self._accessToken = atGroup.group(1)
309
310                 anGroup = GCDialer._accountNumRe.search(data)
311                 self._accountNum = anGroup.group(1)
312
313                 self._callbackNumbers = {}
314                 for match in GCDialer._callbackRe.finditer(data):
315                         self._callbackNumbers[match.group(1)] = match.group(2)