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
This commit is contained in:
Michael Bayne
2005-08-24 21:59:50 +00:00
parent 18fa50cb78
commit ca04c00c44
3 changed files with 595 additions and 110 deletions
@@ -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. <code>/who</code>).
*/
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. <code>/tell</code>).
* @param args the arguments provided along with the command (e.g.
* <code>Bob hello</code>) or <code>null</code> 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 <code>c.</code><i>command</i>). If no translation
* exists the command will be <code>/</code><i>command</i>.
* @param command the name of the command that will be used to invoke
* this handler (e.g. <code>tell</code> if the command will be invoked
* as <code>/tell</code>).
* @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 <code>ChatCodes#SUCCESS</code> 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 <code>/help</code>. */
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 <code>/clear</code>. */
protected class ClearHandler extends CommandHandler
{
public String handleCommand (
SpeakService speakSvc, String command, String args, String[] history)
{
clearDisplays();
return ChatCodes.SUCCESS;
}
}
/** Implements <code>/speak</code>. */
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 <code>/emote</code>. */
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 <code>/think</code>. */
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;
}
@@ -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;
@@ -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