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 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 = None
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) < 60 and not force:
135 self._grab_account_info()
136 except StandardError, e:
137 warnings.warn(traceback.format_exc())
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 grandcentral
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 warnings.warn(traceback.format_exc())
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 warnings.warn(traceback.format_exc())
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 warnings.warn(traceback.format_exc())
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 grand central 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 time.time() - self._lastAuthed < 1800 or self.is_authed():
279 return self._callbackNumbers
283 _setforwardURL = "https://www.google.com//voice/m/setphone"
285 def set_callback_number(self, callbacknumber):
287 Set the number that grandcental calls
288 @param callbacknumber should be a proper 10 digit number
290 self._callbackNumber = callbacknumber
291 callbackPostData = urllib.urlencode({
292 '_rnr_se': self._token,
293 'phone': callbacknumber
296 callbackSetPage = self._browser.download(self._setforwardURL, callbackPostData)
297 except urllib2.URLError, e:
298 warnings.warn(traceback.format_exc())
299 raise RuntimeError("%s is not accesible" % self._setforwardURL)
301 # @bug This does not seem to be keeping on my tablet (but works on the
302 # desktop), or the reading isn't working too well
303 self._browser.cookies.save()
306 def get_callback_number(self):
308 @returns Current callback number or None
310 for c in self._browser.cookies:
311 if c.name == "gv-ph":
313 return self._callbackNumber
315 def get_recent(self):
317 @returns Iterable of (personsName, phoneNumber, date, action)
320 (exactDate, name, number, relativeDate, action)
321 for (name, number, exactDate, relativeDate, action) in self._get_recent()
323 sortedRecent.sort(reverse = True)
324 for exactDate, name, number, relativeDate, action in sortedRecent:
325 yield name, number, relativeDate, action
327 def get_addressbooks(self):
329 @returns Iterable of (Address Book Factory, Book Id, Book Name)
333 def open_addressbook(self, bookId):
337 def contact_source_short_name(contactId):
342 return "Google Voice"
344 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
345 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
346 _contactsURL = "https://www.google.com/voice/mobile/contacts"
348 def get_contacts(self):
350 @returns Iterable of (contact id, contact name)
352 if self.__contacts is None:
355 contactsPagesUrls = [self._contactsURL]
356 for contactsPageUrl in contactsPagesUrls:
358 contactsPage = self._browser.download(contactsPageUrl)
359 except urllib2.URLError, e:
360 warnings.warn(traceback.format_exc())
361 raise RuntimeError("%s is not accesible" % contactsPageUrl)
362 for contact_match in self._contactsRe.finditer(contactsPage):
363 contactId = contact_match.group(1)
364 contactName = saxutils.unescape(contact_match.group(2))
365 contact = contactId, contactName
366 self.__contacts.append(contact)
369 next_match = self._contactsNextRe.match(contactsPage)
370 if next_match is not None:
371 newContactsPageUrl = self._contactsURL + next_match.group(1)
372 contactsPagesUrls.append(newContactsPageUrl)
374 for contact in self.__contacts:
377 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
378 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
380 def get_contact_details(self, contactId):
382 @returns Iterable of (Phone Type, Phone Number)
385 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
386 except urllib2.URLError, e:
387 warnings.warn(traceback.format_exc())
388 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
390 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
391 phoneNumber = detail_match.group(1)
392 phoneType = saxutils.unescape(detail_match.group(2))
393 yield (phoneType, phoneNumber)
395 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
396 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
398 def get_messages(self):
400 voicemailPage = self._browser.download(self._voicemailURL)
401 except urllib2.URLError, e:
402 warnings.warn(traceback.format_exc())
403 raise RuntimeError("%s is not accesible" % self._voicemailURL)
404 voicemailHtml = self._grab_html(voicemailPage)
405 parsedVoicemail = self._parse_voicemail(voicemailHtml)
406 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
409 smsPage = self._browser.download(self._smsURL)
410 except urllib2.URLError, e:
411 warnings.warn(traceback.format_exc())
412 raise RuntimeError("%s is not accesible" % self._smsURL)
413 smsHtml = self._grab_html(smsPage)
414 parsedSms = self._parse_sms(smsHtml)
415 decoratedSms = self._decorate_sms(parsedSms)
417 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
418 sortedMessages = list(allMessages)
419 sortedMessages.sort(reverse=True)
420 for exactDate, header, number, relativeDate, message in sortedMessages:
421 yield header, number, relativeDate, message
423 def _grab_json(self, flatXml):
424 xmlTree = ElementTree.fromstring(flatXml)
425 jsonElement = xmlTree.getchildren()[0]
426 flatJson = jsonElement.text
427 jsonTree = parse_json(flatJson)
430 def _grab_html(self, flatXml):
431 xmlTree = ElementTree.fromstring(flatXml)
432 htmlElement = xmlTree.getchildren()[1]
433 flatHtml = htmlElement.text
436 _tokenRe = re.compile(r"""<input.*?name="_rnr_se".*?value="(.*?)"\s*/>""")
437 _accountNumRe = re.compile(r"""<b class="ms\d">(.{14})</b></div>""")
438 _callbackRe = re.compile(r"""\s+(.*?):\s*(.*?)<br\s*/>\s*$""", re.M)
439 _forwardURL = "https://www.google.com/voice/mobile/phones"
441 def _grab_account_info(self):
442 page = self._browser.download(self._forwardURL)
444 tokenGroup = self._tokenRe.search(page)
445 if tokenGroup is None:
446 raise RuntimeError("Could not extract authentication token from GoogleVoice")
447 self._token = tokenGroup.group(1)
449 anGroup = self._accountNumRe.search(page)
450 if anGroup is not None:
451 self._accountNum = anGroup.group(1)
453 warnings.warn("Could not extract account number from GoogleVoice", UserWarning, 2)
455 self._callbackNumbers = {}
456 for match in self._callbackRe.finditer(page):
457 callbackNumber = match.group(2)
458 callbackName = match.group(1)
459 self._callbackNumbers[callbackNumber] = callbackName
461 def _send_validation(self, number):
462 if not self.is_valid_syntax(number):
463 raise ValueError('Number is not valid: "%s"' % number)
464 elif not self.is_authed():
465 raise RuntimeError("Not Authenticated")
467 if len(number) == 11 and number[0] == 1:
468 # Strip leading 1 from 11 digit dialing
472 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
473 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
474 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
475 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
477 def _get_recent(self):
479 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
482 self._receivedCallsURL,
483 self._missedCallsURL,
484 self._placedCallsURL,
487 flatXml = self._browser.download(url)
488 except urllib2.URLError, e:
489 warnings.warn(traceback.format_exc())
490 raise RuntimeError("%s is not accesible" % url)
492 allRecentData = self._grab_json(flatXml)
493 for recentCallData in allRecentData["messages"].itervalues():
494 number = recentCallData["displayNumber"]
495 exactDate = recentCallData["displayStartDateTime"]
496 relativeDate = recentCallData["relativeStartTime"]
499 for label in recentCallData["labels"]
500 if label.lower() != "all" and label.lower() != "inbox"
502 number = saxutils.unescape(number)
503 exactDate = saxutils.unescape(exactDate)
504 exactDate = datetime.datetime.strptime(exactDate, "%m/%d/%y %I:%M %p")
505 relativeDate = saxutils.unescape(relativeDate)
506 action = saxutils.unescape(action)
507 yield "", number, exactDate, relativeDate, action
509 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class="gc-message.*?">""", re.MULTILINE | re.DOTALL)
510 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
511 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
512 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
513 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
514 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">.*?<a.*?>(.*?)</a></span>""", re.MULTILINE)
515 _voicemailMessageRegex = re.compile(r"""<span class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
517 def _parse_voicemail(self, voicemailHtml):
518 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
519 for messageId, messageHtml in itergroup(splitVoicemail[1:], 2):
520 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
521 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
522 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
523 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
524 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
525 locationGroup = self._voicemailLocationRegex.search(messageHtml)
526 location = locationGroup.group(1).strip() if locationGroup else ""
528 numberGroup = self._voicemailNumberRegex.search(messageHtml)
529 number = numberGroup.group(1).strip() if numberGroup else ""
530 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
531 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
533 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
535 (group.group(1).strip(), group.group(2).strip())
536 for group in messageGroups
537 ) if messageGroups else ()
540 "id": messageId.strip(),
542 "relTime": relativeTime,
543 "prettyNumber": prettyNumber,
545 "location": location,
546 "messageParts": messageParts,
549 def _decorate_voicemail(self, parsedVoicemail):
550 messagePartFormat = {
555 for voicemailData in parsedVoicemail:
556 exactTime = voicemailData["time"]
557 header = "%s %s" % (voicemailData["prettyNumber"], voicemailData["location"])
559 messagePartFormat[quality] % part
560 for (quality, part) in voicemailData["messageParts"]
563 message = "No Transcription"
564 yield exactTime, header, voicemailData["number"], voicemailData["relTime"], message
566 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE | re.DOTALL)
567 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE | re.DOTALL)
568 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE | re.DOTALL)
570 def _parse_sms(self, smsHtml):
571 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
572 for messageId, messageHtml in itergroup(splitSms[1:], 2):
573 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
574 exactTime = exactTimeGroup.group(1).strip() if exactTimeGroup else ""
575 exactTime = datetime.datetime.strptime(exactTime, "%m/%d/%y %I:%M %p")
576 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
577 relativeTime = relativeTimeGroup.group(1).strip() if relativeTimeGroup else ""
579 numberGroup = self._voicemailNumberRegex.search(messageHtml)
580 number = numberGroup.group(1).strip() if numberGroup else ""
581 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
582 prettyNumber = prettyNumberGroup.group(1).strip() if prettyNumberGroup else ""
584 fromGroups = self._smsFromRegex.finditer(messageHtml)
585 fromParts = (group.group(1).strip() for group in fromGroups)
586 textGroups = self._smsTextRegex.finditer(messageHtml)
587 textParts = (group.group(1).strip() for group in textGroups)
588 timeGroups = self._smsTimeRegex.finditer(messageHtml)
589 timeParts = (group.group(1).strip() for group in timeGroups)
591 messageParts = itertools.izip(fromParts, textParts, timeParts)
594 "id": messageId.strip(),
596 "relTime": relativeTime,
597 "prettyNumber": prettyNumber,
599 "messageParts": messageParts,
602 def _decorate_sms(self, parsedSms):
603 for messageData in parsedSms:
604 exactTime = messageData["time"]
605 header = "%s" % (messageData["prettyNumber"])
606 number = messageData["number"]
607 relativeTime = messageData["relTime"]
608 message = "\n".join((
609 "<b>%s (%s)</b>: %s" % messagePart
610 for messagePart in messageData["messageParts"]
613 message = "No Transcription"
614 yield exactTime, header, number, relativeTime, message
617 def test_backend(username, password):
620 print "Authenticated: ", backend.is_authed()
621 print "Login?: ", backend.login(username, password)
622 print "Authenticated: ", backend.is_authed()
623 # print "Token: ", backend._token
624 print "Account: ", backend.get_account_number()
625 print "Callback: ", backend.get_callback_number()
626 # print "All Callback: ",
627 # pprint.pprint(backend.get_callback_numbers())
629 # pprint.pprint(list(backend.get_recent()))
630 # print "Contacts: ",
631 # for contact in backend.get_contacts():
633 # pprint.pprint(list(backend.get_contact_details(contact[0])))