4 DialCentral - Front end for Google's Grand Central service.
5 Copyright (C) 2008 Eric Warnke ericew AT gmail DOT com
7 This library is free software; you can redistribute it and/or
8 modify it under the terms of the GNU Lesser General Public
9 License as published by the Free Software Foundation; either
10 version 2.1 of the License, or (at your option) any later version.
12 This library is distributed in the hope that it will be useful,
13 but WITHOUT ANY WARRANTY; without even the implied warranty of
14 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15 Lesser General Public License for more details.
17 You should have received a copy of the GNU Lesser General Public
18 License along with this library; if not, write to the Free Software
19 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
21 Google Voice backend code
24 http://thatsmith.com/2009/03/google-voice-addon-for-firefox/
25 http://posttopic.com/topic/google-voice-add-on-development
38 from xml.sax import saxutils
40 from xml.etree import ElementTree
50 _TRUE_REGEX = re.compile("true")
51 _FALSE_REGEX = re.compile("false")
55 s = _TRUE_REGEX.sub("True", s)
56 s = _FALSE_REGEX.sub("False", s)
57 return eval(s, {}, {})
60 if simplejson is None:
61 def parse_json(flattened):
62 return safe_eval(flattened)
64 def parse_json(flattened):
65 return simplejson.loads(flattened)
68 def itergroup(iterator, count, padValue = None):
70 Iterate in groups of 'count' values. If there
71 aren't enough values, the last result is padded with
74 >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
78 >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
82 >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3):
87 >>> for val in itergroup("123456", 3):
91 >>> for val in itergroup("123456", 3):
92 ... print repr("".join(val))
96 paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1))
97 nIterators = (paddedIterator, ) * count
98 return itertools.izip(*nIterators)
101 class GVDialer(object):
103 This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
104 the functions include login, setting up a callback number, and initalting a callback
107 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
108 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
109 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
110 _validateRe = re.compile("^[0-9]{10,}$")
111 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
113 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
114 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
115 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
117 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
118 _smsURL = "https://www.google.com/voice/m/sendsms"
119 _contactsURL = "https://www.google.com/voice/mobile/contacts"
120 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
122 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
123 _setforwardURL = "https://www.google.com//voice/m/setphone"
124 _accountNumberURL = "https://www.google.com/voice/mobile"
125 _forwardURL = "https://www.google.com/voice/mobile/phones"
127 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
128 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
129 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
130 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
131 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
132 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
134 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class="gc-message.*?">""", re.MULTILINE | re.DOTALL)
135 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
136 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
137 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
138 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
139 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">(.*?)</span>""", re.MULTILINE)
140 _voicemailMessageRegex = re.compile(r"""<span class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
142 def __init__(self, cookieFile = None):
143 # Important items in this function are the setup of the browser emulation and cookie file
144 self._browser = browser_emu.MozillaEmulator(1)
145 if cookieFile is None:
146 cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
147 self._browser.cookies.filename = cookieFile
148 if os.path.isfile(cookieFile):
149 self._browser.cookies.load()
152 self._accountNum = None
153 self._lastAuthed = 0.0
154 self._callbackNumber = ""
155 self._callbackNumbers = {}
157 self.__contacts = None
159 def is_authed(self, force = False):
161 Attempts to detect a current session
162 @note Once logged in try not to reauth more than once a minute.
163 @returns If authenticated
166 if (time.time() - self._lastAuthed) < 60 and not force:
170 self._grab_account_info()
171 except StandardError, e:
172 warnings.warn(traceback.format_exc())
175 self._browser.cookies.save()
176 self._lastAuthed = time.time()
179 def login(self, username, password):
181 Attempt to login to grandcentral
182 @returns Whether login was successful or not
187 loginPostData = urllib.urlencode({
190 'service': "grandcentral",
193 "PersistentCookie": "yes",
197 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
198 except urllib2.URLError, e:
199 warnings.warn(traceback.format_exc())
200 raise RuntimeError("%s is not accesible" % self._loginURL)
202 return self.is_authed()
205 self._lastAuthed = 0.0
206 self._browser.cookies.clear()
207 self._browser.cookies.save()
211 def dial(self, number):
213 This is the main function responsible for initating the callback
215 number = self._send_validation(number)
217 clickToCallData = urllib.urlencode({
219 "phone": self._callbackNumber,
220 "_rnr_se": self._token,
223 'Referer' : 'https://google.com/voice/m/callsms',
225 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
226 except urllib2.URLError, e:
227 warnings.warn(traceback.format_exc())
228 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
230 if self._gvDialingStrRe.search(callSuccessPage) is None:
231 raise RuntimeError("Google Voice returned an error")
235 def send_sms(self, number, message):
236 number = self._send_validation(number)
238 smsData = urllib.urlencode({
241 "_rnr_se": self._token,
246 'Referer' : 'https://google.com/voice/m/sms',
248 smsSuccessPage = self._browser.download(self._smsURL, smsData, None, otherData)
249 except urllib2.URLError, e:
250 warnings.warn(traceback.format_exc())
251 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
255 def clear_caches(self):
256 self.__contacts = None
258 def is_valid_syntax(self, number):
260 @returns If This number be called ( syntax validation only )
262 return self._validateRe.match(number) is not None
264 def get_account_number(self):
266 @returns The grand central phone number
268 return self._accountNum
270 def set_sane_callback(self):
272 Try to set a sane default callback number on these preferences
273 1) 1747 numbers ( Gizmo )
274 2) anything with gizmo in the name
275 3) anything with computer in the name
278 numbers = self.get_callback_numbers()
280 for number, description in numbers.iteritems():
281 if re.compile(r"""1747""").match(number) is not None:
282 self.set_callback_number(number)
285 for number, description in numbers.iteritems():
286 if re.compile(r"""gizmo""", re.I).search(description) is not None:
287 self.set_callback_number(number)
290 for number, description in numbers.iteritems():
291 if re.compile(r"""computer""", re.I).search(description) is not None:
292 self.set_callback_number(number)
295 for number, description in numbers.iteritems():
296 self.set_callback_number(number)
299 def get_callback_numbers(self):
301 @returns a dictionary mapping call back numbers to descriptions
302 @note These results are cached for 30 minutes.
304 if time.time() - self._lastAuthed < 1800 or self.is_authed():
305 return self._callbackNumbers
309 def set_callback_number(self, callbacknumber):
311 Set the number that grandcental calls
312 @param callbacknumber should be a proper 10 digit number
314 self._callbackNumber = callbacknumber
315 callbackPostData = urllib.urlencode({
316 '_rnr_se': self._token,
317 'phone': callbacknumber
320 callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
321 except urllib2.URLError, e:
322 warnings.warn(traceback.format_exc())
323 raise RuntimeError("%s is not accesible" % self._setforwardURL)
325 # @bug This does not seem to be keeping on my tablet (but works on the
326 # desktop), or the reading isn't working too well
327 self._browser.cookies.save()
330 def get_callback_number(self):
332 @returns Current callback number or None
334 for c in self._browser.cookies:
335 if c.name == "gv-ph":
337 return self._callbackNumber
339 def get_recent(self):
341 @todo Sort this stuff
342 @returns Iterable of (personsName, phoneNumber, date, action)
345 (exactDate, name, number, relativeDate, action)
346 for (name, number, exactDate, relativeDate, action) in self._get_recent()
348 sortedRecent.sort(reverse = True)
349 for exactDate, name, number, relativeDate, action in sortedRecent:
350 yield name, number, relativeDate, action
352 def get_addressbooks(self):
354 @returns Iterable of (Address Book Factory, Book Id, Book Name)
358 def open_addressbook(self, bookId):
362 def contact_source_short_name(contactId):
367 return "Google Voice"
369 def get_contacts(self):
371 @returns Iterable of (contact id, contact name)
373 if self.__contacts is None:
376 contactsPagesUrls = [self._contactsURL]
377 for contactsPageUrl in contactsPagesUrls:
379 contactsPage = self._browser.download(contactsPageUrl)
380 except urllib2.URLError, e:
381 warnings.warn(traceback.format_exc())
382 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
383 for contact_match in self._contactsRe.finditer(contactsPage):
384 contactId = contact_match.group(1)
385 contactName = saxutils.unescape(contact_match.group(2))
386 contact = contactId, contactName
387 self.__contacts.append(contact)
390 next_match = self._contactsNextRe.match(contactsPage)
391 if next_match is not None:
392 newContactsPageUrl = self._contactsURL + next_match.group(1)
393 contactsPagesUrls.append(newContactsPageUrl)
395 for contact in self.__contacts:
398 def get_contact_details(self, contactId):
400 @returns Iterable of (Phone Type, Phone Number)
403 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
404 except urllib2.URLError, e:
405 warnings.warn(traceback.format_exc())
406 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
408 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
409 phoneNumber = detail_match.group(1)
410 phoneType = saxutils.unescape(detail_match.group(2))
411 yield (phoneType, phoneNumber)
413 def get_messages(self):
415 voicemailPage = self._browser.download(self._voicemailURL)
416 except urllib2.URLError, e:
417 warnings.warn(traceback.format_exc())
418 raise RuntimeError("%s is not accesible" % self._voicemailURL)
421 smsPage = self._browser.download(self._smsURL)
422 except urllib2.URLError, e:
423 warnings.warn(traceback.format_exc())
424 raise RuntimeError("%s is not accesible" % self._smsURL)
426 voicemailHtml = self._grab_html(voicemailPage)
427 parsedVoicemail = self._parse_voicemail(voicemailHtml)
428 decoratedVoicemails = self._decorated_voicemail(parsedVoicemail)
431 # smsHtml = self._grab_html(smsPage)
433 allMessages = itertools.chain(decoratedVoicemails)
434 sortedMessages = list(allMessages)
435 for exactDate, header, number, relativeDate, message in sortedMessages:
436 yield header, number, relativeDate, message
438 def _grab_json(self, flatXml):
439 xmlTree = ElementTree.fromstring(flatXml)
440 jsonElement = xmlTree.getchildren()[0]
441 flatJson = jsonElement.text
442 jsonTree = parse_json(flatJson)
445 def _grab_html(self, flatXml):
446 xmlTree = ElementTree.fromstring(flatXml)
447 htmlElement = xmlTree.getchildren()[1]
448 flatHtml = htmlElement.text
451 def _grab_account_info(self):
452 page = self._browser.download(self._forwardURL)
454 tokenGroup = self._tokenRe.search(page)
455 if tokenGroup is None:
456 raise RuntimeError("Could not extract authentication token from GoogleVoice")
457 self._token = tokenGroup.group(1)
459 anGroup = self._accountNumRe.search(page)
461 raise RuntimeError("Could not extract account number from GoogleVoice")
462 self._accountNum = anGroup.group(1)
464 self._callbackNumbers = {}
465 for match in self._callbackRe.finditer(page):
466 callbackNumber = match.group(2)
467 callbackName = match.group(1)
468 self._callbackNumbers[callbackNumber] = callbackName
470 def _send_validation(self, number):
471 if not self.is_valid_syntax(number):
472 raise ValueError('Number is not valid: "%s"' % number)
473 elif not self.is_authed():
474 raise RuntimeError("Not Authenticated")
476 if len(number) == 11 and number[0] == 1:
477 # Strip leading 1 from 11 digit dialing
481 def _get_recent(self):
483 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
486 self._receivedCallsURL,
487 self._missedCallsURL,
488 self._placedCallsURL,
491 flatXml = self._browser.download(url)
492 except urllib2.URLError, e:
493 warnings.warn(traceback.format_exc())
494 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
496 allRecentData = self._grab_json(flatXml)
497 for recentCallData in allRecentData["messages"].itervalues():
498 number = recentCallData["displayNumber"]
499 exactDate = recentCallData["displayStartDateTime"]
500 relativeDate = recentCallData["relativeStartTime"]
503 for label in recentCallData["labels"]
504 if label.lower() != "all" and label.lower() != "inbox"
506 number = saxutils.unescape(number)
507 exactDate = saxutils.unescape(exactDate)
508 exactDate = datetime.datetime.strptime(exactDate, "%m/%d/%y %I:%M %p")
509 relativeDate = saxutils.unescape(relativeDate)
510 action = saxutils.unescape(action)
511 yield "", number, exactDate, relativeDate, action
513 def _parse_voicemail(self, voicemailHtml):
514 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
515 for id, messageHtml in itergroup(splitVoicemail[1:], 2):
516 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
517 exactTime = exactTimeGroup.group(1) if exactTimeGroup else ""
518 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
519 relativeTime = relativeTimeGroup.group(1) if relativeTimeGroup else ""
520 locationGroup = self._voicemailLocationRegex.search(messageHtml)
521 location = locationGroup.group(1) if locationGroup else ""
522 numberGroup = self._voicemailNumberRegex.search(messageHtml)
523 number = numberGroup.group(1) if numberGroup else ""
524 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
525 prettyNumber = prettyNumberGroup.group(1) if prettyNumberGroup else ""
526 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
528 (group.group(1), group.group(2))
529 for group in messageGroups
530 ) if messageGroups else ()
534 "relTime": relativeTime,
535 "prettyNumber": prettyNumber,
537 "location": location,
538 "messageParts": messageParts,
541 def _decorated_voicemail(self, parsedVoicemail):
542 messagePartFormat = {
547 for voicemailData in parsedVoicemail:
548 exactTime = voicemailData["time"] # @todo Parse This
549 header = "%s %s" % (voicemailData["prettyNumber"], voicemailData["location"])
551 messagePartFormat[quality] % part
552 for (quality, part) in voicemailData["messageParts"]
555 message = "No Transcription"
556 yield exactTime, header, voicemailData["number"], voicemailData["relTime"], message
559 def test_backend(username, password):
562 print "Authenticated: ", backend.is_authed()
563 print "Login?: ", backend.login(username, password)
564 print "Authenticated: ", backend.is_authed()
565 print "Token: ", backend._token
566 print "Account: ", backend.get_account_number()
567 print "Callback: ", backend.get_callback_number()
568 # print "All Callback: ",
569 # pprint.pprint(backend.get_callback_numbers())
571 # pprint.pprint(list(backend.get_recent()))
572 # print "Contacts: ",
573 # for contact in backend.get_contacts():
575 # pprint.pprint(list(backend.get_contact_details(contact[0])))