2341f91b24b07f0fd1d3e902aa9c58f5c6554da6
[gc-dialer] / src / 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 class GCDialer(object):
21         """
22         This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
23         the functions include login, setting up a callback number, and initalting a callback
24         """
25
26         _gcDialingStrRe = re.compile("This may take a few seconds", re.M) # string from Grandcentral.com on successful dial
27         _accessTokenRe = re.compile(r"""<input type="hidden" name="a_t" [^>]*value="(.*)"/>""")
28         _isLoginPageRe = re.compile(r"""<form method="post" action="https://www.grandcentral.com/mobile/account/login">""")
29         _callbackRe = re.compile(r"""name="default_number" value="(\d+)" />\s+(.*)\s$""", re.M)
30         _accountNumRe = re.compile(r"""<img src="/images/mobile/inbox_logo.gif" alt="GrandCentral" />\s*(.{14})\s*&nbsp""", re.M)
31         _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)
32         _contactsRe = re.compile(r"""<a href="/mobile/contacts/detail/(\d+)">(.*?)</a>""", re.S)
33         _contactsNextRe = re.compile(r""".*<a href="/mobile/contacts(\?page=\d+)">Next</a>""", re.S)
34         _contactDetailGroupRe   = re.compile(r"""Group:\s*(\w*)""", re.S)
35         _contactDetailPhoneRe   = re.compile(r"""(\w+):[0-9\-\(\) \t]*?<a href="/mobile/calls/click_to_call\?destno=(\d+).*?">call</a>""", re.S)
36
37         _validateRe = re.compile("^[0-9]{10,}$")
38
39         _forwardselectURL = "http://www.grandcentral.com/mobile/settings/forwarding_select"
40         _loginURL = "https://www.grandcentral.com/mobile/account/login"
41         _setforwardURL = "http://www.grandcentral.com/mobile/settings/set_forwarding?from=settings"
42         _clicktocallURL = "http://www.grandcentral.com/mobile/calls/click_to_call?a_t=%s&destno=%s"
43         _inboxallURL = "http://www.grandcentral.com/mobile/messages/inbox?types=all"
44         _contactsURL = "http://www.grandcentral.com/mobile/contacts"
45         _contactDetailURL = "http://www.grandcentral.com/mobile/contacts/detail"
46
47         def __init__(self, cookieFile = None):
48                 # Important items in this function are the setup of the browser emulation and cookie file
49                 self._msg = ""
50
51                 self._browser = MozillaEmulator(None, 0)
52                 if cookieFile is None:
53                         cookieFile = os.path.join(os.path.expanduser("~"), ".gc_dialer_cookies.txt")
54                 self._browser.cookies.filename = cookieFile
55                 if os.path.isfile(cookieFile):
56                         self._browser.cookies.load()
57
58                 self._accessToken = None
59                 self._accountNum = None
60                 self._callbackNumbers = {}
61                 self._lastAuthed = 0.0
62
63         def is_authed(self, force = False):
64                 """
65                 Attempts to detect a current session and pull the auth token ( a_t ) from the page.
66                 @note Once logged in try not to reauth more than once a minute.
67                 @returns If authenticated
68                 """
69
70                 if time.time() - self._lastAuthed < 60 and not force:
71                         return True
72
73                 try:
74                         forwardSelectionPage = self._browser.download(GCDialer._forwardselectURL)
75                 except urllib2.URLError, e:
76                         warnings.warn("%s is not accesible" % GCDialer._forwardselectURL, UserWarning, 2)
77                         return False
78
79                 self._browser.cookies.save()
80                 if GCDialer._isLoginPageRe.search(forwardSelectionPage) is None:
81                         self._grab_token(forwardSelectionPage)
82                         self._lastAuthed = time.time()
83                         return True
84
85                 return False
86
87         def login(self, username, password):
88                 """
89                 Attempt to login to grandcentral
90                 @returns Whether login was successful or not
91                 """
92                 if self.is_authed():
93                         return True
94
95                 loginPostData = urllib.urlencode( {'username' : username , 'password' : password } )
96
97                 try:
98                         loginSuccessOrFailurePage = self._browser.download(GCDialer._loginURL, loginPostData)
99                 except urllib2.URLError, e:
100                         warnings.warn("%s is not accesible" % GCDialer._loginURL, UserWarning, 2)
101                         return False
102
103                 return self.is_authed()
104
105         def dial(self, number):
106                 """
107                 This is the main function responsible for initating the callback
108                 """
109                 self._msg = ""
110
111                 # If the number is not valid throw exception
112                 if not self.is_valid_syntax(number):
113                         raise ValueError('number is not valid')
114
115                 # No point if we don't have the magic cookie
116                 if not self.is_authed():
117                         self._msg = "Not authenticated"
118                         return False
119
120                 # Strip leading 1 from 11 digit dialing
121                 if len(number) == 11 and number[0] == 1:
122                         number = number[1:]
123
124                 try:
125                         callSuccessPage = self._browser.download(
126                                 GCDialer._clicktocallURL % (self._accessToken, number),
127                                 None,
128                                 {'Referer' : 'http://www.grandcentral.com/mobile/messages'}
129                         )
130                 except urllib2.URLError, e:
131                         warnings.warn("%s is not accesible" % GCDialer._clicktocallURL, UserWarning, 2)
132                         return False
133
134                 if GCDialer._gcDialingStrRe.search(callSuccessPage) is not None:
135                         return True
136                 else:
137                         self._msg = "Grand Central returned an error"
138                         return False
139
140                 self._msg = "Unknown Error"
141                 return False
142
143         def clear_caches(self):
144                 pass
145
146         def reset(self):
147                 self._lastAuthed = 0.0
148                 self._browser.cookies.clear()
149                 self._browser.cookies.save()
150
151         def is_valid_syntax(self, number):
152                 """
153                 @returns If This number be called ( syntax validation only )
154                 """
155                 return self._validateRe.match(number) is not None
156
157         def get_account_number(self):
158                 """
159                 @returns The grand central phone number
160                 """
161                 return self._accountNum
162
163         def set_sane_callback(self):
164                 """
165                 Try to set a sane default callback number on these preferences
166                 1) 1747 numbers ( Gizmo )
167                 2) anything with gizmo in the name
168                 3) anything with computer in the name
169                 4) the first value
170                 """
171                 numbers = self.get_callback_numbers()
172
173                 for number, description in numbers.iteritems():
174                         if not re.compile(r"""1747""").match(number) is None:
175                                 self.set_callback_number(number)
176                                 return
177
178                 for number, description in numbers.iteritems():
179                         if not re.compile(r"""gizmo""", re.I).search(description) is None:
180                                 self.set_callback_number(number)
181                                 return
182
183                 for number, description in numbers.iteritems():
184                         if not re.compile(r"""computer""", re.I).search(description) is None:
185                                 self.set_callback_number(number)
186                                 return
187
188                 for number, description in numbers.iteritems():
189                         self.set_callback_number(number)
190                         return
191
192         def get_callback_numbers(self):
193                 """
194                 @returns a dictionary mapping call back numbers to descriptions
195                 @note These results are cached for 30 minutes.
196                 """
197                 if time.time() - self._lastAuthed < 1800 or self.is_authed():
198                         return self._callbackNumbers
199
200                 return {}
201
202         def set_callback_number(self, callbacknumber):
203                 """
204                 Set the number that grandcental calls
205                 @param callbacknumber should be a proper 10 digit number
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 get_callback_number(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 get_contacts(self):
244                 contactsPagesUrls = [GCDialer._contactsURL]
245                 for contactsPageUrl in contactsPagesUrls:
246                         contactsPage = self._browser.download(contactsPageUrl)
247                         for contact_match in self._contactsRe.finditer(contactsPage):
248                                 contactId = contact_match.group(1)
249                                 contactName = contact_match.group(2)
250                                 yield contactId, contactName
251
252                         next_match = self._contactsNextRe.match(contactsPage)
253                         if next_match is not None:
254                                 newContactsPageUrl = self._contactsURL + next_match.group(1)
255                                 contactsPagesUrls.append(newContactsPageUrl)
256         
257         def get_contact_details(self, contactId):
258                 detailPage = self._browser.download(GCDialer._contactDetailURL + '/' + contactId)
259                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
260                         phoneType = detail_match.group(1)
261                         phoneNumber = detail_match.group(2)
262                         yield (phoneType, phoneNumber)
263
264         def _grab_token(self, data):
265                 "Pull the magic cookie from the datastream"
266                 atGroup = GCDialer._accessTokenRe.search(data)
267                 self._accessToken = atGroup.group(1)
268
269                 anGroup = GCDialer._accountNumRe.search(data)
270                 self._accountNum = anGroup.group(1)
271
272                 self._callbackNumbers = {}
273                 for match in GCDialer._callbackRe.finditer(data):
274                         self._callbackNumbers[match.group(1)] = match.group(2)