From ca04c00c4498e546bdc2306c1a0093f964baee2b Mon Sep 17 00:00:00 2001 From: Michael Bayne Date: Wed, 24 Aug 2005 21:59:50 +0000 Subject: [PATCH] Big chat revamp to migrate various useful things from Yohoho like /command handling, chat history support and chat mogrification into the base framework. git-svn-id: svn+ssh://src.earth.threerings.net/narya/trunk@3690 542714f4-19e9-0310-aa3c-eee0fc999fb1 --- .../crowd/chat/client/ChatDirector.java | 701 +++++++++++++++--- .../com/threerings/jme/chat/ChatView.java | 2 +- .../threerings/micasa/client/ChatPanel.java | 2 +- 3 files changed, 595 insertions(+), 110 deletions(-) diff --git a/src/java/com/threerings/crowd/chat/client/ChatDirector.java b/src/java/com/threerings/crowd/chat/client/ChatDirector.java index bde383678..be243f602 100644 --- a/src/java/com/threerings/crowd/chat/client/ChatDirector.java +++ b/src/java/com/threerings/crowd/chat/client/ChatDirector.java @@ -21,9 +21,17 @@ package com.threerings.crowd.chat.client; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; import java.util.Iterator; import java.util.LinkedList; +import java.util.Map; +import java.util.StringTokenizer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import com.samskivert.util.Collections; import com.samskivert.util.HashIntMap; import com.samskivert.util.ObserverList; import com.samskivert.util.ResultListener; @@ -85,11 +93,52 @@ public class ChatDirector extends BasicDirector public boolean isChatterValid (Name username); } + /** + * Used to implement a slash command (e.g. /who). + */ + public static abstract class CommandHandler + { + /** + * Handles the specified chat command. + * + * @param speakSvc an optional SpeakService object representing + * the object to send the chat message on. + * @param command the slash command that was used to invoke this + * handler (e.g. /tell). + * @param args the arguments provided along with the command (e.g. + * Bob hello) or null if no arguments + * were supplied. + * @param history an in/out parameter that allows the command to + * modify the text that will be appended to the chat history. If + * this is set to null, nothing will be appended. + * + * @return an untranslated string that will be reported to the + * chat box to convey an error response to the user, or {@link + * ChatCodes#SUCCESS}. + */ + public abstract String handleCommand ( + SpeakService speakSvc, String command, String args, + String[] history); + + /** + * Returns true if this user should have access to this chat + * command. + */ + public boolean checkAccess (BodyObject user) + { + return true; + } + } + /** * Creates a chat director and initializes it with the supplied * context. The chat director will register itself as a location * observer so that it can automatically process place constrained * chat. + * + * @param msgmgr the message manager via which we do our translations. + * @param bundle the message bundle from which we obtain our + * chat-related translation strings. */ public ChatDirector (CrowdContext ctx, MessageManager msgmgr, String bundle) { @@ -102,6 +151,14 @@ public class ChatDirector extends BasicDirector // register ourselves as a location observer _ctx.getLocationDirector().addLocationObserver(this); + + // register our default chat handlers + MessageBundle msg = _msgmgr.getBundle(_bundle); + registerCommandHandler(msg, "help", new HelpHandler()); + registerCommandHandler(msg, "clear", new ClearHandler()); + registerCommandHandler(msg, "speak", new SpeakHandler()); + registerCommandHandler(msg, "emote", new EmoteHandler()); + registerCommandHandler(msg, "think", new ThinkHandler()); } /** @@ -169,41 +226,54 @@ public class ChatDirector extends BasicDirector } /** - * Adds a chatter to our list of recent chatters. + * Registers a chat command handler. + * + * @param msg the message bundle via which the slash command will be + * translated (as c.command). If no translation + * exists the command will be /command. + * @param command the name of the command that will be used to invoke + * this handler (e.g. tell if the command will be invoked + * as /tell). + * @param handler the chat command handler itself. */ - protected void addChatter (Name name) + public void registerCommandHandler ( + MessageBundle msg, String command, CommandHandler handler) { - // check to see if the chatter validator approves.. - if ((_chatterValidator != null) && - (!_chatterValidator.isChatterValid(name))) { - return; - } - - boolean wasthere = _chatters.remove(name); - _chatters.addFirst(name); - - if (!wasthere) { - if (_chatters.size() > MAX_CHATTERS) { - _chatters.removeLast(); + String key = "c." + command; + if (msg.exists(key)) { + StringTokenizer st = new StringTokenizer(msg.get(key)); + while (st.hasMoreTokens()) { + _handlers.put(st.nextToken(), handler); } - - notifyChatterObservers(); + } else { + // fall back to just using the English command + _handlers.put(command, handler); } } /** - * Notifies all registered {@link ChatterObserver}s that the list of - * chatters has changed. + * Return the current size of the history. */ - protected void notifyChatterObservers () + public int getCommandHistorySize () { - _chatterObservers.apply(new ObserverList.ObserverOp() { - public boolean apply (Object observer) { - ((ChatterObserver)observer).chattersUpdated( - _chatters.listIterator()); - return true; - } - }); + return _history.size(); + } + + /** + * Get the chat history entry at the specified index, + * with 0 being the oldest. + */ + public String getCommandHistory (int index) + { + return (String)_history.get(index); + } + + /** + * Clear the chat command history. + */ + public void clearCommandHistory () + { + _history.clear(); } /** @@ -218,7 +288,7 @@ public class ChatDirector extends BasicDirector } }); } - + /** * Display a system INFO message as if it had come from the server. * The localtype of the message will be PLACE_CHAT_TYPE. @@ -268,23 +338,6 @@ public class ChatDirector extends BasicDirector bundle, message, SystemMessage.ATTENTION, PLACE_CHAT_TYPE); } - /** - * Display the specified system message as if it had come from the server. - */ - protected void displaySystem ( - String bundle, String message, byte attLevel, String localtype) - { - // nothing should be untranslated, so pass the default bundle if need - // be. - if (bundle == null) { - bundle = _bundle; - } - SystemMessage msg = new SystemMessage(); - msg.attentionLevel = attLevel; - msg.setClientInfo(xlate(bundle, message), localtype); - dispatchMessage(msg); - } - /** * Dispatches the provided message to our chat displays. */ @@ -293,29 +346,84 @@ public class ChatDirector extends BasicDirector _displayMessageOp.setMessage(message); _displays.apply(_displayMessageOp); } - - /** - * Dispatches a {@link #requestSpeak(SpeakService,String,byte)} on the - * place object that we currently occupy. - */ - public void requestSpeak (String message) - { - requestSpeak(message, DEFAULT_MODE); - } /** - * Dispatches a {@link #requestSpeak(SpeakService,String,byte)} on the - * place object that we currently occupy. + * Parses and delivers the supplied chat message. Slash command + * processing and mogrification are performed and the message is added + * to the chat history if appropriate. + * + * @param speakSvc the SpeakService representing the target dobj of + * the speak or null if we should speak in the "default" way. + * @param text the text to be parsed and sent. + * @param record if text is a command, should it be added to the history? + * + * @return ChatCodes#SUCCESS if the message was parsed + * and sent correctly, a translatable error string if there was some + * problem. */ - public void requestSpeak (String message, byte mode) + public String requestChat ( + SpeakService speakSvc, String text, boolean record) { - // make sure we're currently in a place - if (_place == null) { - return; + if (text.startsWith("/")) { + // split the text up into a command and arguments + String command = text.substring(1).toLowerCase(); + String[] hist = new String[1]; + String args = ""; + int sidx = text.indexOf(" "); + if (sidx != -1) { + command = text.substring(1, sidx).toLowerCase(); + args = text.substring(sidx+1).trim(); + } + + HashMap possibleCommands = getCommandHandlers(command); + switch (possibleCommands.size()) { + case 0: + StringTokenizer tok = new StringTokenizer(text); + return MessageBundle.tcompose( + "m.unknown_command", tok.nextToken()); + + case 1: + Iterator itr = possibleCommands.entrySet().iterator(); + Map.Entry entry = (Map.Entry) itr.next(); + String cmdName = (String) entry.getKey(); + CommandHandler cmd = (CommandHandler) entry.getValue(); + + String result = cmd.handleCommand(speakSvc, cmdName, args, hist); + if (!result.equals(ChatCodes.SUCCESS)) { + return result; + } + + if (record) { + // get the final history-ready command string + hist[0] = "/" + ((hist[0] == null) ? command : hist[0]); + + // remove from history if it was present and + // add it to the end + addToHistory(hist[0]); + } + + return result; + + default: + String alternativeCommands = ""; + itr = Collections.getSortedIterator(possibleCommands.keySet()); + while (itr.hasNext()) { + cmdName = (String)itr.next(); + alternativeCommands += " /" + cmdName; + } + return MessageBundle.tcompose( + "m.unspecific_command", alternativeCommands); + } } - // dispatch a speak request on the active place object - requestSpeak(_place.speakService, message, mode); + // if not a command then just speak + String message = text.trim(); + if (StringUtil.blank(message)) { + // report silent failure for now + return ChatCodes.SUCCESS; + } + + return deliverChat(speakSvc, message, ChatCodes.DEFAULT_MODE); } /** @@ -326,7 +434,7 @@ public class ChatDirector extends BasicDirector * possibly vetoed) before being dispatched. * * @param speakService the speak service to use when generating the - * speak request. + * speak request or null if we should speak in the current "place". * @param message the contents of the speak message. * @param mode a speech mode that will be interpreted by the {@link * ChatDisplay} implementations that eventually display this speak @@ -335,6 +443,13 @@ public class ChatDirector extends BasicDirector public void requestSpeak ( SpeakService speakService, String message, byte mode) { + if (speakService == null) { + if (_place == null) { + return; + } + speakService = _place.speakService; + } + // make sure they can say what they want to say message = filter(message, null, true); if (message == null) { @@ -483,11 +598,22 @@ public class ChatDirector extends BasicDirector _auxes.remove(source.getOid()); } - // documentation inherited from interface - protected void fetchServices (Client client) + /** + * Run a message through all the currently registered filters. + */ + public String filter (String msg, Name otherUser, boolean outgoing) { - // get a handle on our chat service - _cservice = (ChatService)client.requireService(ChatService.class); + _filterMessageOp.setMessage(msg, otherUser, outgoing); + _filters.apply(_filterMessageOp); + return _filterMessageOp.getMessage(); + } + + /** + * Runs the supplied message through the various chat mogrifications. + */ + public String mogrifyChat (String text) + { + return mogrifyChat(text, false, true); } // documentation inherited @@ -565,42 +691,6 @@ public class ChatDirector extends BasicDirector } } - /** - * Translates the specified message using the specified bundle. - */ - protected String xlate (String bundle, String message) - { - if (bundle != null && _msgmgr != null) { - MessageBundle msgb = _msgmgr.getBundle(bundle); - if (msgb == null) { - Log.warning( - "No message bundle available to translate message " + - "[bundle=" + bundle + ", message=" + message + "]."); - } else { - message = msgb.xlate(message); - } - } - return message; - } - - /** - * Looks up and returns the message type associated with the specified - * oid. - */ - protected String getLocalType (int oid) - { - String type = (String)_auxes.get(oid); - return (type == null) ? PLACE_CHAT_TYPE : type; - } - - /** - * Used to assign unique ids to all speak requests. - */ - protected synchronized int nextRequestId () - { - return _requestId++; - } - // documentation inherited public void clientDidLogon (Client client) { @@ -645,17 +735,295 @@ public class ChatDirector extends BasicDirector locationDidChange(null); // clear our service - _cservice = null; + _cservice = null; } /** - * Run a message through all the currently registered filters. + * Called to determine whether we are permitted to post the supplied + * chat message. Derived classes may wish to throttle chat or restrict + * certain types in certain circumstances for whatever reason. + * + * @return null if the chat is permitted, a translatable string + * indicating the reason for rejection if not. */ - public String filter (String msg, Name otherUser, boolean outgoing) + protected String checkCanChat ( + SpeakService speakSvc, String message, byte mode) { - _filterMessageOp.setMessage(msg, otherUser, outgoing); - _filters.apply(_filterMessageOp); - return _filterMessageOp.getMessage(); + return null; + } + + /** + * Delivers a plain chat message (not a slash command) on the + * specified speak service in the specified mode. The message will be + * mogrified and filtered prior to delivery. + * + * @return {@link ChatCodes#SUCCESS} if the message was delivered or a + * string indicating why it failed. + */ + protected String deliverChat ( + SpeakService speakSvc, String message, byte mode) + { + // run the message through our mogrification process + message = mogrifyChat(message, true, mode != ChatCodes.EMOTE_MODE); + + // mogrification may result in something being turned into a slash + // command, in which case we have to run everything through again + // from the start + if (message.startsWith("/")) { + return requestChat(speakSvc, message, false); + } + + // make sure this client is not restricted from performing this + // chat message for some reason or other + String errmsg = checkCanChat(speakSvc, message, mode); + if (errmsg != null) { + return errmsg; + } + + // speak on the specified service + requestSpeak(speakSvc, message, mode); + + return ChatCodes.SUCCESS; + } + + /** + * Add the specified command to the history. + */ + protected void addToHistory (String cmd) + { + // remove any previous instance of this command + _history.remove(cmd); + + // append it to the end + _history.add(cmd); + + // prune the history once it extends beyond max size + if (_history.size() > MAX_COMMAND_HISTORY) { + _history.remove(0); + } + } + + /** + * Mogrify common literary crutches into more appealing chat or + * commands. + * + * @param transformsAllowed if true, the chat may transformed into a + * different mode. (lol -> /emote laughs) + * @param capFirst if true, the first letter of the text is + * capitalized. This is not desired if the chat is already an emote. + */ + protected String mogrifyChat ( + String text, boolean transformsAllowed, boolean capFirst) + { + int tlen = text.length(); + if (tlen == 0) { + return text; + + // check to make sure there aren't too many caps + } else if (tlen > 7) { + // count caps + int caps = 0; + for (int ii=0; ii < tlen; ii++) { + if (Character.isUpperCase(text.charAt(ii))) { + caps++; + if (caps > (tlen / 2)) { + // lowercase the whole string if there are + text = text.toLowerCase(); + break; + } + } + } + } + + StringBuffer buf = new StringBuffer(text); + buf = mogrifyChat(buf, transformsAllowed, capFirst); + return buf.toString(); + } + + /** Helper function for {@link #mogrifyChat}. */ + protected StringBuffer mogrifyChat ( + StringBuffer buf, boolean transformsAllowed, boolean capFirst) + { + // do the generic mogrifications and translations + buf = translatedReplacements("x.mogrifies", buf); + + // perform themed expansions and transformations + if (transformsAllowed) { + buf = translatedReplacements("x.transforms", buf); + } + + /* + // capitalize the first letter + if (capFirst) { + buf.setCharAt(0, Character.toUpperCase(buf.charAt(0))); + } + // and capitalize any letters after a sentence-ending punctuation + Pattern p = Pattern.compile("([^\\.][\\.\\?\\!](\\s)+\\p{Ll})"); + Matcher m = p.matcher(buf); + if (m.find()) { + buf = new StringBuffer(); + m.appendReplacement(buf, m.group().toUpperCase()); + while (m.find()) { + m.appendReplacement(buf, m.group().toUpperCase()); + } + m.appendTail(buf); + } + */ + + return buf; + } + + /** + * Do all the replacements (mogrifications) specified in the + * translation string specified by the key. + */ + protected StringBuffer translatedReplacements (String key, StringBuffer buf) + { + MessageBundle bundle = _msgmgr.getBundle(_bundle); + if (!bundle.exists(key)) { + return buf; + } + StringTokenizer st = new StringTokenizer(bundle.get(key), "#"); + // apply the replacements to each mogrification that matches + while (st.hasMoreTokens()) { + String pattern = st.nextToken(); + String replace = st.nextToken(); + Matcher m = Pattern.compile(pattern, Pattern.CASE_INSENSITIVE). + matcher(buf); + if (m.find()) { + buf = new StringBuffer(); + m.appendReplacement(buf, replace); + // they may match more than once + while (m.find()) { + m.appendReplacement(buf, replace); + } + m.appendTail(buf); + } + } + return buf; + } + + /** + * Returns a hashmap containing all command handlers that match the + * specified command (i.e. the specified command is a prefix of their + * registered command string). + */ + protected HashMap getCommandHandlers (String command) + { + HashMap matches = new HashMap(); + BodyObject user = (BodyObject)_ctx.getClient().getClientObject(); + Iterator itr = _handlers.entrySet().iterator(); + while (itr.hasNext()) { + Map.Entry entry = (Map.Entry) itr.next(); + String cmd = (String) entry.getKey(); + if (!cmd.startsWith(command)) { + continue; + } + CommandHandler handler = (CommandHandler)entry.getValue(); + if (!handler.checkAccess(user)) { + continue; + } + matches.put(cmd, handler); + } + return matches; + } + + /** + * Adds a chatter to our list of recent chatters. + */ + protected void addChatter (Name name) + { + // check to see if the chatter validator approves.. + if ((_chatterValidator != null) && + (!_chatterValidator.isChatterValid(name))) { + return; + } + + boolean wasthere = _chatters.remove(name); + _chatters.addFirst(name); + + if (!wasthere) { + if (_chatters.size() > MAX_CHATTERS) { + _chatters.removeLast(); + } + + notifyChatterObservers(); + } + } + + /** + * Notifies all registered {@link ChatterObserver}s that the list of + * chatters has changed. + */ + protected void notifyChatterObservers () + { + _chatterObservers.apply(new ObserverList.ObserverOp() { + public boolean apply (Object observer) { + ((ChatterObserver)observer).chattersUpdated( + _chatters.listIterator()); + return true; + } + }); + } + + /** + * Translates the specified message using the specified bundle. + */ + protected String xlate (String bundle, String message) + { + if (bundle != null && _msgmgr != null) { + MessageBundle msgb = _msgmgr.getBundle(bundle); + if (msgb == null) { + Log.warning( + "No message bundle available to translate message " + + "[bundle=" + bundle + ", message=" + message + "]."); + } else { + message = msgb.xlate(message); + } + } + return message; + } + + /** + * Display the specified system message as if it had come from the server. + */ + protected void displaySystem ( + String bundle, String message, byte attLevel, String localtype) + { + // nothing should be untranslated, so pass the default bundle if need + // be. + if (bundle == null) { + bundle = _bundle; + } + SystemMessage msg = new SystemMessage(); + msg.attentionLevel = attLevel; + msg.setClientInfo(xlate(bundle, message), localtype); + dispatchMessage(msg); + } + + /** + * Looks up and returns the message type associated with the specified + * oid. + */ + protected String getLocalType (int oid) + { + String type = (String)_auxes.get(oid); + return (type == null) ? PLACE_CHAT_TYPE : type; + } + + /** + * Used to assign unique ids to all speak requests. + */ + protected synchronized int nextRequestId () + { + return _requestId++; + } + + // documentation inherited from interface + protected void fetchServices (Client client) + { + // get a handle on our chat service + _cservice = (ChatService)client.requireService(ChatService.class); } /** @@ -708,6 +1076,114 @@ public class ChatDirector extends BasicDirector protected ChatMessage _message; } + /** Implements /help. */ + protected class HelpHandler extends CommandHandler + { + public String handleCommand ( + SpeakService speakSvc, String command, String args, String[] history) + { + String hcmd = ""; + + // grab the command they want help on + if (!StringUtil.blank(args)) { + hcmd = args; + int sidx = args.indexOf(" "); + if (sidx != -1) { + hcmd = args.substring(0, sidx); + } + } + + // let the user give commands with or with the / + if (hcmd.startsWith("/")) { + hcmd = hcmd.substring(1); + } + + // handle "/help help" and "/help someboguscommand" + HashMap possibleCommands = getCommandHandlers(hcmd); + if (hcmd.equals("help") || possibleCommands.isEmpty()) { + possibleCommands = getCommandHandlers(""); + possibleCommands.remove("help"); // remove help from the list + } + + // if there is only one possible command display its usage + switch (possibleCommands.size()) { + case 1: + Iterator itr = possibleCommands.keySet().iterator(); + // this is a little funny, but we display the feeback + // message by hand and return SUCCESS so that the chat + // entry field doesn't think that we've failed and + // preserve our command text + displayFeedback(null, "m.usage_" + (String)itr.next()); + return ChatCodes.SUCCESS; + + default: + Object[] commands = possibleCommands.keySet().toArray(); + Arrays.sort(commands); + String commandList = ""; + for (int ii = 0; ii < commands.length; ii++) { + commandList += " /" + commands[ii]; + } + return MessageBundle.tcompose("m.usage_help", commandList); + } + } + } + + /** Implements /clear. */ + protected class ClearHandler extends CommandHandler + { + public String handleCommand ( + SpeakService speakSvc, String command, String args, String[] history) + { + clearDisplays(); + return ChatCodes.SUCCESS; + } + } + + /** Implements /speak. */ + protected class SpeakHandler extends CommandHandler + { + public String handleCommand ( + SpeakService speakSvc, String command, String args, String[] history) + { + if (StringUtil.blank(args)) { + return "m.usage_speak"; + } + // note the command to be stored in the history + history[0] = command + " "; + return requestChat(null, args, true); + } + } + + /** Implements /emote. */ + protected class EmoteHandler extends CommandHandler + { + public String handleCommand ( + SpeakService speakSvc, String command, String args, String[] history) + { + if (StringUtil.blank(args)) { + return "m.usage_emote"; + } + // note the command to be stored in the history + history[0] = command + " "; + return deliverChat(speakSvc, args, ChatCodes.EMOTE_MODE); + } + } + + /** Implements /think. */ + protected class ThinkHandler extends CommandHandler + { + public String handleCommand ( + SpeakService speakSvc, String command, String args, String[] history) + { + if (StringUtil.blank(args)) { + return "m.usage_think"; + } + // note the command to be stored in the history + history[0] = command + " "; + return deliverChat(speakSvc, args, ChatCodes.THINK_MODE); + } + } + /** Our active chat context. */ protected CrowdContext _ctx; @@ -748,6 +1224,12 @@ public class ChatDirector extends BasicDirector protected ObserverList _chatterObservers = new ObserverList(ObserverList.FAST_UNSAFE_NOTIFY); + /** Registered chat command handlers. */ + protected static HashMap _handlers = new HashMap(); + + /** A history of chat commands. */ + protected static ArrayList _history = new ArrayList(); + /** Used by {@link #nextRequestId}. */ protected int _requestId; @@ -759,4 +1241,7 @@ public class ChatDirector extends BasicDirector /** The maximum number of chatter usernames to track. */ protected static final int MAX_CHATTERS = 6; + + /** The maximum number of commands to keep in the chat history. */ + protected static final int MAX_COMMAND_HISTORY = 10; } diff --git a/src/java/com/threerings/jme/chat/ChatView.java b/src/java/com/threerings/jme/chat/ChatView.java index 7d996958b..71e9042c8 100644 --- a/src/java/com/threerings/jme/chat/ChatView.java +++ b/src/java/com/threerings/jme/chat/ChatView.java @@ -160,7 +160,7 @@ public class ChatView extends BContainer } else { // request to send this text as a chat message - _chatdtr.requestSpeak(text); + _chatdtr.requestSpeak(null, text, ChatCodes.DEFAULT_MODE); } return true; diff --git a/src/java/com/threerings/micasa/client/ChatPanel.java b/src/java/com/threerings/micasa/client/ChatPanel.java index bdebb5208..9ed9fe7c1 100644 --- a/src/java/com/threerings/micasa/client/ChatPanel.java +++ b/src/java/com/threerings/micasa/client/ChatPanel.java @@ -231,7 +231,7 @@ public class ChatPanel } else { // request to send this text as a chat message - _chatdtr.requestSpeak(text); + _chatdtr.requestSpeak(null, text, ChatCodes.DEFAULT_MODE); } // clear out the input because we sent a request