Added a safety in case there was image loading issues
[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)
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 logout(self):
106                 self._lastAuthed = 0.0
107                 self._browser.cookies.clear()
108                 self._browser.cookies.save()
109
110         def dial(self, number):
111                 """
112                 This is the main function responsible for initating the callback
113                 """
114                 self._msg = ""
115
116                 # If the number is not valid throw exception
117                 if not self.is_valid_syntax(number):
118                         raise ValueError('number is not valid')
119
120                 # No point if we don't have the magic cookie
121                 if not self.is_authed():
122                         self._msg = "Not authenticated"
123                         return False
124
125                 # Strip leading 1 from 11 digit dialing
126                 if len(number) == 11 and number[0] == 1:
127                         number = number[1:]
128
129                 try:
130                         callSuccessPage = self._browser.download(
131                                 GCDialer._clicktocallURL % (self._accessToken, number),
132                                 None,
133                                 {'Referer' : 'http://www.grandcentral.com/mobile/messages'}
134                         )
135                 except urllib2.URLError, e:
136                         warnings.warn("%s is not accesible" % GCDialer._clicktocallURL, UserWarning, 2)
137                         return False
138
139                 if GCDialer._gcDialingStrRe.search(callSuccessPage) is not None:
140                         return True
141                 else:
142                         self._msg = "Grand Central returned an error"
143                         return False
144
145                 self._msg = "Unknown Error"
146                 return False
147
148         def clear_caches(self):
149                 pass
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({
208                         'a_t': self._accessToken,
209                         'default_number': callbacknumber
210                 })
211                 try:
212                         callbackSetPage = self._browser.download(GCDialer._setforwardURL, callbackPostData)
213                 except urllib2.URLError, e:
214                         warnings.warn("%s is not accesible" % GCDialer._setforwardURL, UserWarning, 2)
215                         return False
216
217                 self._browser.cookies.save()
218                 return True
219
220         def get_callback_number(self):
221                 """
222                 @returns Current callback number or None
223                 """
224                 for c in self._browser.cookies:
225                         if c.name == "pda_forwarding_number":
226                                 return c.value
227                 return None
228
229         def get_recent(self):
230                 """
231                 @returns Iterable of (personsName, phoneNumber, date, action)
232                 """
233                 try:
234                         recentCallsPage = self._browser.download(GCDialer._inboxallURL)
235                 except urllib2.URLError, e:
236                         warnings.warn("%s is not accesible" % GCDialer._inboxallURL, UserWarning, 2)
237                         return
238
239                 for match in self._inboxRe.finditer(recentCallsPage):
240                         phoneNumber = match.group(4)
241                         action = match.group(1)
242                         date = match.group(2)
243                         personsName = match.group(3)
244                         yield personsName, phoneNumber, date, action
245
246         def get_contacts(self):
247                 """
248                 @returns Iterable of (contact id, contact name)
249                 """
250                 contactsPagesUrls = [GCDialer._contactsURL]
251                 for contactsPageUrl in contactsPagesUrls:
252                         contactsPage = self._browser.download(contactsPageUrl)
253                         for contact_match in self._contactsRe.finditer(contactsPage):
254                                 contactId = contact_match.group(1)
255                                 contactName = contact_match.group(2)
256                                 yield contactId, contactName
257
258                         next_match = self._contactsNextRe.match(contactsPage)
259                         if next_match is not None:
260                                 newContactsPageUrl = self._contactsURL + next_match.group(1)
261                                 contactsPagesUrls.append(newContactsPageUrl)
262         
263         def get_contact_details(self, contactId):
264                 """
265                 @returns Iterable of (Phone Type, Phone Number)
266                 """
267                 detailPage = self._browser.download(GCDialer._contactDetailURL + '/' + contactId)
268                 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
269                         phoneType = detail_match.group(1)
270                         phoneNumber = detail_match.group(2)
271                         yield (phoneType, phoneNumber)
272
273         def _grab_token(self, data):
274                 "Pull the magic cookie from the datastream"
275                 atGroup = GCDialer._accessTokenRe.search(data)
276                 self._accessToken = atGroup.group(1)
277
278                 anGroup = GCDialer._accountNumRe.search(data)
279                 self._accountNum = anGroup.group(1)
280
281                 self._callbackNumbers = {}
282                 for match in GCDialer._callbackRe.finditer(data):
283                         self._callbackNumbers[match.group(1)] = match.group(2)