/* This file is part of leafdigital leafChat. leafChat is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. leafChat is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with leafChat. If not, see <http://www.gnu.org/licenses/>. Copyright 2012 Samuel Marshall. */ package com.leafdigital.irc; import java.io.*; import java.util.*; import java.util.regex.*; import util.xml.XML; import com.leafdigital.irc.api.*; import com.leafdigital.prefs.api.*; import leafchat.core.api.*; /** Class for handling basic command messages */ public class BasicCommands { private static final Set<String> oneParamThenText = new HashSet<String>(Arrays.asList(new String[] { "squit","part","topic","kill" })); private static final Set<String> twoParamsThenText = new HashSet<String>(Arrays.asList(new String[] { "kick" })); private PluginContext context; BasicCommands(PluginContext pc) { this.context=pc; } void handle(UserCommandListMsg msg) throws GeneralException { // Commands that are handled specifically in this file. msg.addCommand(true, "say", UserCommandListMsg.FREQ_OBSCURE, "/say <message>", "Say something in the current channel/message."); msg.addCommand(true, "msg", UserCommandListMsg.FREQ_COMMON, "/msg <nick> <message>", "Send private message to the named user"); msg.addCommand(true, "me", UserCommandListMsg.FREQ_COMMON, "/me <action text>", "Send action message in the current " + "channel/message"); msg.addCommand(true, "raw", UserCommandListMsg.FREQ_UNCOMMON, "/raw <command>", "Send a raw IRC command to the server"); msg.addCommand(true, "quote", UserCommandListMsg.FREQ_UNCOMMON, "/quote <command>", "Send a raw IRC command to the server"); msg.addCommand(true, "ctcp", UserCommandListMsg.FREQ_UNCOMMON, "/ctcp <nick> <command> [params]", "Send a CTCP command to the " + "named user"); msg.addCommand(true, "ctcpreply", UserCommandListMsg.FREQ_OBSCURE, "/ctcpreply <nick> <command> [params]", "Send a CTCP reply to the " + "named user"); msg.addCommand(true, "nick", UserCommandListMsg.FREQ_COMMON, "/nick <new nick>", "Change to a new nickname"); msg.addCommand(true, "quit", UserCommandListMsg.FREQ_COMMON, "/quit [message]", "Close IRC connection to current server, leaving an " + "optional message in your channels"); msg.addCommand(true, "aquit", UserCommandListMsg.FREQ_COMMON, "/aquit [message]", "Close IRC connection to all servers, leaving " + "optional message on channels"); msg.addCommand(false, "clear", UserCommandListMsg.FREQ_COMMON, "/clear", "Clear the current scrollback window"); msg.addCommand(false, "echo", UserCommandListMsg.FREQ_OBSCURE, "/echo <message>", "<key>Scripting:</key> Display the message in current window"); msg.addCommand(true, "join", UserCommandListMsg.FREQ_COMMON, "/join <channel> [key]", "Join the named channel (include a key if " + "required by the channel)"); msg.addCommand(true, "away", UserCommandListMsg.FREQ_COMMON, "/away [message]", "Mark yourself away, with specified message (use " + "with no message when you come back)"); msg.addCommand(true, "ban", UserCommandListMsg.FREQ_COMMON, "/ban [channel] <nick>", "Ban a user from a channel; if no channel " + "specified, uses current channel"); msg.addCommand(true, "kick", UserCommandListMsg.FREQ_COMMON, "/kick [channel] <nick>", "Kick a user from a channel; if no channel " + "specified, uses current channel"); msg.addCommand(true, "squit", UserCommandListMsg.FREQ_OBSCURE, "/squit <server> <comment>", "<key>IRCop:</key> Disconnect a remote server"); msg.addCommand(true, "part", UserCommandListMsg.FREQ_COMMON, "/part <channel>", "Leave the named channel"); msg.addCommand(true, "topic", UserCommandListMsg.FREQ_COMMON, "/topic <channel> [new topic]", "View or (if topic specified) change " + "the topic for a channel"); msg.addCommand(true, "kill", UserCommandListMsg.FREQ_OBSCURE, "/kill <nick> <comment>", "<key>IRCop:</key> Cause user to be disconnected from " + "the server, with supplied message"); // Other commands from RFC1459 that can be typed by user msg.addCommand(true, "oper", UserCommandListMsg.FREQ_OBSCURE, "/oper <user> <password>", "<key>IRCop:</key> Enable operator privileges"); msg.addCommand(true, "mode", UserCommandListMsg.FREQ_COMMON, "/mode <channel/nick> <flags> [params]", "Set channel or nickname modes"); msg.addCommand(true, "names", UserCommandListMsg.FREQ_UNCOMMON, "/names <channel>", "<key>Low-level:</key> List all nicknames in a channel"); msg.addCommand(true, "list", UserCommandListMsg.FREQ_UNCOMMON, "/list", "List channels on a server (note: most servers support extra " + "parameters, without which this command is pretty useless)"); msg.addCommand(true, "invite", UserCommandListMsg.FREQ_UNCOMMON, "/invite <nick> <channel>", "Invite somebody to a channel"); msg.addCommand(true, "version", UserCommandListMsg.FREQ_UNCOMMON, "/version [server]", "Show server version (by default, for current " + "server)"); msg.addCommand(true, "stats", UserCommandListMsg.FREQ_OBSCURE, "/stats [query] [server]", "<key>IRCop:</key> Returns server information"); msg.addCommand(true, "links", UserCommandListMsg.FREQ_UNCOMMON, "/links [server] [server mask]", "List all servers known by the " + "current/specified server (matching the mask, if supplied)"); msg.addCommand(true, "time", UserCommandListMsg.FREQ_UNCOMMON, "/time [server]", "Show local time from current or specified server"); msg.addCommand(true, "connect", UserCommandListMsg.FREQ_OBSCURE, "/connect <target> [port] [existing]", "<key>IRCop:</key> Cause an existing " + "server to connect to the target server"); msg.addCommand(true, "trace", UserCommandListMsg.FREQ_OBSCURE, "/trace <server>", "Show route to named server"); msg.addCommand(true, "admin", UserCommandListMsg.FREQ_OBSCURE, "/admin [server]", "Show information about administrator of current " + "or named server"); msg.addCommand(true, "info", UserCommandListMsg.FREQ_OBSCURE, "/info [server]", "Show miscellaneous information about current or " + "named server"); msg.addCommand(true, "privmsg", UserCommandListMsg.FREQ_OBSCURE, "/privmsg <channel/nick> <text>", "<key>Low-level:</key> Send ordinary text to " + "channel or nickname"); msg.addCommand(true, "notice", UserCommandListMsg.FREQ_COMMON, "/notice <nick> <text>", "Send a notice (message which doesn't appear " + "in its own window); some servers also let you send notices to " + "channels or other groups"); msg.addCommand(true, "who", UserCommandListMsg.FREQ_COMMON, "/who <channel/other> [o]", "List all users in current channel " + "(optional 'o' flag restricts to ops)"); msg.addCommand(true, "whois", UserCommandListMsg.FREQ_COMMON, "/whois [server] <nick>", "Display information about user"); msg.addCommand(true, "whowas", UserCommandListMsg.FREQ_UNCOMMON, "/whowas <nick> [count] [server]", "Display information about a user " + "who was previously corrected"); msg.addCommand(true, "pong", UserCommandListMsg.FREQ_OBSCURE, "/pong <param> [param]", "<key>Low-level:</key> Reply to a server PING message"); msg.addCommand(true, "rehash", UserCommandListMsg.FREQ_OBSCURE, "/rehash", "<key>IRCop:</key> Make server reload its configuration file"); msg.addCommand(true, "restart", UserCommandListMsg.FREQ_OBSCURE, "/restart", "<key>IRCop:</key> Restart server"); msg.addCommand(true, "users", UserCommandListMsg.FREQ_OBSCURE, "/users [server]", "List users on the given or current server " + "(normally disabled)"); msg.addCommand(true, "userhost", UserCommandListMsg.FREQ_UNCOMMON, "/userhost <nickname> [nickname]*", "Show username and host for each " + "nickname"); msg.addCommand(true, "ison", UserCommandListMsg.FREQ_UNCOMMON, "/ison <nickname> [nickname]*", "<key>Low-level:</key> List which of the given " + "users are on IRC"); } void handle(UserCommandMsg msg) throws GeneralException { if(msg.isHandled()) { return; } String command=msg.getCommand(); if(command==null||command.equals("say")) { say(msg); } else if(command.equals("msg")) { msg(msg); } else if(command.equals("me")) { me(msg); } else if(command.equals("raw")||command.equals("quote")) { raw(msg); } else if(command.equals("ctcp")) { ctcp(msg); } else if(command.equals("ctcpreply")) { ctcpreply(msg); } else if(command.equals("nick")) { nick(msg); } else if(command.equals("quit")) { quit(msg); } else if(command.equals("aquit")) { aquit(msg); } else if(command.equals("silence")) { silence(msg); } else if(command.equals("clear")) { clear(msg); } else if(command.equals("echo")) { echo(msg); } else if(command.equals("join")) { join(msg); } else if(command.equals("away")) { away(msg); } else if(command.equals("ban")) { ban(msg); } else if(oneParamThenText.contains(command)) { generic(msg,1); } else if(twoParamsThenText.contains(command)) { generic(msg,2); } } private void silence(UserCommandMsg msg) throws GeneralException { msg.error( "Please use <key>/ignore</key>, not /silence. leafChat automatically uses " + "/silence on ignored users whenever the server supports it."); msg.markHandled(); } private void clear(UserCommandMsg msg) throws GeneralException { msg.getMessageDisplay().clear(); msg.markHandled(); } private void echo(UserCommandMsg msg) throws GeneralException { msg.getMessageDisplay().showInfo(XML.esc(msg.getParams())); msg.markHandled(); } byte[] convertEncoding(String text, Server s, String chan, IRCUserAddress user) { // Get encoding IRCEncoding ie = context.getSingle(IRCEncoding.class); IRCEncoding.EncodingInfo ei = ie.getEncoding(s, chan, user); return ei.convertOutgoing(text); } byte[] convertEncoding(String text, Server s, String chanOrNick) { if(s.getChanTypes().indexOf(chanOrNick.charAt(0)) != -1) { // Channel return convertEncoding(text,s,chanOrNick,null); } else { // Nick return convertEncoding(text,s,null,new IRCUserAddress(chanOrNick,false)); } } private void say(UserCommandMsg msg) throws GeneralException { if(msg.getServer()==null || (msg.getContextChan()==null && msg.getContextUser()==null)) { msg.error("You can only talk in channel or message windows."); return; } if(!msg.getServer().isConnected()) { msg.error("Not connected."); return; } if(!msg.getParams().equals("")) { String target; if(msg.getContextChan() != null) { target = msg.getContextChan(); } else { target = msg.getContextUser().getNick(); } msg.getServer().sendLine( IRCMsg.constructBytes("PRIVMSG " + target + " :", convertEncoding( msg.getParams(), msg.getServer(), msg.getContextChan(), msg.getContextUser()))); msg.getMessageDisplay().showOwnText(MessageDisplay.TYPE_MSG, target, msg.getParams()); } msg.markHandled(); } private void me(UserCommandMsg msg) throws GeneralException { if(msg.getServer()==null || (msg.getContextChan()==null && msg.getContextUser()==null)) { msg.error("You can only talk in channel or message windows."); return; } if(!msg.getServer().isConnected()) { msg.error("Not connected."); return; } String target; if(msg.getContextChan() != null) { target = msg.getContextChan(); } else { target = msg.getContextUser().getNick(); } ByteArrayOutputStream out = new ByteArrayOutputStream(); try { out.write(("PRIVMSG " + target + " :\u0001ACTION ").getBytes( "ISO-8859-1")); out.write(convertEncoding(msg.getParams(), msg.getServer(), msg.getContextChan(), msg.getContextUser())); out.write(1); } catch(IOException e) { throw new BugException("Oh come on."); } msg.getServer().sendLine(out.toByteArray()); msg.getMessageDisplay().showOwnText(MessageDisplay.TYPE_ACTION, target, msg.getParams()); msg.markHandled(); } private void msg(UserCommandMsg msg) throws GeneralException { if(!checkConnected(msg)) { return; } int space = msg.getParams().indexOf(' '); if(space==-1 || space==msg.getParams().length()-1) { msg.error( "Incorrect syntax. Use: /msg <nickname or channel> <text>"); msg.markHandled(); return; } String target = msg.getParams().substring(0,space), message=msg.getParams().substring(space+1); msg.getServer().sendLine( IRCMsg.constructBytes("PRIVMSG " + target + " :", convertEncoding(message, msg.getServer(), target))); msg.getMessageDisplay().showOwnText(MessageDisplay.TYPE_MSG, target, message); msg.markHandled(); } private void raw(UserCommandMsg msg) throws GeneralException { if(!checkConnected(msg)) { return; } msg.getServer().sendLine(IRCMsg.constructBytes("", convertEncoding(msg.getParams(), msg.getServer(), null, null))); msg.markHandled(); } private void join(UserCommandMsg msg) throws GeneralException { if(!checkConnected(msg)) { return; } // Send command msg.getServer().sendLine(IRCMsg.constructBytes( "JOIN ", convertEncoding(msg.getParams(), msg.getServer(), null, null))); // Automatically remember channel in favourites (but not if key is // supplied) String[] params = msg.getParams().split(" +", 2); if(params.length == 1) { // Note, this code is basically the same as code in JoinTool.java String channel = params[0]; Preferences p = context.getSingle(Preferences.class); // Obtain prefs for server, or network if it belongs to one PreferencesGroup pg = msg.getServer().getPreferences().getAnonParent(); if(pg.get(IRCPrefs.PREF_NETWORK, null) == null) { pg=msg.getServer().getPreferences(); } // Get channels group PreferencesGroup channels = pg.getChild(IRCPrefs.PREFGROUP_CHANNELS); PreferencesGroup[] existing = channels.getAnon(); boolean found = false; for(int i=0; i<existing.length; i++) { String name = existing[i].get(IRCPrefs.PREF_NAME); if(name.equalsIgnoreCase(channel)) { found = true; break; } } if(!found) { PreferencesGroup newChan = channels.addAnon(); newChan.set(IRCPrefs.PREF_NAME, channel); newChan.set(IRCPrefs.PREF_KEY, ""); newChan.set(IRCPrefs.PREF_AUTOJOIN, p.fromBoolean(false)); } } } private boolean checkConnected(UserCommandMsg msg) throws GeneralException { if(msg.getServer() == null) { msg.error("You must send this command to a specified server."); return false; } if(!msg.getServer().isConnected()) { msg.error("Not connected."); return false; } return true; } private void ctcp(UserCommandMsg msg) throws GeneralException { if(!checkConnected(msg)) { return; } String[] params = msg.getParams().split(" ", 3); if(params.length<2 || params[0].equals("") || params[1].equals("")) { msg.error("Syntax: /ctcp <target> <command> [params]"); return; } if(params.length==2 && params[1].equalsIgnoreCase("ping")) { String[] pingWithTime = new String[3]; pingWithTime[0] = params[0]; pingWithTime[1] = "PING"; pingWithTime[2] = (System.currentTimeMillis()/1000L)+""; params = pingWithTime; } ByteArrayOutputStream baos = new ByteArrayOutputStream(); try { baos.write(("PRIVMSG " + params[0] + " :\u0001" + params[1].toUpperCase()).getBytes("ISO-8859-1")); if(params.length >= 3) { baos.write(convertEncoding( " " + params[2], msg.getServer(), params[0])); } baos.write(1); } catch(IOException e) { throw new BugException("Oh come on."); } msg.getServer().sendLine(baos.toByteArray()); msg.markHandled(); } private void ctcpreply(UserCommandMsg msg) throws GeneralException { if(!checkConnected(msg)) { return; } String[] params = msg.getParams().split(" ", 3); if(params.length<2 || params[0].equals("") || params[1].equals("")) { msg.error("Syntax: /ctcpreply <target> <command> [params]"); return; } ByteArrayOutputStream baos = new ByteArrayOutputStream(); try { baos.write(("NOTICE " + params[0] + " :\u0001" + params[1].toUpperCase()).getBytes("ISO-8859-1")); if(params.length >= 3) { baos.write(convertEncoding(params[2], msg.getServer(), params[0])); } baos.write(1); } catch(IOException e) { throw new BugException("Oh come on."); } msg.getServer().sendLine(baos.toByteArray()); msg.markHandled(); } private void away(UserCommandMsg msg) throws GeneralException { Preferences p = context.getSingle(Preferences.class); PreferencesGroup pg = p.getGroup(context.getPlugin()); boolean awayMultiServer = p.toBoolean(pg.get( IRCPrefs.PREF_AWAYMULTISERVER, IRCPrefs.PREFDEFAULT_AWAYMULTISERVER)); if(awayMultiServer) { Connections c = context.getSingle(Connections.class); Server[] servers = c.getConnected(); for(int i=0; i<servers.length; i++) { byte[] command; if(msg.getParams().length() > 0) { command = IRCMsg.constructBytes("AWAY :", convertEncoding(msg.getParams(), servers[i], null, null)); } else { command = IRCMsg.constructBytes("AWAY"); } servers[i].sendLine(command); } } else { generic(msg, 0); } msg.markHandled(); } private void generic(UserCommandMsg msg, int numParams) throws GeneralException { if(!checkConnected(msg)) { return; } String[] params = msg.getParams().split(" +", numParams+1); if(params.length < numParams) { msg.error("Syntax: /" + msg.getCommand().toLowerCase() + " requires at least " + numParams + " parameters"); return; } String command = msg.getCommand().toUpperCase(); for(int i=0; i<numParams; i++) { command += " " + params[i]; } if(params.length > numParams) { msg.getServer().sendLine(IRCMsg.constructBytes(command + " :", convertEncoding(params[numParams], msg.getServer(), null, null))); } else { msg.getServer().sendLine(IRCMsg.constructBytes(command)); } msg.markHandled(); } private void quit(UserCommandMsg msg) throws GeneralException { if(!checkConnected(msg)) { return; } String message = msg.getParams(); if(message.equals("")) { message = msg.getServer().getQuitMessage(); } msg.getServer().sendLine( IRCMsg.constructBytes("QUIT :", convertEncoding(message, msg.getServer(), null, null))); msg.markHandled(); } private void aquit(UserCommandMsg msg) throws GeneralException { Connections connections = context.getSingle(Connections.class); Server[] connected = connections.getConnected(); if(connected.length == 0) { msg.error("Not currently connected to any servers"); return; } String message = msg.getParams(); if(message.equals("")) { message = msg.getServer().getQuitMessage(); } byte[] quitLine = IRCMsg.constructBytes("QUIT :", convertEncoding(message, msg.getServer(), null, null)); for(int i=0; i<connected.length; i++) { connected[i].sendLine(quitLine); } msg.markHandled(); } private void nick(UserCommandMsg msg) throws GeneralException { if(!checkConnected(msg)) { return; } String nick = msg.getParams(); if(nick.matches("\\S+")) { msg.getServer().sendLine(IRCMsg.constructBytes("NICK "+nick)); ((ServerConnection)msg.getServer()).identify(nick, Server.IDENTIFYEVENT_NICK); msg.markHandled(); } else { msg.error("Syntax: /nick <name>"); } } private static class BanRequest { private final static long TIMEOUT = 30000L; private Server server; private String chan, nick; private long time; private BanRequest(Server server, String chan, String nick) { this.server = server; this.chan = chan; this.nick = nick; this.time = System.currentTimeMillis(); } } private List<BanRequest> banRequests = new LinkedList<BanRequest>(); private int banNumericRequestId = -1; private final static Pattern CHANNEL_NICK_REGEX = Pattern.compile( "^([#+&]\\S+) (\\S+)$"); private final static Pattern NICK_REGEX = Pattern.compile( "^(\\S+)$"); private void ban(UserCommandMsg msg) throws GeneralException { if(!checkConnected(msg)) { return; } String nick = null, chan = null; Matcher m = CHANNEL_NICK_REGEX.matcher(msg.getParams()); if(m.matches()) { chan = m.group(1); nick = m.group(2); } else if(msg.getContextChan() != null) { m = NICK_REGEX.matcher(msg.getParams()); if(m.matches()) { chan = msg.getContextChan(); } nick = msg.getParams(); } if(chan != null) { BanRequest request = new BanRequest(msg.getServer(), chan, nick); synchronized(banRequests) { if(banRequests.isEmpty()) { banNumericRequestId = context.requestMessages(NumericIRCMsg.class, this, new NumericFilter(NumericIRCMsg.RPL_USERHOST), Msg.PRIORITY_FIRST); } banRequests.add(request); } msg.getServer().sendLine(IRCMsg.constructBytes("USERHOST " + request.nick)); msg.markHandled(); } else { msg.error("Syntax: /ban <channel> <name>"); } } private final static Pattern SINGLE_USERHOST_REGEX = Pattern.compile( "^(\\S+)\\*?=[+-]([^@ ]+\\@)?(\\S*)\\s*$"); /** * Numeric IRC messages: the ban command relies on RPL_USERHOST. * @param msg Message */ public void msg(NumericIRCMsg msg) { // Check message format Matcher m = SINGLE_USERHOST_REGEX.matcher(msg.getParamISO(1)); if(!m.matches()) { // Maybe somebody else doing a userhost for more than one person return; } String nick = m.group(1), host = m.group(3); long now = System.currentTimeMillis(); synchronized(banRequests) { // Loop through all outstanding ban request for(Iterator<BanRequest> i=banRequests.iterator(); i.hasNext();) { BanRequest request = i.next(); // If timeout expired, cancel request if(request.time + BanRequest.TIMEOUT < now) { i.remove(); continue; } // If it matched if(msg.getServer() == request.server && nick.equalsIgnoreCase(request.nick)) { msg.getServer().sendLine(IRCMsg.constructBytes("MODE " + request.chan + " +b *!*@" + host)); msg.markHandled(); i.remove(); break; } } if(banRequests.isEmpty()) { context.unrequestMessages(NumericIRCMsg.class, this, banNumericRequestId); banNumericRequestId = -1; } } } }