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
37 from xml.sax import saxutils
39 from xml.etree import ElementTree
49 _TRUE_REGEX = re.compile("true")
50 _FALSE_REGEX = re.compile("false")
54 s = _TRUE_REGEX.sub("True", s)
55 s = _FALSE_REGEX.sub("False", s)
56 return eval(s, {}, {})
59 if simplejson is None:
60 def parse_json(flattened):
61 return safe_eval(flattened)
63 def parse_json(flattened):
64 return simplejson.loads(flattened)
67 def itergroup(iterator, count, padValue = None):
69 Iterate in groups of 'count' values. If there
70 aren't enough values, the last result is padded with
73 >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
77 >>> for val in itergroup([1, 2, 3, 4, 5, 6], 3):
81 >>> for val in itergroup([1, 2, 3, 4, 5, 6, 7], 3):
86 >>> for val in itergroup("123456", 3):
90 >>> for val in itergroup("123456", 3):
91 ... print repr("".join(val))
95 paddedIterator = itertools.chain(iterator, itertools.repeat(padValue, count-1))
96 nIterators = (paddedIterator, ) * count
97 return itertools.izip(*nIterators)
100 class GVDialer(object):
102 This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
103 the functions include login, setting up a callback number, and initalting a callback
106 def __init__(self, cookieFile = None):
107 # Important items in this function are the setup of the browser emulation and cookie file
108 self._browser = browser_emu.MozillaEmulator(1)
109 if cookieFile is None:
110 cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
111 self._browser.cookies.filename = cookieFile
112 if os.path.isfile(cookieFile):
113 self._browser.cookies.load()
116 self._accountNum = ""
117 self._lastAuthed = 0.0
118 self._callbackNumber = ""
119 self._callbackNumbers = {}
121 self.__contacts = None
123 def is_authed(self, force = False):
125 Attempts to detect a current session
126 @note Once logged in try not to reauth more than once a minute.
127 @returns If authenticated
130 if (time.time() - self._lastAuthed) < 120 and not force:
134 self._grab_account_info()
136 logging.exception(str(e))
139 self._browser.cookies.save()
140 self._lastAuthed = time.time()
143 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
145 def login(self, username, password):
147 Attempt to login to grandcentral
148 @returns Whether login was successful or not
153 loginPostData = urllib.urlencode({
156 'service': "grandcentral",
159 "PersistentCookie": "yes",
163 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
164 except urllib2.URLError, e:
165 logging.exception(str(e))
166 raise RuntimeError("%s is not accesible" % self._loginURL)
168 return self.is_authed()
171 self._lastAuthed = 0.0
172 self._browser.cookies.clear()
173 self._browser.cookies.save()
177 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
178 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
180 def dial(self, number):
182 This is the main function responsible for initating the callback
184 number = self._send_validation(number)
186 clickToCallData = urllib.urlencode({
188 "phone": self._callbackNumber,
189 "_rnr_se": self._token,
192 'Referer' : 'https://google.com/voice/m/callsms',
194 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
195 except urllib2.URLError, e:
196 logging.exception(str(e))
197 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
199 if self._gvDialingStrRe.search(callSuccessPage) is None:
200 raise RuntimeError("Google Voice returned an error")
204 _sendSmsURL = "https://www.google.com/voice/m/sendsms"
206 def send_sms(self, number, message):
207 number = self._send_validation(number)
209 smsData = urllib.urlencode({
212 "_rnr_se": self._token,
217 'Referer' : 'https://google.com/voice/m/sms',
219 smsSuccessPage = self._browser.download(self._sendSmsURL, smsData, None, otherData)
220 except urllib2.URLError, e:
221 logging.exception(str(e))
222 raise RuntimeError("%s is not accesible" % self._sendSmsURL)
226 def clear_caches(self):
227 self.__contacts = None
229 _validateRe = re.compile("^[0-9]{10,}$")
231 def is_valid_syntax(self, number):
233 @returns If This number be called ( syntax validation only )
235 return self._validateRe.match(number) is not None
237 def get_account_number(self):
239 @returns The grand central phone number
241 return self._accountNum
243 def set_sane_callback(self):
245 Try to set a sane default callback number on these preferences
246 1) 1747 numbers ( Gizmo )
247 2) anything with gizmo in the name
248 3) anything with computer in the name
251 numbers = self.get_callback_numbers()
253 for number, description in numbers.iteritems():
254 if re.compile(r"""1747""").match(number) is not None:
255 self.set_callback_number(number)
258 for number, description in numbers.iteritems():
259 if re.compile(r"""gizmo""", re.I).search(description) is not None:
260 self.set_callback_number(number)
263 for number, description in numbers.iteritems():
264 if re.compile(r"""computer""", re.I).search(description) is not None:
265 self.set_callback_number(number)
268 for number, description in numbers.iteritems():
269 self.set_callback_number(number)
272 def get_callback_numbers(self):
274 @returns a dictionary mapping call back numbers to descriptions
275 @note These results are cached for 30 minutes.
277 if not self.is_authed():
279 return self._callbackNumbers
281 _setforwardURL = "https://www.google.com//voice/m/setphone"
283 def set_callback_number(self, callbacknumber):
285 Set the number that grandcental calls
286 @param callbacknumber should be a proper 10 digit number
288 self._callbackNumber = callbacknumber
291 def get_callback_number(self):
293 @returns Current callback number or None
295 return self._callbackNumber
297 def get_recent(self):
299 @returns Iterable of (personsName, phoneNumber, date, action)
302 (exactDate, name, number, relativeDate, action)
303 for (name, number, exactDate, relativeDate, action) in self._get_recent()
305 sortedRecent.sort(reverse = True)
306 for exactDate, name, number, relativeDate, action in sortedRecent:
307 yield name, number, relativeDate, action
309 def get_addressbooks(self):
311 @returns Iterable of (Address Book Factory, Book Id, Book Name)
315 def open_addressbook(self, bookId):
319 def contact_source_short_name(contactId):
324 return "Google Voice"
326 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
327 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
328 _contactsURL = "https://www.google.com/voice/mobile/contacts"
330 def get_contacts(self):
332 @returns Iterable of (contact id, contact name)
334 if self.__contacts is None:
337 contactsPagesUrls = [self._contactsURL]
338 for contactsPageUrl in contactsPagesUrls:
340 contactsPage = self._browser.download(contactsPageUrl)
341 except urllib2.URLError, e:
342 logging.exception(str(e))
343 raise RuntimeError("%s is not accesible" % contactsPageUrl)
344 for contact_match in self._contactsRe.finditer(contactsPage):
345 contactId = contact_match.group(1)
346 contactName = saxutils.unescape(contact_match.group(2))
347 contact = contactId, contactName
348 self.__contacts.append(contact)
351 next_match = self._contactsNextRe.match(contactsPage)
352 if next_match is not None:
353 newContactsPageUrl = self._contactsURL + next_match.group(1)
354 contactsPagesUrls.append(newContactsPageUrl)
356 for contact in self.__contacts:
359 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9+\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
360 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
362 def get_contact_details(self, contactId):
364 @returns Iterable of (Phone Type, Phone Number)
367 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
368 except urllib2.URLError, e:
369 logging.exception(str(e))
370 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
372 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
373 phoneNumber = detail_match.group(1)
374 phoneType = saxutils.unescape(detail_match.group(2))
375 yield (phoneType, phoneNumber)
377 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
378 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
380 def get_messages(self):
382 voicemailPage = self._browser.download(self._voicemailURL)
383 except urllib2.URLError, e:
384 logging.exception(str(e))
385 raise RuntimeError("%s is not accesible" % self._voicemailURL)
386 voicemailHtml = self._grab_html(voicemailPage)
387 parsedVoicemail = self._parse_voicemail(voicemailHtml)
388 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
391 smsPage = self._browser.download(self._smsURL)
392 except urllib2.URLError, e:
393 logging.exception(str(e))
394 raise RuntimeError("%s is not accesible" % self._smsURL)
395 smsHtml = self._grab_html(smsPage)
396 parsedSms = self._parse_sms(smsHtml)
397 decoratedSms = self._decorate_sms(parsedSms)
399 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
400 sortedMessages = list(allMessages)
401 sortedMessages.sort(reverse=True)
402 for exactDate, header, number, relativeDate, message in sortedMessages:
403 yield header, number, relativeDate, message
405 def _grab_json(self, flatXml):
406 xmlTree = ElementTree.fromstring(flatXml)
407 jsonElement = xmlTree.getchildren()[0]
408 flatJson = jsonElement.text
409 jsonTree = parse_json(flatJson)
412 def _grab_html(self, flatXml):
413 xmlTree = ElementTree.fromstring(flatXml)
414 htmlElement = xmlTree.getchildren()[1]
415 flatHtml = htmlElement.text
418 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
419 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
420 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
421 _forwardURL = "https://www.google.com/voice/mobile/phones"
423 def _grab_account_info(self):
424 page = self._browser.download(self._forwardURL)
426 tokenGroup = self._tokenRe.search(page)
427 if tokenGroup is None:
428 raise RuntimeError("Could not extract authentication token from GoogleVoice")
429 self._token = tokenGroup.group(1)
431 anGroup = self._accountNumRe.search(page)
432 if anGroup is not None:
433 self._accountNum = anGroup.group(1)
435 logging.debug("Could not extract account number from GoogleVoice")
437 self._callbackNumbers = {}
438 for match in self._callbackRe.finditer(page):
439 callbackNumber = match.group(2)
440 callbackName = match.group(1)
441 self._callbackNumbers[callbackNumber] = callbackName
443 def _send_validation(self, number):
444 if not self.is_valid_syntax(number):
445 raise ValueError('Number is not valid: "%s"' % number)
446 elif not self.is_authed():
447 raise RuntimeError("Not Authenticated")
449 if len(number) == 11 and number[0] == 1:
450 # Strip leading 1 from 11 digit dialing
454 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
455 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
456 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
457 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
459 def _get_recent(self):
461 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
464 ("Received", self._receivedCallsURL),
465 ("Missed", self._missedCallsURL),
466 ("Placed", self._placedCallsURL),
469 flatXml = self._browser.download(url)
470 except urllib2.URLError, e:
471 logging.exception(str(e))
472 raise RuntimeError("%s is not accesible" % url)
474 allRecentHtml = self._grab_html(flatXml)
475 allRecentData = self._parse_voicemail(allRecentHtml)
476 for recentCallData in allRecentData:
477 exactTime = recentCallData["time"]
478 if recentCallData["name"]:
479 header = recentCallData["name"]
480 elif recentCallData["prettyNumber"]:
481 header = recentCallData["prettyNumber"]
482 elif recentCallData["location"]:
483 header = recentCallData["location"]
486 yield header, recentCallData["number"], exactTime, recentCallData["relTime"], action
488 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
489 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
490 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
491 _voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
492 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
493 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
494 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
495 _voicemailMessageRegex = re.compile(r"""<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
497 def _parse_voicemail(self, voicemailHtml):
498 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
499 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
500 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
501 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
502 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
503 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
504 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
505 locationGroup = self._voicemailLocationRegex.search(messageHtml)
506 location = locationGroup.group(1).strip() if locationGroup else ""
508 nameGroup = self._voicemailNameRegex.search(messageHtml)
509 name = nameGroup.group(1).strip() if nameGroup else ""
510 numberGroup = self._voicemailNumberRegex.search(messageHtml)
511 number = numberGroup.group(1).strip() if numberGroup else ""
512 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
513 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
515 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
517 (group.group(1).strip(), group.group(2).strip())
518 for group in messageGroups
519 ) if messageGroups else ()
522 "id": messageId.strip(),
525 "relTime": relativeTime,
526 "prettyNumber": prettyNumber,
528 "location": location,
529 "messageParts": messageParts,
532 def _decorate_voicemail(self, parsedVoicemail):
533 messagePartFormat = {
538 for voicemailData in parsedVoicemail:
539 exactTime = voicemailData["time"]
540 if voicemailData["name"]:
541 header = voicemailData["name"]
542 elif voicemailData["prettyNumber"]:
543 header = voicemailData["prettyNumber"]
544 elif voicemailData["location"]:
545 header = voicemailData["location"]
549 messagePartFormat[quality] % part
550 for (quality, part) in voicemailData["messageParts"]
553 message = "No Transcription"
554 yield exactTime, header, voicemailData["number"], voicemailData["relTime"], message
556 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
557 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
558 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
560 def _parse_sms(self, smsHtml):
561 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
562 for messageId, messageHtml in itergroup(splitSms[1:], 2):
563 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
564 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
565 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
566 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
567 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
569 nameGroup = self._voicemailNameRegex.search(messageHtml)
570 name = nameGroup.group(1).strip() if nameGroup else ""
571 numberGroup = self._voicemailNumberRegex.search(messageHtml)
572 number = numberGroup.group(1).strip() if numberGroup else ""
573 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
574 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
576 fromGroups = self._smsFromRegex.finditer(messageHtml)
577 fromParts = (group.group(1).strip() for group in fromGroups)
578 textGroups = self._smsTextRegex.finditer(messageHtml)
579 textParts = (group.group(1).strip() for group in textGroups)
580 timeGroups = self._smsTimeRegex.finditer(messageHtml)
581 timeParts = (group.group(1).strip() for group in timeGroups)
583 messageParts = itertools.izip(fromParts, textParts, timeParts)
586 "id": messageId.strip(),
589 "relTime": relativeTime,
590 "prettyNumber": prettyNumber,
592 "messageParts": messageParts,
595 def _decorate_sms(self, parsedSms):
596 for messageData in parsedSms:
597 exactTime = messageData["time"]
598 if messageData["name"]:
599 header = messageData["name"]
600 elif messageData["prettyNumber"]:
601 header = messageData["prettyNumber"]
604 number = messageData["number"]
605 relativeTime = messageData["relTime"]
606 message = "\n".join((
607 "<b>%s</b>: %s" % (messagePart[0], messagePart[-1])
608 for messagePart in messageData["messageParts"]
611 message = "No Transcription"
612 yield exactTime, header, number, relativeTime, message
615 def test_backend(username, password):
617 print "Authenticated: ", backend.is_authed()
618 print "Login?: ", backend.login(username, password)
619 print "Authenticated: ", backend.is_authed()
620 # print "Token: ", backend._token
621 print "Account: ", backend.get_account_number()
622 print "Callback: ", backend.get_callback_number()
623 # print "All Callback: ",
625 # pprint.pprint(backend.get_callback_numbers())
627 # pprint.pprint(list(backend.get_recent()))
628 # print "Contacts: ",
629 # for contact in backend.get_contacts():
631 # pprint.pprint(list(backend.get_contact_details(contact[0])))