4 DialCentral - Front end for Google's GoogleVoice 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 _moduleLogger = logging.getLogger("gv_backend")
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 interact with the GoogleVoice servers
104 the functions include login, setting up a callback number, and initalting a callback
107 def __init__(self, cookieFile = None):
108 # Important items in this function are the setup of the browser emulation and cookie file
109 self._browser = browser_emu.MozillaEmulator(1)
110 if cookieFile is None:
111 cookieFile = os.path.join(os.path.expanduser("~"), ".gv_cookies.txt")
112 self._browser.cookies.filename = cookieFile
113 if os.path.isfile(cookieFile):
114 self._browser.cookies.load()
117 self._accountNum = ""
118 self._lastAuthed = 0.0
119 self._callbackNumber = ""
120 self._callbackNumbers = {}
122 self.__contacts = None
124 def is_authed(self, force = False):
126 Attempts to detect a current session
127 @note Once logged in try not to reauth more than once a minute.
128 @returns If authenticated
131 if (time.time() - self._lastAuthed) < 120 and not force:
135 self._grab_account_info()
137 _moduleLogger.exception(str(e))
140 self._browser.cookies.save()
141 self._lastAuthed = time.time()
144 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
146 def login(self, username, password):
148 Attempt to login to GoogleVoice
149 @returns Whether login was successful or not
154 loginPostData = urllib.urlencode({
157 'service': "grandcentral",
160 "PersistentCookie": "yes",
164 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
165 except urllib2.URLError, e:
166 _moduleLogger.exception(str(e))
167 raise RuntimeError("%s is not accesible" % self._loginURL)
169 return self.is_authed()
172 self._lastAuthed = 0.0
173 self._browser.cookies.clear()
174 self._browser.cookies.save()
178 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
179 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
181 def dial(self, number):
183 This is the main function responsible for initating the callback
185 number = self._send_validation(number)
187 clickToCallData = urllib.urlencode({
189 "phone": self._callbackNumber,
190 "_rnr_se": self._token,
193 'Referer' : 'https://google.com/voice/m/callsms',
195 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
196 except urllib2.URLError, e:
197 _moduleLogger.exception(str(e))
198 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
200 if self._gvDialingStrRe.search(callSuccessPage) is None:
201 raise RuntimeError("Google Voice returned an error")
205 _sendSmsURL = "https://www.google.com/voice/m/sendsms"
207 def send_sms(self, number, message):
208 number = self._send_validation(number)
210 smsData = urllib.urlencode({
213 "_rnr_se": self._token,
218 'Referer' : 'https://google.com/voice/m/sms',
220 smsSuccessPage = self._browser.download(self._sendSmsURL, smsData, None, otherData)
221 except urllib2.URLError, e:
222 _moduleLogger.exception(str(e))
223 raise RuntimeError("%s is not accesible" % self._sendSmsURL)
227 def clear_caches(self):
228 self.__contacts = None
230 _validateRe = re.compile("^[0-9]{10,}$")
232 def is_valid_syntax(self, number):
234 @returns If This number be called ( syntax validation only )
236 return self._validateRe.match(number) is not None
238 def get_account_number(self):
240 @returns The GoogleVoice phone number
242 return self._accountNum
244 def set_sane_callback(self):
246 Try to set a sane default callback number on these preferences
247 1) 1747 numbers ( Gizmo )
248 2) anything with gizmo in the name
249 3) anything with computer in the name
252 numbers = self.get_callback_numbers()
254 for number, description in numbers.iteritems():
255 if re.compile(r"""1747""").match(number) is not None:
256 self.set_callback_number(number)
259 for number, description in numbers.iteritems():
260 if re.compile(r"""gizmo""", re.I).search(description) is not None:
261 self.set_callback_number(number)
264 for number, description in numbers.iteritems():
265 if re.compile(r"""computer""", re.I).search(description) is not None:
266 self.set_callback_number(number)
269 for number, description in numbers.iteritems():
270 self.set_callback_number(number)
273 def get_callback_numbers(self):
275 @returns a dictionary mapping call back numbers to descriptions
276 @note These results are cached for 30 minutes.
278 if not self.is_authed():
280 return self._callbackNumbers
282 _setforwardURL = "https://www.google.com//voice/m/setphone"
284 def set_callback_number(self, callbacknumber):
286 Set the number that GoogleVoice calls
287 @param callbacknumber should be a proper 10 digit number
289 self._callbackNumber = callbacknumber
292 def get_callback_number(self):
294 @returns Current callback number or None
296 return self._callbackNumber
298 def get_recent(self):
300 @returns Iterable of (personsName, phoneNumber, date, action)
303 (exactDate, name, number, relativeDate, action)
304 for (name, number, exactDate, relativeDate, action) in self._get_recent()
306 sortedRecent.sort(reverse = True)
307 for exactDate, name, number, relativeDate, action in sortedRecent:
308 yield name, number, relativeDate, action
310 def get_addressbooks(self):
312 @returns Iterable of (Address Book Factory, Book Id, Book Name)
316 def open_addressbook(self, bookId):
320 def contact_source_short_name(contactId):
325 return "Google Voice"
327 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
328 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
329 _contactsURL = "https://www.google.com/voice/mobile/contacts"
331 def get_contacts(self):
333 @returns Iterable of (contact id, contact name)
335 if self.__contacts is None:
338 contactsPagesUrls = [self._contactsURL]
339 for contactsPageUrl in contactsPagesUrls:
341 contactsPage = self._browser.download(contactsPageUrl)
342 except urllib2.URLError, e:
343 _moduleLogger.exception(str(e))
344 raise RuntimeError("%s is not accesible" % contactsPageUrl)
345 for contact_match in self._contactsRe.finditer(contactsPage):
346 contactId = contact_match.group(1)
347 contactName = saxutils.unescape(contact_match.group(2))
348 contact = contactId, contactName
349 self.__contacts.append(contact)
352 next_match = self._contactsNextRe.match(contactsPage)
353 if next_match is not None:
354 newContactsPageUrl = self._contactsURL + next_match.group(1)
355 contactsPagesUrls.append(newContactsPageUrl)
357 for contact in self.__contacts:
360 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9+\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
361 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
363 def get_contact_details(self, contactId):
365 @returns Iterable of (Phone Type, Phone Number)
368 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
369 except urllib2.URLError, e:
370 _moduleLogger.exception(str(e))
371 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
373 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
374 phoneNumber = detail_match.group(1)
375 phoneType = saxutils.unescape(detail_match.group(2))
376 yield (phoneType, phoneNumber)
378 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
379 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
381 def get_messages(self):
383 voicemailPage = self._browser.download(self._voicemailURL)
384 except urllib2.URLError, e:
385 _moduleLogger.exception(str(e))
386 raise RuntimeError("%s is not accesible" % self._voicemailURL)
387 voicemailHtml = self._grab_html(voicemailPage)
388 parsedVoicemail = self._parse_voicemail(voicemailHtml)
389 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
392 smsPage = self._browser.download(self._smsURL)
393 except urllib2.URLError, e:
394 _moduleLogger.exception(str(e))
395 raise RuntimeError("%s is not accesible" % self._smsURL)
396 smsHtml = self._grab_html(smsPage)
397 parsedSms = self._parse_sms(smsHtml)
398 decoratedSms = self._decorate_sms(parsedSms)
400 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
401 sortedMessages = list(allMessages)
402 sortedMessages.sort(reverse=True)
403 for exactDate, header, number, relativeDate, message in sortedMessages:
404 yield header, number, relativeDate, message
406 def _grab_json(self, flatXml):
407 xmlTree = ElementTree.fromstring(flatXml)
408 jsonElement = xmlTree.getchildren()[0]
409 flatJson = jsonElement.text
410 jsonTree = parse_json(flatJson)
413 def _grab_html(self, flatXml):
414 xmlTree = ElementTree.fromstring(flatXml)
415 htmlElement = xmlTree.getchildren()[1]
416 flatHtml = htmlElement.text
419 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
420 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
421 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
422 _forwardURL = "https://www.google.com/voice/mobile/phones"
424 def _grab_account_info(self):
425 page = self._browser.download(self._forwardURL)
427 tokenGroup = self._tokenRe.search(page)
428 if tokenGroup is None:
429 raise RuntimeError("Could not extract authentication token from GoogleVoice")
430 self._token = tokenGroup.group(1)
432 anGroup = self._accountNumRe.search(page)
433 if anGroup is not None:
434 self._accountNum = anGroup.group(1)
436 _moduleLogger.debug("Could not extract account number from GoogleVoice")
438 self._callbackNumbers = {}
439 for match in self._callbackRe.finditer(page):
440 callbackNumber = match.group(2)
441 callbackName = match.group(1)
442 self._callbackNumbers[callbackNumber] = callbackName
444 def _send_validation(self, number):
445 if not self.is_valid_syntax(number):
446 raise ValueError('Number is not valid: "%s"' % number)
447 elif not self.is_authed():
448 raise RuntimeError("Not Authenticated")
450 if len(number) == 11 and number[0] == 1:
451 # Strip leading 1 from 11 digit dialing
455 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
456 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
457 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
458 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
460 def _get_recent(self):
462 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
465 ("Received", self._receivedCallsURL),
466 ("Missed", self._missedCallsURL),
467 ("Placed", self._placedCallsURL),
470 flatXml = self._browser.download(url)
471 except urllib2.URLError, e:
472 _moduleLogger.exception(str(e))
473 raise RuntimeError("%s is not accesible" % url)
475 allRecentHtml = self._grab_html(flatXml)
476 allRecentData = self._parse_voicemail(allRecentHtml)
477 for recentCallData in allRecentData:
478 exactTime = recentCallData["time"]
479 if recentCallData["name"]:
480 header = recentCallData["name"]
481 elif recentCallData["prettyNumber"]:
482 header = recentCallData["prettyNumber"]
483 elif recentCallData["location"]:
484 header = recentCallData["location"]
487 yield header, recentCallData["number"], exactTime, recentCallData["relTime"], action
489 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
490 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
491 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
492 _voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
493 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
494 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
495 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
496 #_voicemailMessageRegex = re.compile(r"""<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
497 #_voicemailMessageRegex = re.compile(r"""<a .*? class="gc-message-mni">(.*?)</a>""", re.MULTILINE)
498 _voicemailMessageRegex = re.compile(r"""(<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>|<a .*? class="gc-message-mni">(.*?)</a>)""", re.MULTILINE)
501 def _interpret_voicemail_regex(group):
502 quality, content, number = group.group(2), group.group(3), group.group(4)
503 if quality is not None and content is not None:
504 return quality, content
505 elif number is not None:
506 return "high", number
508 def _parse_voicemail(self, voicemailHtml):
509 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
510 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
511 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
512 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
513 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
514 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
515 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
516 locationGroup = self._voicemailLocationRegex.search(messageHtml)
517 location = locationGroup.group(1).strip() if locationGroup else ""
519 nameGroup = self._voicemailNameRegex.search(messageHtml)
520 name = nameGroup.group(1).strip() if nameGroup else ""
521 numberGroup = self._voicemailNumberRegex.search(messageHtml)
522 number = numberGroup.group(1).strip() if numberGroup else ""
523 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
524 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
526 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
528 self._interpret_voicemail_regex(group)
529 for group in messageGroups
530 ) if messageGroups else ()
533 "id": messageId.strip(),
536 "relTime": relativeTime,
537 "prettyNumber": prettyNumber,
539 "location": location,
540 "messageParts": messageParts,
543 def _decorate_voicemail(self, parsedVoicemail):
544 messagePartFormat = {
549 for voicemailData in parsedVoicemail:
550 exactTime = voicemailData["time"]
551 if voicemailData["name"]:
552 header = voicemailData["name"]
553 elif voicemailData["prettyNumber"]:
554 header = voicemailData["prettyNumber"]
555 elif voicemailData["location"]:
556 header = voicemailData["location"]
560 messagePartFormat[quality] % part
561 for (quality, part) in voicemailData["messageParts"]
564 message = "No Transcription"
565 yield exactTime, header, voicemailData["number"], voicemailData["relTime"], (message, )
567 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
568 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
569 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
571 def _parse_sms(self, smsHtml):
572 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
573 for messageId, messageHtml in itergroup(splitSms[1:], 2):
574 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
575 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
576 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
577 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
578 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
580 nameGroup = self._voicemailNameRegex.search(messageHtml)
581 name = nameGroup.group(1).strip() if nameGroup else ""
582 numberGroup = self._voicemailNumberRegex.search(messageHtml)
583 number = numberGroup.group(1).strip() if numberGroup else ""
584 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
585 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
587 fromGroups = self._smsFromRegex.finditer(messageHtml)
588 fromParts = (group.group(1).strip() for group in fromGroups)
589 textGroups = self._smsTextRegex.finditer(messageHtml)
590 textParts = (group.group(1).strip() for group in textGroups)
591 timeGroups = self._smsTimeRegex.finditer(messageHtml)
592 timeParts = (group.group(1).strip() for group in timeGroups)
594 messageParts = itertools.izip(fromParts, textParts, timeParts)
597 "id": messageId.strip(),
600 "relTime": relativeTime,
601 "prettyNumber": prettyNumber,
603 "messageParts": messageParts,
606 def _decorate_sms(self, parsedSms):
607 for messageData in parsedSms:
608 exactTime = messageData["time"]
609 if messageData["name"]:
610 header = messageData["name"]
611 elif messageData["prettyNumber"]:
612 header = messageData["prettyNumber"]
615 number = messageData["number"]
616 relativeTime = messageData["relTime"]
618 "<b>%s</b>: %s" % (messagePart[0], messagePart[-1])
619 for messagePart in messageData["messageParts"]
622 messages = ("No Transcription", )
623 yield exactTime, header, number, relativeTime, messages
626 def test_backend(username, password):
628 print "Authenticated: ", backend.is_authed()
629 print "Login?: ", backend.login(username, password)
630 print "Authenticated: ", backend.is_authed()
631 # print "Token: ", backend._token
632 print "Account: ", backend.get_account_number()
633 print "Callback: ", backend.get_callback_number()
634 # print "All Callback: ",
636 # pprint.pprint(backend.get_callback_numbers())
638 # pprint.pprint(list(backend.get_recent()))
639 # print "Contacts: ",
640 # for contact in backend.get_contacts():
642 # pprint.pprint(list(backend.get_contact_details(contact[0])))
643 for message in backend.get_messages():
644 pprint.pprint(message)