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("gvoice.dialer")
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 def is_authed(self, force = False):
124 Attempts to detect a current session
125 @note Once logged in try not to reauth more than once a minute.
126 @returns If authenticated
128 if (time.time() - self._lastAuthed) < 120 and not force:
132 page = self._browser.download(self._forwardURL)
133 self._grab_account_info(page)
135 _moduleLogger.exception(str(e))
138 self._browser.cookies.save()
139 self._lastAuthed = time.time()
142 _loginURL = "https://www.google.com/accounts/ServiceLoginAuth"
144 def login(self, username, password):
146 Attempt to login to GoogleVoice
147 @returns Whether login was successful or not
149 loginPostData = urllib.urlencode({
152 'service': "grandcentral",
155 "PersistentCookie": "yes",
156 "continue": self._forwardURL,
160 loginSuccessOrFailurePage = self._browser.download(self._loginURL, loginPostData)
161 except urllib2.URLError, e:
162 _moduleLogger.exception(str(e))
163 raise RuntimeError("%s is not accesible" % self._loginURL)
166 self._grab_account_info(loginSuccessOrFailurePage)
168 _moduleLogger.exception(str(e))
171 self._browser.cookies.save()
172 self._lastAuthed = time.time()
176 self._lastAuthed = 0.0
177 self._browser.cookies.clear()
178 self._browser.cookies.save()
180 _gvDialingStrRe = re.compile("This may take a few seconds", re.M)
181 _clicktocallURL = "https://www.google.com/voice/m/sendcall"
183 def dial(self, number):
185 This is the main function responsible for initating the callback
187 number = self._send_validation(number)
189 clickToCallData = urllib.urlencode({
191 "phone": self._callbackNumber,
192 "_rnr_se": self._token,
195 'Referer' : 'https://google.com/voice/m/callsms',
197 callSuccessPage = self._browser.download(self._clicktocallURL, clickToCallData, None, otherData)
198 except urllib2.URLError, e:
199 _moduleLogger.exception(str(e))
200 raise RuntimeError("%s is not accesible" % self._clicktocallURL)
202 if self._gvDialingStrRe.search(callSuccessPage) is None:
203 raise RuntimeError("Google Voice returned an error")
207 _sendSmsURL = "https://www.google.com/voice/m/sendsms"
209 def send_sms(self, number, message):
210 number = self._send_validation(number)
212 smsData = urllib.urlencode({
215 "_rnr_se": self._token,
220 'Referer' : 'https://google.com/voice/m/sms',
222 smsSuccessPage = self._browser.download(self._sendSmsURL, smsData, None, otherData)
223 except urllib2.URLError, e:
224 _moduleLogger.exception(str(e))
225 raise RuntimeError("%s is not accesible" % self._sendSmsURL)
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 GoogleVoice phone number
241 return self._accountNum
243 def get_callback_numbers(self):
245 @returns a dictionary mapping call back numbers to descriptions
246 @note These results are cached for 30 minutes.
248 if not self.is_authed():
250 return self._callbackNumbers
252 _setforwardURL = "https://www.google.com//voice/m/setphone"
254 def set_callback_number(self, callbacknumber):
256 Set the number that GoogleVoice calls
257 @param callbacknumber should be a proper 10 digit number
259 self._callbackNumber = callbacknumber
262 def get_callback_number(self):
264 @returns Current callback number or None
266 return self._callbackNumber
268 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
269 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
270 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
271 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
273 def get_recent(self):
275 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
278 ("Received", self._receivedCallsURL),
279 ("Missed", self._missedCallsURL),
280 ("Placed", self._placedCallsURL),
283 flatXml = self._browser.download(url)
284 except urllib2.URLError, e:
285 _moduleLogger.exception(str(e))
286 raise RuntimeError("%s is not accesible" % url)
288 allRecentHtml = self._grab_html(flatXml)
289 allRecentData = self._parse_voicemail(allRecentHtml)
290 for recentCallData in allRecentData:
291 recentCallData["action"] = action
294 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
295 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
296 _contactsURL = "https://www.google.com/voice/mobile/contacts"
298 def get_contacts(self):
300 @returns Iterable of (contact id, contact name)
302 contactsPagesUrls = [self._contactsURL]
303 for contactsPageUrl in contactsPagesUrls:
305 contactsPage = self._browser.download(contactsPageUrl)
306 except urllib2.URLError, e:
307 _moduleLogger.exception(str(e))
308 raise RuntimeError("%s is not accesible" % contactsPageUrl)
309 for contact_match in self._contactsRe.finditer(contactsPage):
310 contactId = contact_match.group(1)
311 contactName = saxutils.unescape(contact_match.group(2))
312 contact = contactId, contactName
315 next_match = self._contactsNextRe.match(contactsPage)
316 if next_match is not None:
317 newContactsPageUrl = self._contactsURL + next_match.group(1)
318 contactsPagesUrls.append(newContactsPageUrl)
320 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9+\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
321 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
323 def get_contact_details(self, contactId):
325 @returns Iterable of (Phone Type, Phone Number)
328 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
329 except urllib2.URLError, e:
330 _moduleLogger.exception(str(e))
331 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
333 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
334 phoneNumber = detail_match.group(1)
335 phoneType = saxutils.unescape(detail_match.group(2))
336 yield (phoneType, phoneNumber)
338 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
339 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
341 def get_messages(self):
343 voicemailPage = self._browser.download(self._voicemailURL)
344 except urllib2.URLError, e:
345 _moduleLogger.exception(str(e))
346 raise RuntimeError("%s is not accesible" % self._voicemailURL)
347 voicemailHtml = self._grab_html(voicemailPage)
348 parsedVoicemail = self._parse_voicemail(voicemailHtml)
349 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
352 smsPage = self._browser.download(self._smsURL)
353 except urllib2.URLError, e:
354 _moduleLogger.exception(str(e))
355 raise RuntimeError("%s is not accesible" % self._smsURL)
356 smsHtml = self._grab_html(smsPage)
357 parsedSms = self._parse_sms(smsHtml)
358 decoratedSms = self._decorate_sms(parsedSms)
360 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
363 def _grab_json(self, flatXml):
364 xmlTree = ElementTree.fromstring(flatXml)
365 jsonElement = xmlTree.getchildren()[0]
366 flatJson = jsonElement.text
367 jsonTree = parse_json(flatJson)
370 def _grab_html(self, flatXml):
371 xmlTree = ElementTree.fromstring(flatXml)
372 htmlElement = xmlTree.getchildren()[1]
373 flatHtml = htmlElement.text
376 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
377 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
378 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
379 _forwardURL = "https://www.google.com/voice/mobile/phones"
381 def _grab_account_info(self, page):
382 tokenGroup = self._tokenRe.search(page)
383 if tokenGroup is None:
384 raise RuntimeError("Could not extract authentication token from GoogleVoice")
385 self._token = tokenGroup.group(1)
387 anGroup = self._accountNumRe.search(page)
388 if anGroup is not None:
389 self._accountNum = anGroup.group(1)
391 _moduleLogger.debug("Could not extract account number from GoogleVoice")
393 self._callbackNumbers = {}
394 for match in self._callbackRe.finditer(page):
395 callbackNumber = match.group(2)
396 callbackName = match.group(1)
397 self._callbackNumbers[callbackNumber] = callbackName
399 def _send_validation(self, number):
400 if not self.is_valid_syntax(number):
401 raise ValueError('Number is not valid: "%s"' % number)
402 elif not self.is_authed():
403 raise RuntimeError("Not Authenticated")
405 if len(number) == 11 and number[0] == 1:
406 # Strip leading 1 from 11 digit dialing
410 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class=".*?gc-message.*?">""", re.MULTILINE | re.DOTALL)
411 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
412 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
413 _voicemailNameRegex = re.compile(r"""<a class=.*?gc-message-name-link.*?>(.*?)</a>""", re.MULTILINE | re.DOTALL)
414 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
415 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
416 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
417 _messagesContactID = re.compile(r"""<a class=".*?gc-message-name-link.*?">.*?</a>\s*?<span .*?>(.*?)</span>""", re.MULTILINE)
418 #_voicemailMessageRegex = re.compile(r"""<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
419 #_voicemailMessageRegex = re.compile(r"""<a .*? class="gc-message-mni">(.*?)</a>""", re.MULTILINE)
420 _voicemailMessageRegex = re.compile(r"""(<span id="\d+-\d+" class="gc-word-(.*?)">(.*?)</span>|<a .*? class="gc-message-mni">(.*?)</a>)""", re.MULTILINE)
423 def _interpret_voicemail_regex(group):
424 quality, content, number = group.group(2), group.group(3), group.group(4)
425 if quality is not None and content is not None:
426 return quality, content
427 elif number is not None:
428 return "high", number
430 def _parse_voicemail(self, voicemailHtml):
431 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
432 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
433 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
434 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
435 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
436 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
437 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
438 locationGroup = self._voicemailLocationRegex.search(messageHtml)
439 location = locationGroup.group(1).strip() if locationGroup else ""
441 nameGroup = self._voicemailNameRegex.search(messageHtml)
442 name = nameGroup.group(1).strip() if nameGroup else ""
443 numberGroup = self._voicemailNumberRegex.search(messageHtml)
444 number = numberGroup.group(1).strip() if numberGroup else ""
445 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
446 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
447 contactIdGroup = self._messagesContactID.search(messageHtml)
448 contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
450 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
452 self._interpret_voicemail_regex(group)
453 for group in messageGroups
454 ) if messageGroups else ()
457 "id": messageId.strip(),
458 "contactId": contactId,
461 "relTime": relativeTime,
462 "prettyNumber": prettyNumber,
464 "location": location,
465 "messageParts": messageParts,
468 def _decorate_voicemail(self, parsedVoicemails):
469 messagePartFormat = {
474 for voicemailData in parsedVoicemails:
476 messagePartFormat[quality] % part
477 for (quality, part) in voicemailData["messageParts"]
480 message = "No Transcription"
481 whoFrom = voicemailData["name"]
482 when = voicemailData["time"]
483 voicemailData["messageParts"] = ((whoFrom, message, when), )
486 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
487 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
488 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
490 def _parse_sms(self, smsHtml):
491 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
492 for messageId, messageHtml in itergroup(splitSms[1:], 2):
493 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
494 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
495 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
496 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
497 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
499 nameGroup = self._voicemailNameRegex.search(messageHtml)
500 name = nameGroup.group(1).strip() if nameGroup else ""
501 numberGroup = self._voicemailNumberRegex.search(messageHtml)
502 number = numberGroup.group(1).strip() if numberGroup else ""
503 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
504 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
505 contactIdGroup = self._messagesContactID.search(messageHtml)
506 contactId = contactIdGroup.group(1).strip() if contactIdGroup else ""
508 fromGroups = self._smsFromRegex.finditer(messageHtml)
509 fromParts = (group.group(1).strip() for group in fromGroups)
510 textGroups = self._smsTextRegex.finditer(messageHtml)
511 textParts = (group.group(1).strip() for group in textGroups)
512 timeGroups = self._smsTimeRegex.finditer(messageHtml)
513 timeParts = (group.group(1).strip() for group in timeGroups)
515 messageParts = itertools.izip(fromParts, textParts, timeParts)
518 "id": messageId.strip(),
519 "contactId": contactId,
522 "relTime": relativeTime,
523 "prettyNumber": prettyNumber,
526 "messageParts": messageParts,
529 def _decorate_sms(self, parsedTexts):
533 def set_sane_callback(backend):
535 Try to set a sane default callback number on these preferences
536 1) 1747 numbers ( Gizmo )
537 2) anything with gizmo in the name
538 3) anything with computer in the name
541 numbers = backend.get_callback_numbers()
543 priorityOrderedCriteria = [
551 for numberCriteria, descriptionCriteria in priorityOrderedCriteria:
552 for number, description in numbers.iteritems():
553 if numberCriteria is not None and re.compile(numberCriteria).match(number) is None:
555 if descriptionCriteria is not None and re.compile(descriptionCriteria).match(description) is None:
557 backend.set_callback_number(number)
561 def sort_messages(allMessages):
562 sortableAllMessages = [
563 (message["time"], message)
564 for message in allMessages
566 sortableAllMessages.sort(reverse=True)
569 for (exactTime, message) in sortableAllMessages
573 def decorate_recent(recentCallData):
575 @returns (personsName, phoneNumber, date, action)
577 if recentCallData["name"]:
578 header = recentCallData["name"]
579 elif recentCallData["prettyNumber"]:
580 header = recentCallData["prettyNumber"]
581 elif recentCallData["location"]:
582 header = recentCallData["location"]
586 number = recentCallData["number"]
587 relTime = recentCallData["relTime"]
588 action = recentCallData["action"]
589 return header, number, relTime, action
592 def decorate_message(messageData):
593 exactTime = messageData["time"]
594 if messageData["name"]:
595 header = messageData["name"]
596 elif messageData["prettyNumber"]:
597 header = messageData["prettyNumber"]
600 number = messageData["number"]
601 relativeTime = messageData["relTime"]
603 messageParts = list(messageData["messageParts"])
604 if len(messageParts) == 0:
605 messages = ("No Transcription", )
606 elif len(messageParts) == 1:
607 messages = (messageParts[0][1], )
610 "<b>%s</b>: %s" % (messagePart[0], messagePart[1])
611 for messagePart in messageParts
614 decoratedResults = header, number, relativeTime, messages
615 return decoratedResults
618 def test_backend(username, password):
620 print "Authenticated: ", backend.is_authed()
621 if not backend.is_authed():
622 print "Login?: ", backend.login(username, password)
623 print "Authenticated: ", backend.is_authed()
625 #print "Token: ", backend._token
626 #print "Account: ", backend.get_account_number()
627 #print "Callback: ", backend.get_callback_number()
628 #print "All Callback: ",
630 #pprint.pprint(backend.get_callback_numbers())
633 #for data in backend.get_recent():
634 # pprint.pprint(data)
635 #for data in sort_messages(backend.get_recent()):
636 # pprint.pprint(decorate_recent(data))
637 #pprint.pprint(list(backend.get_recent()))
640 #for contact in backend.get_contacts():
642 # pprint.pprint(list(backend.get_contact_details(contact[0])))
645 #for message in backend.get_messages():
646 # pprint.pprint(message)
647 #for message in sort_messages(backend.get_messages()):
648 # pprint.pprint(decorate_message(message))
653 if __name__ == "__main__":
655 logging.basicConfig(level=logging.DEBUG)
656 test_backend(sys.argv[1], sys.argv[2])