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