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 @todo Sort this stuff
318 @returns Iterable of (personsName, phoneNumber, date, action)
321 (exactDate, name, number, relativeDate, action)
322 for (name, number, exactDate, relativeDate, action) in self._get_recent()
324 sortedRecent.sort(reverse = True)
325 for exactDate, name, number, relativeDate, action in sortedRecent:
326 yield name, number, relativeDate, action
328 def get_addressbooks(self):
330 @returns Iterable of (Address Book Factory, Book Id, Book Name)
334 def open_addressbook(self, bookId):
338 def contact_source_short_name(contactId):
343 return "Google Voice"
345 _contactsRe = re.compile(r"""<a href="/voice/m/contact/(\d+)">(.*?)</a>""", re.S)
346 _contactsNextRe = re.compile(r""".*<a href="/voice/m/contacts(\?p=\d+)">Next.*?</a>""", re.S)
347 _contactsURL = "https://www.google.com/voice/mobile/contacts"
349 def get_contacts(self):
351 @returns Iterable of (contact id, contact name)
353 if self.__contacts is None:
356 contactsPagesUrls = [self._contactsURL]
357 for contactsPageUrl in contactsPagesUrls:
359 contactsPage = self._browser.download(contactsPageUrl)
360 except urllib2.URLError, e:
361 warnings.warn(traceback.format_exc())
362 raise RuntimeError("%s is not accesible" % contactsPageUrl)
363 for contact_match in self._contactsRe.finditer(contactsPage):
364 contactId = contact_match.group(1)
365 contactName = saxutils.unescape(contact_match.group(2))
366 contact = contactId, contactName
367 self.__contacts.append(contact)
370 next_match = self._contactsNextRe.match(contactsPage)
371 if next_match is not None:
372 newContactsPageUrl = self._contactsURL + next_match.group(1)
373 contactsPagesUrls.append(newContactsPageUrl)
375 for contact in self.__contacts:
378 _contactDetailPhoneRe = re.compile(r"""<div.*?>([0-9\-\(\) \t]+?)<span.*?>\((\w+)\)</span>""", re.S)
379 _contactDetailURL = "https://www.google.com/voice/mobile/contact"
381 def get_contact_details(self, contactId):
383 @returns Iterable of (Phone Type, Phone Number)
386 detailPage = self._browser.download(self._contactDetailURL + '/' + contactId)
387 except urllib2.URLError, e:
388 warnings.warn(traceback.format_exc())
389 raise RuntimeError("%s is not accesible" % self._contactDetailURL)
391 for detail_match in self._contactDetailPhoneRe.finditer(detailPage):
392 phoneNumber = detail_match.group(1)
393 phoneType = saxutils.unescape(detail_match.group(2))
394 yield (phoneType, phoneNumber)
396 _voicemailURL = "https://www.google.com/voice/inbox/recent/voicemail/"
397 _smsURL = "https://www.google.com/voice/inbox/recent/sms/"
399 def get_messages(self):
401 voicemailPage = self._browser.download(self._voicemailURL)
402 except urllib2.URLError, e:
403 warnings.warn(traceback.format_exc())
404 raise RuntimeError("%s is not accesible" % self._voicemailURL)
405 voicemailHtml = self._grab_html(voicemailPage)
406 parsedVoicemail = self._parse_voicemail(voicemailHtml)
407 decoratedVoicemails = self._decorate_voicemail(parsedVoicemail)
410 smsPage = self._browser.download(self._smsURL)
411 except urllib2.URLError, e:
412 warnings.warn(traceback.format_exc())
413 raise RuntimeError("%s is not accesible" % self._smsURL)
414 smsHtml = self._grab_html(smsPage)
415 parsedSms = self._parse_sms(smsHtml)
416 decoratedSms = self._decorate_sms(parsedSms)
418 allMessages = itertools.chain(decoratedVoicemails, decoratedSms)
419 sortedMessages = list(allMessages)
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)
451 raise RuntimeError("Could not extract account number from GoogleVoice")
452 self._accountNum = anGroup.group(1)
454 self._callbackNumbers = {}
455 for match in self._callbackRe.finditer(page):
456 callbackNumber = match.group(2)
457 callbackName = match.group(1)
458 self._callbackNumbers[callbackNumber] = callbackName
460 def _send_validation(self, number):
461 if not self.is_valid_syntax(number):
462 raise ValueError('Number is not valid: "%s"' % number)
463 elif not self.is_authed():
464 raise RuntimeError("Not Authenticated")
466 if len(number) == 11 and number[0] == 1:
467 # Strip leading 1 from 11 digit dialing
471 _recentCallsURL = "https://www.google.com/voice/inbox/recent/"
472 _placedCallsURL = "https://www.google.com/voice/inbox/recent/placed/"
473 _receivedCallsURL = "https://www.google.com/voice/inbox/recent/received/"
474 _missedCallsURL = "https://www.google.com/voice/inbox/recent/missed/"
476 def _get_recent(self):
478 @returns Iterable of (personsName, phoneNumber, exact date, relative date, action)
481 self._receivedCallsURL,
482 self._missedCallsURL,
483 self._placedCallsURL,
486 flatXml = self._browser.download(url)
487 except urllib2.URLError, e:
488 warnings.warn(traceback.format_exc())
489 raise RuntimeError("%s is not accesible" % url)
491 allRecentData = self._grab_json(flatXml)
492 for recentCallData in allRecentData["messages"].itervalues():
493 number = recentCallData["displayNumber"]
494 exactDate = recentCallData["displayStartDateTime"]
495 relativeDate = recentCallData["relativeStartTime"]
498 for label in recentCallData["labels"]
499 if label.lower() != "all" and label.lower() != "inbox"
501 number = saxutils.unescape(number)
502 exactDate = saxutils.unescape(exactDate)
503 exactDate = datetime.datetime.strptime(exactDate, "%m/%d/%y %I:%M %p")
504 relativeDate = saxutils.unescape(relativeDate)
505 action = saxutils.unescape(action)
506 yield "", number, exactDate, relativeDate, action
508 _seperateVoicemailsRegex = re.compile(r"""^\s*<div id="(\w+)"\s* class="gc-message.*?">""", re.MULTILINE | re.DOTALL)
509 _exactVoicemailTimeRegex = re.compile(r"""<span class="gc-message-time">(.*?)</span>""", re.MULTILINE)
510 _relativeVoicemailTimeRegex = re.compile(r"""<span class="gc-message-relative">(.*?)</span>""", re.MULTILINE)
511 _voicemailNumberRegex = re.compile(r"""<input type="hidden" class="gc-text gc-quickcall-ac" value="(.*?)"/>""", re.MULTILINE)
512 _prettyVoicemailNumberRegex = re.compile(r"""<span class="gc-message-type">(.*?)</span>""", re.MULTILINE)
513 _voicemailLocationRegex = re.compile(r"""<span class="gc-message-location">(.*?)</span>""", re.MULTILINE)
514 _voicemailMessageRegex = re.compile(r"""<span class="gc-word-(.*?)">(.*?)</span>""", re.MULTILINE)
516 def _parse_voicemail(self, voicemailHtml):
517 splitVoicemail = self._seperateVoicemailsRegex.split(voicemailHtml)
518 for id, messageHtml in itergroup(splitVoicemail[1:], 2):
519 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
520 exactTime = exactTimeGroup.group(1) if exactTimeGroup else ""
521 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
522 relativeTime = relativeTimeGroup.group(1) if relativeTimeGroup else ""
523 locationGroup = self._voicemailLocationRegex.search(messageHtml)
524 location = locationGroup.group(1) if locationGroup else ""
526 numberGroup = self._voicemailNumberRegex.search(messageHtml)
527 number = numberGroup.group(1) if numberGroup else ""
528 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
529 prettyNumber = prettyNumberGroup.group(1) if prettyNumberGroup else ""
531 messageGroups = self._voicemailMessageRegex.finditer(messageHtml)
533 (group.group(1), group.group(2))
534 for group in messageGroups
535 ) if messageGroups else ()
540 "relTime": relativeTime,
541 "prettyNumber": prettyNumber,
543 "location": location,
544 "messageParts": messageParts,
547 def _decorate_voicemail(self, parsedVoicemail):
548 messagePartFormat = {
553 for voicemailData in parsedVoicemail:
554 exactTime = voicemailData["time"] # @todo Parse This
555 header = "%s %s" % (voicemailData["prettyNumber"], voicemailData["location"])
557 messagePartFormat[quality] % part
558 for (quality, part) in voicemailData["messageParts"]
561 message = "No Transcription"
562 yield exactTime, header, voicemailData["number"], voicemailData["relTime"], message
564 _smsFromRegex = re.compile(r"""<span class="gc-message-sms-from">(.*?)</span>""", re.MULTILINE)
565 _smsTextRegex = re.compile(r"""<span class="gc-message-sms-time">(.*?)</span>""", re.MULTILINE)
566 _smsTimeRegex = re.compile(r"""<span class="gc-message-sms-text">(.*?)</span>""", re.MULTILINE)
568 def _parse_sms(self, smsHtml):
572 splitSms = self._seperateVoicemailsRegex.split(smsHtml)
573 for id, messageHtml in itergroup(splitSms[1:], 2):
574 exactTimeGroup = self._exactVoicemailTimeRegex.search(messageHtml)
575 exactTime = exactTimeGroup.group(1) if exactTimeGroup else ""
576 relativeTimeGroup = self._relativeVoicemailTimeRegex.search(messageHtml)
577 relativeTime = relativeTimeGroup.group(1) if relativeTimeGroup else ""
579 locationGroup = self._voicemailLocationRegex.search(messageHtml)
580 location = locationGroup.group(1) if locationGroup else ""
582 numberGroup = self._voicemailNumberRegex.search(messageHtml)
583 number = numberGroup.group(1) if numberGroup else ""
584 prettyNumberGroup = self._prettyVoicemailNumberRegex.search(messageHtml)
585 prettyNumber = prettyNumberGroup.group(1) if prettyNumberGroup else ""
587 fromGroups = self._smsFromRegex.finditer(messageHtml)
588 fromParts = (group.group(1) for group in fromGroups)
589 textGroups = self._smsTextRegex.finditer(messageHtml)
590 textParts = (group.group(1) for group in textGroups)
591 timeGroups = self._smsTimeRegex.finditer(messageHtml)
592 timeParts = (group.group(1) for group in timeGroups)
594 # @todo Switch from chain to izip once debugged the parts
595 #messageParts = itertools.izip(fromParts, textParts, timeParts)
596 messageParts = itertools.chain(fromParts, textParts, timeParts)
598 # @todo Switch pprint to yield and remove list() call once debugged parts
603 "relTime": relativeTime,
604 "prettyNumber": prettyNumber,
606 "location": location,
607 "messageParts": list(messageParts),
611 def _decorate_sms(self, parsedSms):
615 def test_backend(username, password):
618 print "Authenticated: ", backend.is_authed()
619 print "Login?: ", backend.login(username, password)
620 print "Authenticated: ", backend.is_authed()
621 print "Token: ", backend._token
622 print "Account: ", backend.get_account_number()
623 print "Callback: ", backend.get_callback_number()
624 # 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])))