04a0468bdc205720a62111296b1cdd926830cc22
[gc-dialer] / gc_dialer / gcbackend.py
1 #!/usr/bin/python
2
3 """
4 Grandcentral Dialer backend code
5 Eric Warnke <ericew@gmail.com>
6 Copyright 2008 GPLv2
7 """
8
9
10 import os
11 import re
12 import urllib
13 import urllib2
14 import time
15 import warnings
16
17 from browser_emu import MozillaEmulator
18
19
20 _validateRe = re.compile("^[0-9]{10,}$")
21
22
23 class GCDialer(object):
24         """
25         This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
26         the functions include login, setting up a callback number, and initalting a callback
27         """
28
29         _gcDialingStrRe = re.compile("This may take a few seconds", re.M) # string from Grandcentral.com on successful dial
30         _accessTokenRe = re.compile(r"""<input type="hidden" name="a_t" [^>]*value="(.*)"/>""")
31         _isLoginPageRe = re.compile(r"""<form method="post" action="https://www.grandcentral.com/mobile/account/login">""")
32         _callbackRe = re.compile(r"""name="default_number" value="(\d+)" />\s+(.*)\s$""", re.M)
33         _accountNumRe = re.compile(r"""<img src="/images/mobile/inbox_logo.gif" alt="GrandCentral" />\s*(.{14})\s*&nbsp""", re.M)
34         _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)
35
36         _forwardselectURL = "http://www.grandcentral.com/mobile/settings/forwarding_select"
37         _loginURL = "https://www.grandcentral.com/mobile/account/login"
38         _setforwardURL = "http://www.grandcentral.com/mobile/settings/set_forwarding?from=settings"
39         _clicktocallURL = "http://www.grandcentral.com/mobile/calls/click_to_call?a_t=%s&destno=%s"
40         _inboxallURL = "http://www.grandcentral.com/mobile/messages/inbox?types=all"
41
42         def __init__(self, cookieFile = None):
43                 # Important items in this function are the setup of the browser emulation and cookie file
44                 self._msg = ""
45
46                 self._browser = MozillaEmulator(None, 0)
47                 if cookieFile is None:
48                         cookieFile = os.path.join(os.path.expanduser("~"), ".gc_dialer_cookies.txt")
49                 self._browser.cookies.filename = cookieFile
50                 if os.path.isfile(cookieFile):
51                         self._browser.cookies.load()
52
53                 self._accessToken = None
54                 self._accountNum = None
55                 self._callbackNumbers = {}
56                 self._lastAuthed = 0.0
57
58         def isAuthed(self, force = False):
59                 """
60                 Attempts to detect a current session and pull the auth token ( a_t ) from the page.
61                 @note Once logged in try not to reauth more than once a minute.
62                 @returns If authenticated
63                 """
64
65                 if time.time() - self._lastAuthed < 60 and not force:
66                         return True
67
68                 try:
69                         forwardSelectionPage = self._browser.download(GCDialer._forwardselectURL)
70                 except urllib2.URLError, e:
71                         warnings.warn("%s is not accesible" % GCDialer._forwardselectURL, UserWarning, 2)
72                         return False
73
74                 self._browser.cookies.save()
75                 if GCDialer._isLoginPageRe.search(forwardSelectionPage) is None:
76                         self._grabToken(forwardSelectionPage)
77                         self._lastAuthed = time.time()
78                         return True
79
80                 return False
81
82         def login(self, username, password):
83                 """
84                 Attempt to login to grandcentral
85                 @returns Whether login was successful or not
86                 """
87                 if self.isAuthed():
88                         return True
89
90                 loginPostData = urllib.urlencode( {'username' : username , 'password' : password } )
91
92                 try:
93                         loginSuccessOrFailurePage = self._browser.download(GCDialer._loginURL, loginPostData)
94                 except urllib2.URLError, e:
95                         warnings.warn("%s is not accesible" % GCDialer._loginURL, UserWarning, 2)
96                         return False
97
98                 return self.isAuthed()
99
100         def dial(self, number):
101                 """
102                 This is the main function responsible for initating the callback
103                 """
104                 self._msg = ""
105
106                 # If the number is not valid throw exception
107                 if not self.is_valid_syntax(number):
108                         raise ValueError('number is not valid')
109
110                 # No point if we don't have the magic cookie
111                 if not self.isAuthed():
112                         self._msg = "Not authenticated"
113                         return False
114
115                 # Strip leading 1 from 11 digit dialing
116                 if len(number) == 11 and number[0] == 1:
117                         number = number[1:]
118
119                 try:
120                         callSuccessPage = self._browser.download(
121                                 GCDialer._clicktocallURL % (self._accessToken, number),
122                                 None,
123                                 {'Referer' : 'http://www.grandcentral.com/mobile/messages'}
124                         )
125                 except urllib2.URLError, e:
126                         warnings.warn("%s is not accesible" % GCDialer._clicktocallURL, UserWarning, 2)
127                         return False
128
129                 if GCDialer._gcDialingStrRe.search(callSuccessPage) is not None:
130                         return True
131                 else:
132                         self._msg = "Grand Central returned an error"
133                         return False
134
135                 self._msg = "Unknown Error"
136                 return False
137
138         def clear_caches(self):
139                 pass
140
141         def reset(self):
142                 self._lastAuthed = 0.0
143                 self._browser.cookies.clear()
144                 self._browser.cookies.save()
145
146         def is_valid_syntax(self, number):
147                 """
148                 @returns If This number be called ( syntax validation only )
149                 """
150                 return _validateRe.match(number) is not None
151
152
153         def getAccountNumber(self):
154                 """
155                 @returns The grand central phone number
156                 """
157                 return self._accountNum
158
159         def setSaneCallback(self):
160                 """
161                 Try to set a sane default callback number on these preferences
162                 1) 1747 numbers ( Gizmo )
163                 2) anything with gizmo in the name
164                 3) anything with computer in the name
165                 4) the first value
166                 """
167                 print "setSaneCallback"
168                 numbers = self.getCallbackNumbers()
169
170                 for number, description in numbers.iteritems():
171                         if not re.compile(r"""1747""").match(number) is None:
172                                 self.setCallbackNumber(number)
173                                 return
174
175                 for number, description in numbers.iteritems():
176                         if not re.compile(r"""gizmo""", re.I).search(description) is None:
177                                 self.setCallbackNumber(number)
178                                 return
179
180                 for number, description in numbers.iteritems():
181                         if not re.compile(r"""computer""", re.I).search(description) is None:
182                                 self.setCallbackNumber(number)
183                                 return
184
185                 for number, description in numbers.iteritems():
186                         self.setCallbackNumber(number)
187                         return
188
189         def getCallbackNumbers(self):
190                 """
191                 @returns a dictionary mapping call back numbers to descriptions
192                 @note These results are cached for 30 minutes.
193                 """
194                 print "getCallbackNumbers"
195                 if time.time() - self._lastAuthed < 1800 or self.isAuthed():
196                         return self._callbackNumbers
197
198                 return {}
199
200         def setCallbackNumber(self, callbacknumber):
201                 """
202                 Set the number that grandcental calls
203                 @param callbacknumber should be a proper 10 digit number
204                 """
205                 print "setCallbackNumber %s" % (callbacknumber)
206
207                 callbackPostData = urllib.urlencode({'a_t' : self._accessToken, 'default_number' : callbacknumber })
208                 try:
209                         callbackSetPage = self._browser.download(GCDialer._setforwardURL, callbackPostData)
210                 except urllib2.URLError, e:
211                         warnings.warn("%s is not accesible" % GCDialer._setforwardURL, UserWarning, 2)
212                         return False
213
214                 self._browser.cookies.save()
215                 return True
216
217         def getCallbackNumber(self):
218                 """
219                 @returns Current callback number or None
220                 """
221                 for c in self._browser.cookies:
222                         if c.name == "pda_forwarding_number":
223                                 return c.value
224                 return None
225
226         def get_recent(self):
227                 """
228                 @returns Iterable of (personsName, phoneNumber, date, action)
229                 """
230                 try:
231                         recentCallsPage = self._browser.download(GCDialer._inboxallURL)
232                 except urllib2.URLError, e:
233                         warnings.warn("%s is not accesible" % GCDialer._inboxallURL, UserWarning, 2)
234                         return
235
236                 for match in self._inboxRe.finditer(recentCallsPage):
237                         phoneNumber = match.group(4)
238                         action = match.group(1)
239                         date = match.group(2)
240                         personsName = match.group(3)
241                         yield personsName, phoneNumber, date, action
242
243         def _grabToken(self, data):
244                 "Pull the magic cookie from the datastream"
245                 atGroup = GCDialer._accessTokenRe.search(data)
246                 self._accessToken = atGroup.group(1)
247
248                 anGroup = GCDialer._accountNumRe.search(data)
249                 self._accountNum = anGroup.group(1)
250
251                 self._callbackNumbers = {}
252                 for match in GCDialer._callbackRe.finditer(data):
253                         self._callbackNumbers[match.group(1)] = match.group(2)