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 def abbrev_relative_date(date):
103 >>> abbrev_relative_date("42 hours ago")
105 >>> abbrev_relative_date("2 days ago")
107 >>> abbrev_relative_date("4 weeks ago")
110 parts = date.split(" ")
111 return "%s %s" % (parts[0], parts[1][0])
114 class GVDialer(object):
116 This class encapsulates all of the knowledge necessary to interace with the grandcentral servers
117 the functions include login, setting up a callback number, and initalting a callback
120 def __init__(self, cookieFile = None):
121 # Important items in this function are the setup of the browser emulation and cookie file
122 self._browser = browser_emu.MozillaEmulator(1)
123 if cookieFile is None:
124 cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
125 self._browser.cookies.filename = cookieFile
126 if os.path.isfile(cookieFile):
127 self._browser.cookies.load()
130 self._accountNum = ""
131 self._lastAuthed = 0.0
132 self._callbackNumber = ""
133 self._callbackNumbers = {}
135 self.__contacts = None
137 def is_authed(self, force = False):
139 Attempts to detect a current session
140 @note Once logged in try not to reauth more than once a minute.
141 @returns If authenticated
144 if (time.time() - self._lastAuthed) < 120 and not force:
148 self._grab_account_info()
149 except StandardError, e:
150 warnings.warn(traceback.format_exc())
153 self._browser.cookies.save()
154 self._lastAuthed = time.time()
157 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
159 def login(self, username, password):
161 Attempt to login to grandcentral
162 @returns Whether login was successful or not
167 loginPostData = urllib.urlencode({
170 'service': "grandcentral",
173 "PersistentCookie": "yes",
177 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
178 except urllib2.URLError, e:
179 warnings.warn(traceback.format_exc())
180 raise RuntimeError("%s is not accesible" % self._loginURL)
182 return self.is_authed()
185 self._lastAuthed = 0.0
186 self._browser.cookies.clear()
187 self._browser.cookies.save()
191 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
192 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
194 def dial(self, number):
196 This is the main function responsible for initating the callback
198 number = self._send_validation(number)
200 clickToCallData = urllib.urlencode({
202 "phone": self._callbackNumber,
203 "_rnr_se": self._token,
206 'Referer' : 'https://google.com/voice/m/callsms',
208 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
209 except urllib2.URLError, e:
210 warnings.warn(traceback.format_exc())
211 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
213 if self._gvDialingStrRe.search(callSuccessPage) is None:
214 raise RuntimeError("Google Voice returned an error")
218 _sendSmsURL = "https://www.google.com/voice/m/sendsms"
220 def send_sms(self, number, message):
221 number = self._send_validation(number)
223 smsData = urllib.urlencode({
226 "_rnr_se": self._token,
231 'Referer' : 'https://google.com/voice/m/sms',
233 smsSuccessPage = self._browser.download(self._sendSmsURL, smsData, None, otherData)
234 except urllib2.URLError, e:
235 warnings.warn(traceback.format_exc())
236 raise RuntimeError("%s is not accesible" % self._sendSmsURL)
240 def clear_caches(self):
241 self.__contacts = None
243 _validateRe = re.compile("^[0-9]{10,}$")
245 def is_valid_syntax(self, number):
247 @returns If This number be called ( syntax validation only )
249 return self._validateRe.match(number) is not None
251 def get_account_number(self):
253 @returns The grand central phone number
255 return self._accountNum
257 def set_sane_callback(self):
259 Try to set a sane default callback number on these preferences
260 1) 1747 numbers ( Gizmo )
261 2) anything with gizmo in the name
262 3) anything with computer in the name
265 numbers = self.get_callback_numbers()
267 for number, description in numbers.iteritems():
268 if re.compile(r"""1747""").match(number) is not None:
269 self.set_callback_number(number)
272 for number, description in numbers.iteritems():
273 if re.compile(r"""gizmo""", re.I).search(description) is not None:
274 self.set_callback_number(number)
277 for number, description in numbers.iteritems():
278 if re.compile(r"""computer""", re.I).search(description) is not None:
279 self.set_callback_number(number)
282 for number, description in numbers.iteritems():
283 self.set_callback_number(number)
286 def get_callback_numbers(self):
288 @returns a dictionary mapping call back numbers to descriptions
289 @note These results are cached for 30 minutes.
291 if not self.is_authed():
293 return self._callbackNumbers
295 _setforwardURL = "https://www.google.com//voice/m/setphone"
297 def set_callback_number(self, callbacknumber):
299 Set the number that grandcental calls
300 @param callbacknumber should be a proper 10 digit number
302 self._callbackNumber = callbacknumber
304 # Currently this isn't working out in GoogleVoice, but thats ok, we pass the callback on dial
305 #callbackPostData = urllib.urlencode({
306 # '_rnr_se': self._token,
307 # 'phone': callbacknumber
310 # callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
311 # self._browser.cookies.save()
312 #except urllib2.URLError, e:
313 # warnings.warn(traceback.format_exc())
314 # raise RuntimeError("%s is not accesible" % self._setforwardURL)
318 def get_callback_number(self):
320 @returns Current callback number or None
322 #for c in self._browser.cookies:
323 # if c.name == "gv-ph":
325 return self._callbackNumber
327 def get_recent(self):
329 @returns Iterable of (personsName, phoneNumber, date, action)
332 (exactDate, name, number, relativeDate, action)
333 for (name, number, exactDate, relativeDate, action) in self._get_recent()
335 sortedRecent.sort(reverse = True)
336 for exactDate, name, number, relativeDate, action in sortedRecent:
337 relativeDate = abbrev_relative_date(relativeDate)
338 yield name, number, relativeDate, action
340 def get_addressbooks(self):
342 @returns Iterable of (Address Book Factory, Book Id, Book Name)
346 def open_addressbook(self, bookId):
350 def contact_source_short_name(contactId):
355 return "Google Voice"
357 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
358 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
359 _contactsURL = "https://www.google.com/voice/mobile/contacts"
361 def get_contacts(self):
363 @returns Iterable of (contact id, contact name)
365 if self.__contacts is None:
368 contactsPagesUrls = [self._contactsURL]
369 for contactsPageUrl in contactsPagesUrls:
371 contactsPage = self._browser.download(contactsPageUrl)
372 except urllib2.URLError, e:
373 warnings.warn(traceback.format_exc())
374 raise RuntimeError("%s is not accesible" % contactsPageUrl)
375 for contact_match in self._contactsRe.finditer(contactsPage):
376 contactId = contact_match.group(1)
377 contactName = saxutils.unescape(contact_match.group(2))
378 contact = contactId, contactName
379 self.__contacts.append(contact)
382 next_match = self._contactsNextRe.match(contactsPage)
383 if next_match is not None:
384 newContactsPageUrl = self._contactsURL + next_match.group(1)
385 contactsPagesUrls.append(newContactsPageUrl)
387 for contact in self.__contacts:
390 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9+\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
391 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
393 def get_contact_details(self, contactId):
395 @returns Iterable of (Phone Type, Phone Number)
398 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
399 except urllib2.URLError, e:
400 warnings.warn(traceback.format_exc())
401 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
403 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
404 phoneNumber = detail_match.group(1)
405 phoneType = saxutils.unescape(detail_match.group(2))
406 yield (phoneType, phoneNumber)
408 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
409 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
411 def get_messages(self):
413 voicemailPage = self._browser.download(self._voicemailURL)
414 except urllib2.URLError, e:
415 warnings.warn(traceback.format_exc())
416 raise RuntimeError("%s is not accesible" % self._voicemailURL)
417 voicemailHtml = self._grab_html(voicemailPage)
418 parsedVoicemail = self._parse_voicemail(voicemailHtml)
419 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
422 smsPage = self._browser.download(self._smsURL)
423 except urllib2.URLError, e:
424 warnings.warn(traceback.format_exc())
425 raise RuntimeError("%s is not accesible" % self._smsURL)
426 smsHtml = self._grab_html(smsPage)
427 parsedSms = self._parse_sms(smsHtml)
428 decoratedSms = self._decorate_sms(parsedSms)
430 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
431 sortedMessages = list(allMessages)
432 sortedMessages.sort(reverse=True)
433 for exactDate, header, number, relativeDate, message in sortedMessages:
434 relativeDate = abbrev_relative_date(relativeDate)
435 yield header, number, relativeDate, message
437 def _grab_json(self, flatXml):
438 xmlTree = ElementTree.fromstring(flatXml)
439 jsonElement = xmlTree.getchildren()[0]
440 flatJson = jsonElement.text
441 jsonTree = parse_json(flatJson)
444 def _grab_html(self, flatXml):
445 xmlTree = ElementTree.fromstring(flatXml)
446 htmlElement = xmlTree.getchildren()[1]
447 flatHtml = htmlElement.text
450 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
451 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
452 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
453 _forwardURL = "https://www.google.com/voice/mobile/phones"
455 def _grab_account_info(self):
456 page = self._browser.download(self._forwardURL)
458 tokenGroup = self._tokenRe.search(page)
459 if tokenGroup is None:
460 raise RuntimeError("Could not extract authentication token from GoogleVoice")
461 self._token = tokenGroup.group(1)
463 anGroup = self._accountNumRe.search(page)
464 if anGroup is not None:
465 self._accountNum = anGroup.group(1)
467 warnings.warn("Could not extract account number from GoogleVoice", UserWarning, 2)
469 self._callbackNumbers = {}
470 for match in self._callbackRe.finditer(page):
471 callbackNumber = match.group(2)
472 callbackName = match.group(1)
473 self._callbackNumbers[callbackNumber] = callbackName
475 def _send_validation(self, number):
476 if not self.is_valid_syntax(number):
477 raise ValueError('Number is not valid: "%s"' % number)
478 elif not self.is_authed():
479 raise RuntimeError("Not Authenticated")
481 if len(number) == 11 and number[0] == 1:
482 # Strip leading 1 from 11 digit dialing
486 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
487 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
488 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
489 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
491 def _get_recent(self):
493 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
496 ("Recieved", self._receivedCallsURL),
497 ("Missed", self._missedCallsURL),
498 ("Placed", self._placedCallsURL),
501 flatXml = self._browser.download(url)
502 except urllib2.URLError, e:
503 warnings.warn(traceback.format_exc())
504 raise RuntimeError("%s is not accesible" % url)
506 allRecentHtml = self._grab_html(flatXml)
507 allRecentData = self._parse_voicemail(allRecentHtml)
508 for recentCallData in allRecentData:
509 exactTime = recentCallData["time"]
510 if recentCallData["name"]:
511 header = recentCallData["name"]
512 elif recentCallData["prettyNumber"]:
513 header = recentCallData["prettyNumber"]
514 elif recentCallData["location"]:
515 header = recentCallData["location"]
518 yield header, recentCallData["number"], exactTime, recentCallData["relTime"], action
520 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
521 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
522 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
523 _voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
524 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
525 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
526 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
527 _voicemailMessageRegex = re.compile(r"""<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
529 def _parse_voicemail(self, voicemailHtml):
530 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
531 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
532 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
533 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
534 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
535 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
536 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
537 locationGroup = self._voicemailLocationRegex.search(messageHtml)
538 location = locationGroup.group(1).strip() if locationGroup else ""
540 nameGroup = self._voicemailNameRegex.search(messageHtml)
541 name = nameGroup.group(1).strip() if nameGroup else ""
542 numberGroup = self._voicemailNumberRegex.search(messageHtml)
543 number = numberGroup.group(1).strip() if numberGroup else ""
544 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
545 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
547 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
549 (group.group(1).strip(), group.group(2).strip())
550 for group in messageGroups
551 ) if messageGroups else ()
554 "id": messageId.strip(),
557 "relTime": relativeTime,
558 "prettyNumber": prettyNumber,
560 "location": location,
561 "messageParts": messageParts,
564 def _decorate_voicemail(self, parsedVoicemail):
565 messagePartFormat = {
570 for voicemailData in parsedVoicemail:
571 exactTime = voicemailData["time"]
572 if voicemailData["name"]:
573 header = voicemailData["name"]
574 elif voicemailData["prettyNumber"]:
575 header = voicemailData["prettyNumber"]
576 elif voicemailData["location"]:
577 header = voicemailData["location"]
581 messagePartFormat[quality] % part
582 for (quality, part) in voicemailData["messageParts"]
585 message = "No Transcription"
586 yield exactTime, header, voicemailData["number"], voicemailData["relTime"], message
588 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
589 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
590 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
592 def _parse_sms(self, smsHtml):
593 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
594 for messageId, messageHtml in itergroup(splitSms[1:], 2):
595 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
596 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
597 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
598 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
599 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
601 nameGroup = self._voicemailNameRegex.search(messageHtml)
602 name = nameGroup.group(1).strip() if nameGroup else ""
603 numberGroup = self._voicemailNumberRegex.search(messageHtml)
604 number = numberGroup.group(1).strip() if numberGroup else ""
605 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
606 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
608 fromGroups = self._smsFromRegex.finditer(messageHtml)
609 fromParts = (group.group(1).strip() for group in fromGroups)
610 textGroups = self._smsTextRegex.finditer(messageHtml)
611 textParts = (group.group(1).strip() for group in textGroups)
612 timeGroups = self._smsTimeRegex.finditer(messageHtml)
613 timeParts = (group.group(1).strip() for group in timeGroups)
615 messageParts = itertools.izip(fromParts, textParts, timeParts)
618 "id": messageId.strip(),
621 "relTime": relativeTime,
622 "prettyNumber": prettyNumber,
624 "messageParts": messageParts,
627 def _decorate_sms(self, parsedSms):
628 for messageData in parsedSms:
629 exactTime = messageData["time"]
630 if messageData["name"]:
631 header = messageData["name"]
632 elif messageData["prettyNumber"]:
633 header = messageData["prettyNumber"]
636 number = messageData["number"]
637 relativeTime = messageData["relTime"]
638 message = "\n".join((
639 "<b>%s</b>: %s" % (messagePart[0], messagePart[-1])
640 for messagePart in messageData["messageParts"]
643 message = "No Transcription"
644 yield exactTime, header, number, relativeTime, message
647 def test_backend(username, password):
650 print "Authenticated: ", backend.is_authed()
651 print "Login?: ", backend.login(username, password)
652 print "Authenticated: ", backend.is_authed()
653 # print "Token: ", backend._token
654 print "Account: ", backend.get_account_number()
655 print "Callback: ", backend.get_callback_number()
656 # print "All Callback: ",
657 # pprint.pprint(backend.get_callback_numbers())
659 # pprint.pprint(list(backend.get_recent()))
660 # print "Contacts: ",
661 # for contact in backend.get_contacts():
663 # pprint.pprint(list(backend.get_contact_details(contact[0])))