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