/*
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.net.*;
import java.util.*;
import java.util.regex.*;
import javax.net.ssl.SSLSocket;
import util.StringUtils;
import util.xml.XML;
import com.leafdigital.irc.api.*;
import com.leafdigital.net.api.Network;
import com.leafdigital.prefs.api.*;
import leafchat.core.api.*;
/** Represents a connection to a server. */
public class ServerConnection implements Server, IRCPrefs
{
private final static boolean TRACECOMMS=false;
/**
* How often to send a server ping if we don't receive anything, in multiples
* of 10 seconds.
*/
private final static int
SERVERPING_10SECS_NORMAL=10,
SERVERPING_10SECS_FREQUENT=3;
private PluginContext context;
private Socket s=null;
private InputStream input;
private OutputStream output;
private String host, address;
private String reportedHost,version;
private int port;
private int secureMode;
private boolean gotEndOfMOTD=false,connectionFinished=false;
private boolean quitRequested=false;
private boolean away=false;
private boolean deferConnectionFinished=false;
private boolean suppressAutoJoin = false;
private Map<String, Object> properties=new HashMap<String, Object>();
private ConnectionsSingleton connections;
private Throwable error=null;
/** Current details of this user */
private String ourNick,ourUser,ourHost;
/** @return How often (in 10-second units) we should send server pings if no data */
private int getServerPingFrequency()
{
Preferences p=context.getSingle(Preferences.class);
if(p.toBoolean(p.getGroup(context.getPlugin()).get(PREF_FREQUENTPINGS,PREFDEFAULT_FREQUENTPINGS)))
return SERVERPING_10SECS_FREQUENT;
return SERVERPING_10SECS_NORMAL;
}
ServerConnection(ConnectionsSingleton cs,PluginContext context)
{
this.connections=cs;
this.context=context;
}
@Override
public String toString()
{
return host+":"+port;
}
@Override
public void beginConnect(String host,int port,Server.ConnectionProgress cp)
{
this.host = host;
this.port = port;
if(!getPreferences().exists(PREF_SECUREMODE))
{
// When secure mode is not set, autodetect unless using a standard port
if(port == 6667 || port == 7000)
{
getPreferences().set(PREF_SECUREMODE, PREF_SECUREMODE_NONE);
}
}
String secureString = getPreferences().get(PREF_SECUREMODE, PREF_SECUREMODE_OPTIONAL);
if(secureString.equals(PREF_SECUREMODE_NONE))
{
secureMode = Network.SECURE_NONE;
}
else if(secureString.equals(PREF_SECUREMODE_REQUIRED))
{
secureMode = Network.SECURE_REQUIRED;
}
else
{
secureMode = Network.SECURE_OPTIONAL;
}
new ServerThread(cp);
}
@Override
public boolean isConnected()
{
return s!=null;
}
@Override
public boolean isConnectionFinished()
{
return connectionFinished;
}
@Override
public NetworkException getError()
{
if(error==null) return null;
if(error instanceof NetworkException) return (NetworkException) error;
return new NetworkException(error);
}
@Override
public void sendLine(byte[] line)
{
sendServerRequest(line);
}
// WHO
// RPL_WHOREPLY
// RPL_ENDOFWHO
//private final static
/**
* Details about a particular type of command we track.
*/
private class TrackedCommand
{
private String command;
private int[] includedNumerics;
private int finalNumeric;
private LinkedList<Integer> queue = new LinkedList<Integer>();
/**
* @param command Command name in sent message
* @param includedNumerics Numerics that belong to the response
* @param finalNumeric Numeric that indicates the end of a response
*/
TrackedCommand(String command,int[] includedNumerics,
int finalNumeric)
{
this.command=command;
this.includedNumerics=includedNumerics;
this.finalNumeric=finalNumeric;
// Index the command name in the global list
trackedCommandNames.put(command,this);
// Index the required numerics in the global list
int[] allNumerics=new int[includedNumerics.length+1];
System.arraycopy(includedNumerics,0,allNumerics,1,includedNumerics.length);
includedNumerics[0]=finalNumeric;
for(int i=0;i<allNumerics.length;i++)
{
Integer key = allNumerics[i];
if(trackedCommandNumerics.containsKey(key))
throw new BugException("Duplicate numeric in tracked commands: "+key);
trackedCommandNumerics.put(key,this);
}
}
/**
* Adds a new request to the queue.
* @param trackingNumber Tracking number of the request
*/
synchronized void queueRequest(int trackingNumber)
{
queue.addLast(trackingNumber);
}
/**
* Handles a message that belongs to this
* @param msg
*/
synchronized void handleMessage(NumericIRCMsg msg)
{
// Unexpected message, doesn't correspond to a queue
if(queue.isEmpty()) return;
// Set response ID
msg.setResponseID(queue.getFirst().intValue());
// If this was the last one, pull it out of the queue
if(msg.getNumeric()==finalNumeric) queue.removeFirst();
}
@Override
public String toString()
{
String result="[TrackedCommand "+command;
for(int i=0;i<includedNumerics.length;i++)
{
result+=","+includedNumerics[i];
}
result+=" -> "+finalNumeric;
return result;
}
}
/** Initialises list of tracked commands */
void initTrackedCommands()
{
new TrackedCommand("WHO",
new int[] {NumericIRCMsg.RPL_WHOREPLY},
NumericIRCMsg.RPL_ENDOFWHO);
};
/** Index of tracked commands by involved numerics */
private HashMap<Integer, TrackedCommand> trackedCommandNumerics =
new HashMap<Integer, TrackedCommand>();
/** Index of tracked commands by command name */
private HashMap<String, TrackedCommand> trackedCommandNames =
new HashMap<String, TrackedCommand>();
/** ID used for next globally unique tracking number */
private static int nextTrackingNumber=1;
private static Object trackingNumberSynch=new Object();
private int getTrackingNumber(String command)
{
TrackedCommand tc=trackedCommandNames.get(command);
if(tc==null) return UNTRACKED_REQUEST;
int number;
synchronized(trackingNumberSynch)
{
number=nextTrackingNumber++;
}
tc.queueRequest(number);
return nextTrackingNumber;
}
@Override
public int sendServerRequest(byte[] line)
{
// Get command
int commandLength=0;
for(;commandLength<line.length;commandLength++)
{
if(line[commandLength]==' ') break;
}
String command;
try
{
command=new String(line,0,commandLength,"US-ASCII");
}
catch(UnsupportedEncodingException e)
{
throw new BugException(e);
}
// Track when quit is requested
if(command.equalsIgnoreCase("QUIT"))
{
quitRequested=true;
}
// Send
buffer.send(line);
return getTrackingNumber(command);
}
/** Buffer used to keep send rate safe */
class SendBuffer implements Runnable
{
private final static int
MILLISECONDS_PER_LINE=2000,
BURST_SIZE=5;
private LinkedList<byte[]> backlog=null;
private long lastSend=0;
private int sendCount=0;
synchronized void send(byte[] line)
{
// If there's anything in the send buffer, just add this line
if(backlog!=null)
{
backlog.addLast(line);
return;
}
// Waiting 2N seconds reduces your send count by N
long now=System.currentTimeMillis();
if(sendCount>0)
{
long secondsDelay=(now-lastSend)/MILLISECONDS_PER_LINE;
sendCount-=secondsDelay;
if(sendCount<0) sendCount=0;
}
// Now increment send count for this message
sendCount++;
if(sendCount>BURST_SIZE)
{
backlog = new LinkedList<byte[]>();
backlog.addLast(line);
(new Thread(this,"Send buffer")).start();
}
else
{
lastSend=now;
connections.informSend(ServerConnection.this,line);
}
}
@Override
public void run()
{
while(true)
{
byte[] nextLine;
synchronized(this)
{
if(backlog.size()==0)
{
backlog=null;
return;
}
nextLine=backlog.removeFirst();
}
try
{
Thread.sleep(MILLISECONDS_PER_LINE);
}
catch(InterruptedException ie)
{
}
connections.informSend(ServerConnection.this,nextLine);
lastSend=System.currentTimeMillis();
}
}
}
SendBuffer buffer=new SendBuffer();
/**
* Message: Send line.
* @param msg Message
* @throws NetworkException
*/
public synchronized void msg(ServerSendMsg msg) throws NetworkException
{
if(msg.isHandled()) return;
if(s!=null) internalSend(msg.getLine(),false);
msg.markHandled();
}
private synchronized void internalSend(byte[] abLine,boolean bFlush) throws NetworkException
{
if(s==null) throw new NetworkException("Not connected");
try
{
byte[] complete=new byte[abLine.length+2];
System.arraycopy(abLine,0,complete,0,abLine.length);
complete[abLine.length]=13; // CR
complete[abLine.length+1]=10; // LF
output.write(complete);
if(bFlush) output.flush();
if(TRACECOMMS) System.err.println(">> "+new String(abLine));
}
catch(IOException e)
{
context.log("Error sending data to server",e);
disconnect();
}
}
@Override
public void disconnect()
{
synchronized(this)
{
if(s==null) return;
try
{
s.close();
}
catch(IOException e)
{
}
s=null; // Probably not required, but just to make sure thread doesn't mess
}
}
@Override
public void disconnectGracefully()
{
try
{
IRCEncoding ie=connections.getPluginContext().getSingle(IRCEncoding.class);
IRCEncoding.EncodingInfo ei=ie.getEncoding(this,null,null);
internalSend(IRCMsg.constructBytes(
"QUIT :",ei.convertOutgoing(getQuitMessage())),true);
}
catch(GeneralException ge)
{
}
disconnect();
}
private long lastLineTime;
/** @return Time at which last line was received from server */
long getLastLineTime()
{
return lastLineTime;
}
private int waitingTimes=0;
@Override
public synchronized boolean isSecureConnection()
{
return s!=null && (s instanceof SSLSocket);
}
/** Thread that connects to server and reads server data */
private class ServerThread extends Thread
{
private ConnectionProgress cp;
ServerThread(Server.ConnectionProgress cp)
{
super("Server thread - "+host+":"+port);
this.cp=cp;
start();
}
@Override
public void run()
{
// Connect to socket
try
{
cp.progress("Looking up <key>"+XML.esc(host)+"</key>...");
InetAddress ia=InetAddress.getByName(host);
address = ia.getHostAddress();
cp.progress("Connecting to <key>"+ia.getHostAddress()+"</key>...");
s=context.getSingle(Network.class).connect(host, port, 30000, secureMode);
s.setSoTimeout(10000);
if(isSecureConnection())
{
cp.progress("<key>Connected! Connection is <key>secure</key>.</key>");
}
else
{
cp.progress("<key>Connected! Connection is <key>unencrypted</key>.</key>");
}
input=s.getInputStream();
output=s.getOutputStream();
}
catch(Throwable t)
{
s=null;
error=t;
}
try
{
// Start listening for server send messages
ServerFilter sf=new ServerFilter(ServerConnection.this);
connections.getPluginContext().requestMessages(
ServerSendMsg.class,ServerConnection.this,
sf,Msg.PRIORITY_NORMAL);
// To track nickname send
connections.getPluginContext().requestMessages(
NumericIRCMsg.class,ServerConnection.this,sf,Msg.PRIORITY_FIRST);
connections.getPluginContext().requestMessages(
UserSourceIRCMsg.class,ServerConnection.this,sf,Msg.PRIORITY_FIRST); // Includes nick
connections.getPluginContext().requestMessages(
ServerConnectionFinishedMsg.class,ServerConnection.this,sf,Msg.PRIORITY_FIRST);
}
catch(Throwable t)
{
try
{
s.close();
}
catch(IOException e1)
{
}
s=null;
error=t;
}
// Connection is over, so no need for progress
cp=null;
// Notify that either connection or error occurred
synchronized(ServerConnection.this)
{
ServerConnection.this.notifyAll();
if(error!=null) return;
}
// OK we're connected, inform (now guaranteed to send disconnected too)
connections.informConnected(ServerConnection.this);
try
{
// Repeatedly read server data
byte[] abBuffer=new byte[510];
int iPos=0;
int delayCount=0;
while(true)
{
// Keep reading bytes until we get a CR or LF
try
{
int iByte=input.read();
if(iByte==-1 || s==null) break;
delayCount=0;
if(iByte==10 || iByte==13)
{
if(iPos>0)
{
byte[] abLine=new byte[iPos];
System.arraycopy(abBuffer,0,abLine,0,iPos);
lastLineTime=System.currentTimeMillis();
connections.informLine(ServerConnection.this,abLine);
if(TRACECOMMS) System.err.println("<< "+new String(abLine));
iPos=0;
}
}
else
{
if(iPos>=abBuffer.length)
throw new NetworkException("IRC server sent a line longer than 510 bytes (rogue server?)");
abBuffer[iPos++]=(byte)iByte;
}
}
catch(SocketTimeoutException e)
{
delayCount++;
int target=getServerPingFrequency();
if(delayCount==target)
{
// Hrm, looks like we haven't received anything in 100 seconds
// (more than most server ping timeouts). Let's send a TIME,
// supposing the server is still around.
synchronized(ServerConnection.this)
{
if(s==null) break;
waitingTimes++;
internalSend(IRCMsg.constructBytes("TIME"),true);
}
}
else if(delayCount>=target+2)
{
// Still not got anything? That's bad. Let's drop the connection
throw e;
}
}
}
}
catch(Throwable t)
{
error=t;
}
finally
{
synchronized(ServerConnection.this)
{
try
{
if(s!=null) s.close();
}
catch(IOException e)
{
}
s=null;
}
connections.informDisconnected(ServerConnection.this,error);
connections.getPluginContext().unrequestMessages(
null,ServerConnection.this,PluginContext.ALLREQUESTS);
}
}
}
@Override
public PreferencesGroup getPreferences()
{
if(host==null) throw new BugException("Not connected");
// Use reported host if we have one, otherwise regular host
String sSearch=reportedHost;
if(sSearch==null) sSearch=host;
// Find preferences group for server
Preferences p=
connections.getPluginContext().getSingle(Preferences.class);
PreferencesGroup pg=p.getGroup(connections.getPlugin()).getChild("servers");
PreferencesGroup pgNow=pg.findAnonGroup(PREF_HOST, sSearch, true, true);
// Make a new one if there wasn't one already
if(pgNow==null)
{
pgNow=pg.addAnon();
pgNow.set(PREF_HOST,sSearch);
pgNow.set(PREF_PORTRANGE,port+"");
}
return pgNow;
}
/**
* Called to set the server name (what the server thinks its name is, not what
* was connected with)
* @param reportedHost
*/
private void setReportedHost(String reportedHost)
{
this.reportedHost = reportedHost;
Preferences p =
connections.getPluginContext().getSingle(Preferences.class);
PreferencesGroup pg = p.getGroup(connections.getPlugin()).getChild("servers");
PreferencesGroup thisGroup=null;
PreferencesGroup fromRedirector = null;
// Find preferences group for server
PreferencesGroup existingGroup = pg.findAnonGroup(PREF_HOST, reportedHost, true, true);
if(existingGroup != null) // Existing group
{
// Mark that we've actually had this host reported now
existingGroup.set(PREF_REPORTED, "yes");
thisGroup = existingGroup;
// Set host option in case it's a different case
existingGroup.set(PREF_HOST, reportedHost);
// Are there settings for original connected host?
if(!reportedHost.equalsIgnoreCase(host))
{
PreferencesGroup previousGroup = pg.findAnonGroup(PREF_HOST, host, true, true);
if(previousGroup != null)
{
// Discard those settings, if we've never really connected (reported
// connection) to there and they weren't added by hand and it's not
// a redirector server.
if(previousGroup.get(PREF_REPORTED, "no").equals("no") &&
previousGroup.get(PREF_HANDADDED, "no").equals("no") &&
previousGroup.get(PREF_REDIRECTOR, "no").equals("no"))
{
previousGroup.remove();
}
else if(previousGroup.get(PREF_REDIRECTOR, "no").equals("yes"))
{
fromRedirector = previousGroup;
}
}
}
}
else
{
// Are there settings for original connected host?
if(!reportedHost.equalsIgnoreCase(host))
{
PreferencesGroup previousGroup = pg.findAnonGroup(PREF_HOST, host, true, true);
// Check if it was a redirector address (like irc.dal.net)
if(previousGroup == null || (previousGroup.get(PREF_REPORTED, "no").equals("no") &&
previousGroup.get(PREF_REDIRECTOR, "no").equals("no")))
{
boolean redirector = false;
try
{
// Get address list. There must be multiple entries.
InetAddress[] addresses1 = InetAddress.getAllByName(host);
if(addresses1.length > 1)
{
redirector = true;
}
}
catch(UnknownHostException e)
{
// This can't really happen, we already looked it up to connect.
}
if(redirector)
{
if(previousGroup == null)
{
// Make a new entry for the redirector
previousGroup = pg.addAnon();
previousGroup.set(PREF_HOST, host);
}
// Mark it as redirector
previousGroup.set(PREF_REDIRECTOR, "yes");
}
}
if(previousGroup != null)
{
// Move those settings to this, if we've never really connected (reported
// connection) to there and it's not a redirector server
if(previousGroup.get(PREF_REPORTED, "no").equals("no") &&
previousGroup.get(PREF_REDIRECTOR, "no").equals("no"))
{
previousGroup.set(PREF_HOST, reportedHost);
thisGroup = previousGroup;
}
else if(previousGroup.get(PREF_REDIRECTOR, "no").equals("yes"))
{
fromRedirector = previousGroup;
}
}
}
}
// If we still haven't got preferences, make them up now
if(thisGroup == null)
{
thisGroup = pg.addAnon();
thisGroup.set(PREF_HOST, reportedHost);
thisGroup.set(PREF_REPORTED, "yes");
}
// Only consider if refusednetwork is not set and it's not already in a
// network
if(thisGroup.get(PREF_REFUSEDNETWORK,"no").equals("no") &&
thisGroup.getAnonHierarchical(PREF_NETWORK,null)==null)
{
try
{
// Don't send connection-finished until user has decided
deferConnectionFinished=true;
// Consider all existing networks to see if there's one with a matching
// suffix.
PreferencesGroup pgNetwork=findMatchingNetwork(pg,reportedHost);
if(pgNetwork!=null)
{
// Offer add to that network
switch(connections.informRearrange(new ServerRearrangeMsg(
this,reportedHost,pgNetwork.get("network"))))
{
case ServerRearrangeMsg.CONFIRM:
// Actually add (automatically removes from previous parent)
pgNetwork.addAnon(thisGroup,PreferencesGroup.ANON_LAST);
break;
case ServerRearrangeMsg.REJECT:
thisGroup.set(PREF_REFUSEDNETWORK,"yes");
break;
default:
// Indecisive user
}
}
else
{
// Determine suffix: if last segment of hostname is >2 characters,
// use last 2 segments (e.g. .dal.net). If it's 2 characters, use last
// 3 (e.g. mynet.co.uk).
String[] segments = reportedHost.split("\\.");
if(segments.length >= 3)
{
String suffix;
if(segments[segments.length-1].length() >= 3)
{
suffix = "." + segments[segments.length - 2] + "." + segments[segments.length - 1];
}
else
{
suffix = "." + segments[segments.length - 3] + "." + segments[segments.length - 2] +
"." + segments[segments.length - 1];
}
// Consider all existing servers to see if there's one with that suffix that
// doesn't have refusednetwork=yes. If so, offer add for those servers to
// a new network.
PreferencesGroup other;
if(fromRedirector != null)
{
other = fromRedirector;
}
else
{
other = findMatchingServer(pg, suffix, thisGroup);
}
if(other != null)
{
String network = suffix.substring(1);
// Offer combine with those servers to make network; or if it came
// from a redirector, do that automatically
int check;
if(fromRedirector != null)
{
check = ServerRearrangeMsg.CONFIRM;
}
else
{
check = connections.informRearrange(new ServerRearrangeMsg(
this, reportedHost, network, other.get("host")));
}
switch(check)
{
case ServerRearrangeMsg.CONFIRM:
// Make new server to remember other one
PreferencesGroup newOther = other.addAnon();
try
{
newOther.set(PREF_HOST, other.get(PREF_HOST));
if(other.exists(PREF_REPORTED))
{
newOther.set(PREF_REPORTED, other.get(PREF_REPORTED));
}
if(other.exists(PREF_REDIRECTOR))
{
newOther.set(PREF_REDIRECTOR, other.get(PREF_REDIRECTOR));
}
}
catch(BugException e)
{
// If any of this fails, remove the newly added entry
newOther.remove();
throw e;
}
// Turn other server into network
other.set(PREF_NETWORK, network);
other.set(PREF_NETWORKSUFFIX, suffix);
other.unset(PREF_HOST);
other.unset(PREF_REPORTED);
other.unset(PREF_REDIRECTOR);
// Add this one to the new network
other.addAnon(thisGroup, PreferencesGroup.ANON_LAST);
break;
case ServerRearrangeMsg.REJECT:
thisGroup.set(PREF_REFUSEDNETWORK, "yes");
break;
default:
// Indecisive user
}
}
}
}
}
finally
{
deferConnectionFinished=false;
}
}
}
/**
* Recursively searches for server with the given suffix.
* @param parent Parent (start by calling with 'servers' root)
* @param suffix Suffix to match (not case-sensitive)
* @param exclude Exclude this group from consideration
* @return Matching server or null if none
*/
private static PreferencesGroup findMatchingServer(
PreferencesGroup parent,String suffix,PreferencesGroup exclude)
{
// If it isn't the exclude one, and matches, and isn't in a network, and
// hasn't refused the network
if(parent != exclude &&
parent.exists(PREF_HOST) &&
parent.get(PREF_HOST).toLowerCase().endsWith(suffix.toLowerCase()) &&
parent.getAnonHierarchical(PREF_NETWORK, null) == null &&
parent.get(PREF_REFUSEDNETWORK, "no").equals("no"))
{
return parent;
}
// Try children too
PreferencesGroup[] children = parent.getAnon();
for(int i=0; i<children.length; i++)
{
PreferencesGroup found = findMatchingServer(children[i], suffix, exclude);
if(found != null)
{
return found;
}
}
return null;
}
/**
* Recursively searches for a network that matches the given host.
* @param pgParent Parent start by calling with 'servers' root)
* @param sHost Hostname to match (not case-sensitive)
* @return Prefs for matching network, or null if none
*/
private static PreferencesGroup findMatchingNetwork(
PreferencesGroup pgParent,String sHost)
{
// If it matches, return it
if(sHost.toLowerCase().endsWith(
pgParent.get(PREF_NETWORKSUFFIX,"!fail").toLowerCase()))
return pgParent;
// Otherwise try children
PreferencesGroup[] apgChildren=pgParent.getAnon();
for(int i=0;i<apgChildren.length;i++)
{
PreferencesGroup pgFound=findMatchingNetwork(apgChildren[i],sHost);
if(pgFound!=null) return pgFound;
}
// Nope, wasn't herre
return null;
}
@Override
public String getISupport(String sParameter)
{
return mISupport.get(sParameter);
}
/** Map of information from ISUPPORT */
private Map<String, String> mISupport = new HashMap<String, String>();
/** Regex patterns used for interpreting ISUPPORT */
private final static Pattern
ISUPPORT_NEGATE=Pattern.compile("-([A-Z0-9]{1,20})"),
ISUPPORT_EMPTY=Pattern.compile("([A-Z0-9]{1,20})=?"),
ISUPPORT_VALUE=Pattern.compile("([A-Z0-9]{1,20})=([A-Za-z0-9!\"#$%&'()*+,\\-./:;<=>?@\\[\\\\\\]^_`{|}~]+)"),
ISUPPORT_ESCAPE=Pattern.compile("\\\\x([0-9A-Fa-f]{2})");
/**
* Called to set the ISUPPORT data.
* @param nim 005 numeric message
*/
private void setISupport(NumericIRCMsg nim)
{
// This is an implementation of the grammar defined in
// http://www.irc.org/tech_docs/draft-brocklesby-irc-isupport-03.txt
byte[][] aabParams=nim.getParams();
for(int iParam=0;iParam<aabParams.length;iParam++)
{
// Ignore postfix param
if(iParam==aabParams.length-1 && nim.includesPostfix()) break;
// Convert param to string
String s=IRCMsg.convertISO(aabParams[iParam]);
Matcher m=ISUPPORT_NEGATE.matcher(s);
if(m.matches()) // Negate
mISupport.remove(m.group(1));
else
{
m=ISUPPORT_EMPTY.matcher(s);
if(m.matches())
mISupport.put(m.group(1),"");
else
{
m=ISUPPORT_VALUE.matcher(s);
if(m.matches())
{
String sValue=m.group(2);
// Implement \x20 style escapes
Matcher mEscapes=ISUPPORT_ESCAPE.matcher(sValue);
StringBuffer sb = new StringBuffer();
while(mEscapes.find())
{
char c=(char)Integer.parseInt(mEscapes.group(1),16);
mEscapes.appendReplacement(sb,""+c);
}
mEscapes.appendTail(sb);
mISupport.put(m.group(1),sb.toString());
}
else
{
// If we fail to match a token, ignore it
}
}
}
}
}
@Override
public String getChanTypes()
{
String sTypes=getISupport("CHANTYPES");
if(sTypes!=null)
return sTypes;
else
return "#&";
}
@Override
public int getMaxTopicLength()
{
String maxLength=getISupport("TOPICLEN");
if(maxLength!=null && maxLength.matches("[0-9]+"))
return Integer.parseInt(maxLength);
else
return 80;
}
@Override
public String getStatusMsg()
{
String sStatusMsg=getISupport("STATUSMSG");
if(sStatusMsg!=null)
return sStatusMsg;
else
{
// Correct behaviour according to the spec would be to assume no support.
// But since some servers (esper.net I'm looking at you) don't report
// STATUSMSG, we'll default to the PREFIX (or @+)
Matcher m=ISUPPORTVAL_PREFIX.matcher(getValidPrefix());
m.matches(); // It does, see below code for why
return m.group(2);
}
}
private final static Pattern ISUPPORTVAL_PREFIX=Pattern.compile("\\((.*?)\\)(.*)");
private final static Pattern ISUPPORTVAL_CHANMODES=Pattern.compile("(.*?),(.*?),(.*?),(.*?)(,.*)?");
private String getValidPrefix()
{
String sPrefix=getISupport("PREFIX");
if(sPrefix!=null)
{
Matcher m=ISUPPORTVAL_PREFIX.matcher(sPrefix);
if(m.matches() && m.group(1).length()==m.group(2).length())
return sPrefix;
}
return "(ov)@+";
}
private String getValidChanModes()
{
String sChanModes=getISupport("CHANMODES");
if(sChanModes!=null)
{
Matcher m=ISUPPORTVAL_CHANMODES.matcher(sChanModes);
if(m.matches())
return sChanModes;
}
return "b,k,l,imnpstr";
}
@Override
public StatusPrefix[] getPrefix()
{
String sPrefix=getValidPrefix();
Matcher m=ISUPPORTVAL_PREFIX.matcher(sPrefix);
if(!m.matches()) assert false;
String sModes=m.group(1),sPrefixes=m.group(2);
StatusPrefix[] asp=new StatusPrefix[sModes.length()];
for(int i=0;i<sModes.length();i++)
{
asp[i]=new StatusPrefix(sModes.charAt(i),sPrefixes.charAt(i));
}
return asp;
}
@Override
public boolean isPrefixAtLeast(char prefix,char required)
{
Server.StatusPrefix[] prefixes=getPrefix();
for(int i=0;i<prefixes.length;i++)
{
if(prefixes[i].getPrefix()==prefix) return true;
if(prefixes[i].getPrefix()==required) break;
}
return false;
}
@Override
public String getChanModes()
{
String
sChanModes=getValidChanModes(),
sPrefix=getValidPrefix();
StringBuffer sbModes=new StringBuffer();
Matcher m=ISUPPORTVAL_CHANMODES.matcher(sChanModes);
if(!m.matches()) assert false;
for(int i=1;i<=4;i++) sbModes.append(m.group(i));
m=ISUPPORTVAL_PREFIX.matcher(sPrefix);
if(!m.matches()) assert false;
sbModes.append(m.group(1));
return sbModes.toString();
}
@Override
public int getChanModeType(char cMode)
{
String sChanModes=getValidChanModes();
Matcher m=ISUPPORTVAL_CHANMODES.matcher(sChanModes);
if(!m.matches()) assert false;
for(int i=1;i<=4;i++) if(m.group(i).indexOf(cMode)!=-1) return i;
// Things in prefix are assigned CHANMODE_USERSTATUS
String sPrefix=getValidPrefix();
m=ISUPPORTVAL_PREFIX.matcher(sPrefix);
if(!m.matches()) assert false;
if(m.group(1).indexOf(cMode)!=-1) return CHANMODE_USERSTATUS;
return CHANMODE_UNKNOWN;
}
@Override
public int getChanModeParamCount()
{
String modes=getISupport("MODES");
if(modes!=null)
{
try
{
return Integer.parseInt(modes);
}
catch(NumberFormatException nfe)
{
}
}
return 3;
}
@Override
public Object getProperty(Class<?> c,String sKey)
{
synchronized(properties)
{
return properties.get(c+"\n"+sKey);
}
}
@Override
public Object setProperty(Class<?> c,String sKey,Object oValue)
{
synchronized(properties)
{
return properties.put(c + "\n" + sKey, oValue);
}
}
boolean hasGotEndOfMOTD()
{
return gotEndOfMOTD;
}
/**
* Message: Nickname change. Used to update our own nick.
* @param msg Message
*/
public void msg(NickIRCMsg msg)
{
// Update nickname if nick applies to us
if(msg.getSourceUser().getNick().equals(ourNick))
{
ourNick=msg.getNewNick();
}
}
/**
* Message: Any message. Used to track our own username/host.
* @param msg Message
*/
public void msg(UserSourceIRCMsg msg)
{
// If it's our nickname and we don't already have a un/host:
if(ourUser==null && !msg.getSourceUser().getHost().equals("") &&
msg.getSourceUser().getNick().equals(ourNick))
{
ourUser=msg.getSourceUser().getUser();
ourHost=msg.getSourceUser().getHost();
}
}
/** Keep track of how many times we've tried different nicks */
int iNickRetries=0;
/**
* Message: Numerics. Used for connection nickname renaming and to track
* various other info.
* @param msg Message
* @throws GeneralException
*/
public void msg(NumericIRCMsg msg) throws GeneralException
{
// Handle tracking response numbers
TrackedCommand tc=
trackedCommandNumerics.get(msg.getNumeric());
if(tc!=null) tc.handleMessage(msg);
// Handle nickname changing
if(msg.getNumeric()==NumericIRCMsg.ERR_NICKNAMEINUSE ||
msg.getNumeric()==NumericIRCMsg.ERR_ERRONEUSNICKNAME)
{
// If this happens after connection, ignore it. Automatic changing should
// only apply on connect (and doesn't work later, because the iNickRetries
// never gets reset...)
if(ourNick!=null) return;
// If the message isn't valid (no param), ignore it
if(msg.getParams().length < 2) return;
// ...Better change it then!
String sAttemptNick=msg.getParamISO(1);
if(iNickRetries<3)
{
// Add _ characters at the end
sAttemptNick+="_";
}
else if(iNickRetries<6)
{
if(iNickRetries==4)
{
// Trim off the _ characters at end
sAttemptNick=sAttemptNick.substring(0,sAttemptNick.length()-3);
}
// Add _ characters at beginning
sAttemptNick="_"+sAttemptNick;
}
else
{
// Pick a random (and 9-letter safe) nick
Random r=new Random();
sAttemptNick="lcuser"+r.nextInt(1000);
}
sendLine(IRCMsg.constructBytes("NICK "+sAttemptNick));
iNickRetries++;
return;
}
// Update nickname from server numeric if needed (should happen on first line
// after connection registration, but on some cases it doesn't, we get * or
// blank for various reasons)
if(ourNick==null && !msg.getTarget().matches("\\*?")) ourNick=msg.getTarget();
switch(msg.getNumeric())
{
case NumericIRCMsg.RPL_ISUPPORT:
setISupport(msg);
msg.markHandled();
break;
case NumericIRCMsg.RPL_MYINFO:
if(msg.getParams().length >= 3)
{
version = msg.getParamISO(2);
}
if(msg.getParams().length >= 2)
{
String claimedHost = msg.getParamISO(1);
// Some idiot servers put colours in this, so strip them out
claimedHost = context.getSingle(IRCEncoding.class).
processEscapes(claimedHost, false, false);
setReportedHost(claimedHost);
}
break;
case NumericIRCMsg.RPL_ENDOFMOTD:
if(!gotEndOfMOTD)
{
gotEndOfMOTD=true;
identify(ourNick,Server.IDENTIFYEVENT_CONNECT);
}
break;
case NumericIRCMsg.RPL_WELCOME:
// If username and hostname are in the welcome message, then obtain them
String sText=IRCMsg.convertISO(msg.getParams()[msg.getParams().length-1]);
Matcher m=Pattern.compile("^.* "+StringUtils.regexpEscape(ourNick)+"!(.*?)@(.*?)( .*)?$").matcher(sText);
if(m.matches())
{
ourUser=m.group(1);
ourHost=m.group(2);
}
break;
case NumericIRCMsg.RPL_TIME:
synchronized(ServerConnection.this)
{
if(waitingTimes>0)
{
waitingTimes--;
msg.markHandled();
}
}
break;
case NumericIRCMsg.RPL_NOWAWAY:
away=true;
break;
case NumericIRCMsg.RPL_UNAWAY:
away=false;
break;
}
}
/**
* Message: Connection finished. Used to provide the boolean and to handle
* auto-join and to update security settings.
* @param msg Message
* @throws GeneralException
*/
public void msg(ServerConnectionFinishedMsg msg) throws GeneralException
{
// Update security mode if it's currently set to optional - note we
// only do this on connection finished so we know it really worked
boolean optional =
getPreferences().get(PREF_SECUREMODE, PREFDEFAULT_SECUREMODE).
equals(PREF_SECUREMODE_OPTIONAL);
if(optional)
{
if(isSecureConnection())
{
getPreferences().set(PREF_SECUREMODE, PREF_SECUREMODE_REQUIRED);
}
else
{
getPreferences().set(PREF_SECUREMODE, PREF_SECUREMODE_NONE);
}
}
connectionFinished=true;
autojoin();
}
private void autojoin() throws GeneralException
{
if(suppressAutoJoin)
{
return;
}
Preferences p=connections.getPluginContext().getSingle(Preferences.class);
PreferencesGroup serverPrefs=getPreferences(),networkPrefs=serverPrefs.getAnonParent();
PreferencesGroup[] serverAndNetwork;
if(networkPrefs==null)
serverAndNetwork=new PreferencesGroup[] {serverPrefs};
else
serverAndNetwork=new PreferencesGroup[] {serverPrefs,networkPrefs};
for(int group=0;group<serverAndNetwork.length;group++)
{
PreferencesGroup[] channels=
serverAndNetwork[group].getChild(PREFGROUP_CHANNELS).getAnon();
for(int channel=0;channel<channels.length;channel++)
{
PreferencesGroup thisChan=channels[channel];
if(p.toBoolean(thisChan.get(PREF_AUTOJOIN)))
{
String name=thisChan.get(PREF_NAME),key=thisChan.get(PREF_KEY);
sendLine(IRCMsg.constructBytes("JOIN "+name+(key.length()>0 ? " "+key:"")));
}
}
}
}
@Override
public void suppressAutoJoin()
{
suppressAutoJoin = true;
}
/**
* Triggers identify in response to some event.
* @param nick Current/new nickname
* @param identifyEvent Event type
*/
public void identify(String nick,String identifyEvent)
{
String sPassword=getNickPassword(nick);
if(sPassword!=null && !sPassword.equals("") && shouldIdentify(identifyEvent))
{
String sCommand=getIdentifyPattern();
sCommand=StringUtils.replace(sCommand,"${password}",sPassword);
sCommand=StringUtils.replace(sCommand,"${nick}",nick);
// For this we use a special message display that doesn't display normal
// results, only error. This is because we don't want to display the
// password if they use /msg
final MessageDisplay actual = getDefaultMessageDisplay();
MessageDisplay secret = new MessageDisplay()
{
@Override
public void showError(String message)
{
actual.showError(message);
}
@Override
public void showInfo(String message)
{
}
@Override
public void showOwnText(int type, String target, String text)
{
}
@Override
public void clear()
{
}
};
context.getSingle(Commands.class).doCommand(
sCommand, this, null, null, secret, false);
}
}
@Override
public MessageDisplay getDefaultMessageDisplay() throws IllegalStateException
{
return connections.getDefaultMessageDisplay().getMessageDisplay(this);
}
@Override
public String getOurNick()
{
return ourNick;
}
@Override
public String getOurUser()
{
return ourUser;
}
@Override
public String getOurHost()
{
return ourHost;
}
@Override
public int getApproxPrefixLength()
{
return
3+ // Standard characers : ! @
(ourNick==null ? 20 : ourNick.length()) + // Nick
(ourUser==null ? 20 : ourUser.length()) + // User
(ourHost==null ? 30 : ourHost.length()) + // Host
20; // Allowance in case server is munging prefixes and making them longer
// Note: If servers actually reported in 005 when they were going to do the
// above then we could take note and only add the allowance if they were.
// But they don't.
}
@Override
public String getReportedHost()
{
return reportedHost;
}
@Override
public String getReportedOrConnectedHost()
{
return reportedHost!=null ? reportedHost : host;
}
@Override
public String getConnectedHost()
{
return host;
}
@Override
public int getConnectedPort()
{
return port;
}
@Override
public String getConnectedIpAddress()
{
if(address == null)
{
return null;
}
return address;
}
@Override
public String getVersion()
{
return version;
}
@Override
public String getDefaultNick()
{
return getPreferences().getAnonHierarchical(PREF_DEFAULTNICK);
}
@Override
public String getNickPassword(String nick)
{
PreferencesGroup pg=getPreferences();
while(pg!=null)
{
PreferencesGroup[] apgNicks=pg.getChild(PREFGROUP_NICKS).getAnon();
// Get from this group if we've got it
for(int iNick=0;iNick<apgNicks.length;iNick++)
{
if(apgNicks[iNick].get(PREF_NICK).equals(nick))
{
String sPassword=apgNicks[iNick].get(PREF_PASSWORD,null);
return sPassword;
}
}
// OK try parent
pg=pg.getAnonParent();
}
return "";
}
@Override
public boolean shouldIdentify(String identifyEvent)
{
return getPreferences().getAnonHierarchical(PREF_AUTOIDENTIFY,"y").
equals("y");
}
@Override
public String getIdentifyCommand()
{
return getPreferences().getAnonHierarchical(PREF_IDENTIFYCOMMAND,PREFDEFAULT_IDENTIFYCOMMAND);
}
@Override
public String getIdentifyPattern()
{
return getPreferences().getAnonHierarchical(PREF_IDENTIFYPATTERN,PREFDEFAULT_IDENTIFYPATTERN);
}
@Override
public String getQuitMessage()
{
return getPreferences().getAnonHierarchical(PREF_QUITMESSAGE,PREFDEFAULT_QUITMESSAGE);
}
@Override
public String getServerPassword()
{
String password=getPreferences().getAnonHierarchical(PREF_SERVERPASSWORD,"");
if(password.equals("")) return null;
return password;
}
@Override
public String getCurrentShortName()
{
return connections.getShortName(this);
}
/** Map from mask to Long (time when silence was set up) */
private Map<String, Long> silenced=new HashMap<String, Long>();
/**
* Prevent 'thrashing' SILENCE - this doesn't allow it to remove ones that
* were put in less than 2 minutes ago
*/
private final static long SILENCETHRASHPREVENTION=120000L;
@Override
public synchronized boolean silence(String mask)
{
// Check how many silences the server supports
String number=getISupport("SILENCE");
if(number==null)
return false;
int max;
try
{
max=Integer.parseInt(number);
}
catch(NumberFormatException nfe)
{
return false;
}
if(max<1) return false;
// OK, it supports some... are we at that limit?
if(silenced.size()==max)
{
long oldest = 0;
String oldestMask = null;
for(Map.Entry<String, Long> me : silenced.entrySet())
{
long setupTime = me.getValue().longValue();
if(oldestMask==null || setupTime<oldest)
{
oldest = setupTime;
oldestMask = me.getKey();
}
}
// No old silences
if(System.currentTimeMillis() - oldest < SILENCETHRASHPREVENTION)
return false;
// OK, get rid of it
unsilence(oldestMask);
}
sendLine(IRCMsg.constructBytes("SILENCE +"+mask));
silenced.put(mask,new Long(System.currentTimeMillis()));
return true;
}
@Override
public synchronized boolean unsilence(String mask)
{
if(silenced.remove(mask)!=null)
{
sendLine(IRCMsg.constructBytes("SILENCE -"+mask));
return true;
}
else
return false;
}
@Override
public InetAddress getLocalAddress()
{
return s.getLocalAddress();
}
@Override
public int getMaxModeParams()
{
try
{
String modesIsupport=getISupport("MODES");
if(modesIsupport!=null) return Integer.parseInt(modesIsupport);
}
catch(NumberFormatException nfe)
{
}
return 3;
}
@Override
public boolean wasQuitRequested()
{
return quitRequested;
}
@Override
public boolean isAway()
{
return away;
}
/**
* Used to prevent the connection-finished message being sent while we are
* waiting for user input.
* @return True if connection-finished should not be sent yet
*/
boolean deferConnectionFinished()
{
return deferConnectionFinished;
}
}