diff --git a/.gitignore b/.gitignore index 7a6a59d..8962e95 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ *~ .project .classpath +/project/target/ diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..c6e93ea --- /dev/null +++ b/build.sbt @@ -0,0 +1,52 @@ +name := "sirc" + +autoScalaLibrary := false + +javacOptions in Compile ++= Seq("-target", "1.6", "-source", "1.6") + +javacOptions in (Compile,doc) ~= { + _.foldRight(List.empty[String]) { case (o,r) => + if (o != "-target") o :: r else r.drop(1) + } +} + +crossPaths := false + +organization := "com.hanhuy" + +version := "1.1.6-pfn.3" + +// sonatype publishing options follow +publishMavenStyle := true + +publishTo := { + val nexus = "https://oss.sonatype.org/" + if (isSnapshot.value) + Some("snapshots" at nexus + "content/repositories/snapshots") + else + Some("releases" at nexus + "service/local/staging/deploy/maven2") +} + +pomIncludeRepository := { _ => false } + +pomExtra := + + git@github.com:pfn/sIRC.git + scm:git:git@github.com:pfn/sIRC.git + + + + pfnguyen + Perry Nguyen + https://github.com/pfn + + + +licenses := Seq("BSD-style" -> url("http://www.opensource.org/licenses/bsd-license.php")) + +homepage := Some(url("https://github.com/pfn/sIRC")) + +libraryDependencies ++= Seq( + "com.novocode" % "junit-interface" % "0.11" % "test", + "org.mockito" % "mockito-core" % "1.8.5" % "test" +) diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..64abd37 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=0.13.6 diff --git a/src/main/java/com/sorcix/sirc/Channel.java b/src/main/java/com/sorcix/sirc/Channel.java index dd5a17a..e924cc4 100644 --- a/src/main/java/com/sorcix/sirc/Channel.java +++ b/src/main/java/com/sorcix/sirc/Channel.java @@ -46,7 +46,7 @@ public final class Channel { /** The user list. */ private ConcurrentHashMap users; /** Possible channel prefixes. */ - protected static final String CHANNEL_PREFIX = "#&+!"; + public static final String CHANNEL_PREFIX = "#&+!"; /** * Creates a new {@code Channel} object with given name. @@ -56,7 +56,7 @@ public final class Channel { * channel. * @param global Whether this object is going to be shared. */ - protected Channel(final String name, final IrcConnection irc, final boolean global) { + public Channel(final String name, final IrcConnection irc, final boolean global) { this.name = name; this.irc = irc; if (global) { @@ -71,7 +71,7 @@ protected Channel(final String name, final IrcConnection irc, final boolean glob * * @param user The user to add. */ - protected void addUser(final User user) { + public void addUser(final User user) { if (this.users != null) { this.users.putIfAbsent(user.getNickLower(), user); } @@ -357,7 +357,7 @@ public void removeOperator(final User user) { * * @param user The user to remove. */ - protected void removeUser(final User user) { + public void removeUser(final User user) { if (this.users != null) { this.users.remove(user.getNickLower()); } @@ -501,7 +501,7 @@ public String toString() { * didn't exist. * @return The updated shared User object. */ - protected User updateUser(final User user, final boolean createNew) { + public User updateUser(final User user, final boolean createNew) { if (this.hasUser(user.getNickLower())) { // update user if it exists final User shared = this.getUser(user.getNickLower()); diff --git a/src/main/java/com/sorcix/sirc/ClientState.java b/src/main/java/com/sorcix/sirc/ClientState.java index f41bd74..74b9cd7 100644 --- a/src/main/java/com/sorcix/sirc/ClientState.java +++ b/src/main/java/com/sorcix/sirc/ClientState.java @@ -61,7 +61,7 @@ protected ClientState() { * @param channel * The channel to add. */ - protected void addChannel(final Channel channel) { + public void addChannel(final Channel channel) { if (!this.channels.containsKey(channel.getName().toLowerCase())) { this.channels.put(channel.getName().toLowerCase(), channel); } @@ -100,7 +100,7 @@ protected Channel getChannel(final Channel channel) { * @return The channel, or null if this channel doesn't exist. (The local * user is not in that channel) */ - protected Channel getChannel(final String channel) { + public Channel getChannel(final String channel) { if (channel != null && this.channels.containsKey(channel.toLowerCase())) { return this.channels.get(channel.toLowerCase()); } @@ -165,7 +165,7 @@ protected void removeAll() { * @param channel * The channel name. */ - protected void removeChannel(final String channel) { + public void removeChannel(final String channel) { if (channel != null && this.channels.containsKey(channel.toLowerCase())) { this.channels.remove(channel.toLowerCase()); } diff --git a/src/main/java/com/sorcix/sirc/IrcConnection.java b/src/main/java/com/sorcix/sirc/IrcConnection.java index 98fad80..f5d7dac 100644 --- a/src/main/java/com/sorcix/sirc/IrcConnection.java +++ b/src/main/java/com/sorcix/sirc/IrcConnection.java @@ -27,6 +27,10 @@ */ package com.sorcix.sirc; +import com.sorcix.sirc.cap.CapNegotiator; +import com.sorcix.sirc.event.MessageEventListener; +import com.sorcix.sirc.event.ServerEventListener; + import javax.net.SocketFactory; import javax.net.ssl.SSLContext; import java.io.IOException; @@ -60,12 +64,18 @@ public class IrcConnection { public static final String VERSION = "1.1.6-SNAPSHOT"; /** Advanced listener. */ private AdvancedListener advancedListener = null; + + private CapNegotiator.Listener capNegotiatorListener = null; /** Connection InputStream thread. */ private IrcInput in = null; /** Outgoing message delay. (Flood control) */ private int messageDelay = 100; /** Message listeners. */ private final List messageListeners; + + private final List messageEventListeners; + private final List serverEventListeners; + /** Mode listeners. */ private final List modeListeners; /** Connection OutputStream thread. */ @@ -133,6 +143,8 @@ public IrcConnection(final String server, final int port) { public IrcConnection(final String server, final int port, final String password) { this.server = new IrcServer(server, port, password, false); + this.serverEventListeners = new Vector(4); + this.messageEventListeners = new Vector(4); this.serverListeners = new Vector(4); this.messageListeners = new Vector(4); this.modeListeners = new Vector(2); @@ -164,6 +176,18 @@ public void addMessageListener(final MessageListener listener) { } } + /** + * Adds a message listener to this IrcConnection. + * + * @param listener + * The message listener to add. + */ + public void addMessageEventListener(final MessageEventListener listener) { + if ((listener != null) && !this.messageEventListeners.contains(listener)) { + this.messageEventListeners.add(listener); + } + } + /** * Adds a mode listener to this IrcConnection. Note that adding mode * listeners will cause sIRC to check every incoming mode change for @@ -192,7 +216,19 @@ public void addServerListener(final ServerListener listener) { } } - /** + /** + * Adds a server listener to this IrcConnection. + * + * @param listener + * The server listener to add. + */ + public void addServerEventListener(final ServerEventListener listener) { + if ((listener != null) && !this.serverEventListeners.contains(listener)) { + this.serverEventListeners.add(listener); + } + } + + /** * Add and load a service. {@code IrcConnection} will call the * {@link SIRCService#load(IrcConnection)} method of this * {@code SIRCService} after adding it to the service list. @@ -234,7 +270,7 @@ public void sendRaw(final String line) { * @param channel * The channel to request the userlist for. */ - protected void askNames(final Channel channel) { + public void askNames(final Channel channel) { this.out.send(IrcPacketFactory.createNAMES(channel.getName())); } @@ -369,10 +405,17 @@ public void connect(Socket sock) throws UnknownHostException, IOException, NickN this.socket = sock; reconnecting = false; } - // open streams + // open streams this.out = new IrcOutput(this, new OutputStreamWriter(this.socket.getOutputStream(), this.charset)); this.in = new IrcInput(this, new InputStreamReader(this.socket.getInputStream(), this.charset)); + CapNegotiator capNegotiator = new CapNegotiator(out); + if (capNegotiatorListener != null) + capNegotiator.addListener(capNegotiatorListener); if (!reconnecting) { + // start capability negotiation + if (capNegotiatorListener != null) { + this.out.sendNowEx(IrcPacketFactory.createCAPLS()); + } // send password if given if (this.server.getPassword() != null) { this.out.sendNowEx(IrcPacketFactory.createPASS(this.server @@ -388,10 +431,14 @@ public void connect(Socket sock) throws UnknownHostException, IOException, NickN loop: while ((line = this.in.getReader().readLine()) != null) { IrcDebug.log(line); final IrcPacket decoder = new IrcPacket(line, this); - if (decoder.isNumeric()) { + if (capNegotiator.isNegotiating()) { + capNegotiator.process(decoder); + } + if (decoder.isNumeric()) { final int command = decoder.getNumericCommand(); switch (command) { case 1: + capNegotiator.cancel(); case 2: case 3: { final String nick = decoder.getArgumentsArray()[0]; @@ -411,7 +458,9 @@ public void connect(Socket sock) throws UnknownHostException, IOException, NickN throw new PasswordException("Invalid password"); } // break; unnecessary due to throw } - } + } else if ("CAP".equals(decoder.getCommand()) && !capNegotiator.isNegotiating()) { + capNegotiator.process(decoder); + } if (line.startsWith("PING ")) { this.out.pong(line.substring(5)); } @@ -426,6 +475,9 @@ public void connect(Socket sock) throws UnknownHostException, IOException, NickN .hasNext();) { it.next().onConnect(this); } + for (ServerEventListener l : serverEventListeners) { + l.onConnect(this); + } } /** @@ -625,6 +677,14 @@ protected Iterator getServerListeners() { return this.serverListeners.iterator(); } + protected List getServerEventListeners() { + return this.serverEventListeners; + } + + protected List getMessageEventListeners() { + return this.messageEventListeners; + } + /** * Gives the port number this {@code IrcConnection} is using to connect. * @@ -755,6 +815,18 @@ public void removeServerListener(final ServerListener listener) { } } + public void removeServerEventListener(final ServerEventListener listener) { + if ((listener != null) && this.serverEventListeners.contains(listener)) { + this.serverEventListeners.remove(listener); + } + } + + public void removeMessageEventListener(final MessageEventListener listener) { + if ((listener != null) && this.messageEventListeners.contains(listener)) { + this.messageEventListeners.remove(listener); + } + } + /** * Remove a service. {@code IrcConnection} will call the * {@link SIRCService#unload(IrcConnection)} method of this @@ -780,6 +852,10 @@ public void setAdvancedListener(final AdvancedListener listener) { this.advancedListener = listener; } + public void setCapNegotiatorListener(final CapNegotiator.Listener listener) { + this.capNegotiatorListener = listener; + } + /** * Marks you as away on the server. If any user sends a message to you while * marked as away, the the server will send them a message back. diff --git a/src/main/java/com/sorcix/sirc/IrcInput.java b/src/main/java/com/sorcix/sirc/IrcInput.java index 6f5228d..a885003 100644 --- a/src/main/java/com/sorcix/sirc/IrcInput.java +++ b/src/main/java/com/sorcix/sirc/IrcInput.java @@ -27,6 +27,8 @@ */ package com.sorcix.sirc; +import com.sorcix.sirc.event.ServerEventListener; + import java.io.BufferedReader; import java.io.IOException; import java.io.Reader; @@ -106,20 +108,22 @@ public void run() { try { // wait for lines to come in while ((line = this.in.readLine()) != null) { - IrcDebug.log("<<< " + line); + IrcDebug.log(irc + " <<< " + line); // always respond to PING if (line.startsWith("PING ")) { this.irc.out.pong(line.substring(5)); - } else { + } else if (!line.trim().isEmpty()){ this.handleLine(line); - } + } else { + IrcDebug.log(irc + ": *** Invalid line from server, ignoring"); + } } } catch (final SocketException ex) { this.irc.setConnected(false); } catch (final IOException ex) { this.irc.setConnected(false); } catch (final Exception ex) { - IrcDebug.log("Exception " + ex + " on: " + line); + IrcDebug.log(irc + ": Exception " + ex + " on: " + line); ex.printStackTrace(); } // when reaching this, we are disconnected @@ -130,5 +134,7 @@ public void run() { for (final Iterator it = this.irc.getServerListeners(); it.hasNext();) { it.next().onDisconnect(this.irc); } + for (ServerEventListener l : irc.getServerEventListeners()) + l.onDisconnect(irc); } } diff --git a/src/main/java/com/sorcix/sirc/IrcOutput.java b/src/main/java/com/sorcix/sirc/IrcOutput.java index 010809b..9403508 100644 --- a/src/main/java/com/sorcix/sirc/IrcOutput.java +++ b/src/main/java/com/sorcix/sirc/IrcOutput.java @@ -36,7 +36,7 @@ * * @author Sorcix */ -class IrcOutput extends Thread { +public class IrcOutput extends Thread { /** The IrcConnection. */ private final IrcConnection irc; @@ -171,7 +171,7 @@ protected synchronized void sendNow(final String line) { * @throws IOException If anything goes wrong while sending this * message. */ - protected synchronized void sendNowEx(final IrcPacket packet) throws IOException { + public synchronized void sendNowEx(final IrcPacket packet) throws IOException { this.sendNowEx(packet.getRaw()); } @@ -183,11 +183,11 @@ protected synchronized void sendNowEx(final IrcPacket packet) throws IOException * @throws IOException If anything goes wrong while sending this * message. */ - private synchronized void sendNowEx(String line) throws IOException { + public synchronized void sendNowEx(String line) throws IOException { if (line.length() > (IrcOutput.MAX_LINE_LENGTH - 2)) { line = line.substring(0, IrcOutput.MAX_LINE_LENGTH - 2); } - IrcDebug.log(">>> " + line); + IrcDebug.log(irc + " >>> " + line); this.out.write(line + IrcConnection.ENDLINE); this.out.flush(); } diff --git a/src/main/java/com/sorcix/sirc/IrcPacket.java b/src/main/java/com/sorcix/sirc/IrcPacket.java index 74de8b2..4b5f814 100644 --- a/src/main/java/com/sorcix/sirc/IrcPacket.java +++ b/src/main/java/com/sorcix/sirc/IrcPacket.java @@ -27,6 +27,12 @@ */ package com.sorcix.sirc; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; + /** * Parses a raw server response into a more readable format. * @@ -39,6 +45,9 @@ */ public final class IrcPacket { + private Date timestamp = null; + private String rawLine = null; + /** Arguments separated by a space */ private String arguments = null; /** The numeric server reply. */ @@ -62,7 +71,7 @@ public final class IrcPacket { /** Reply to MOTD. (message of the day) */ protected static final int RPL_MOTD = 372; /** Response to TOPIC with the set topic. */ - protected static final int RPL_TOPIC = 332; + public static final int RPL_TOPIC = 332; /** Termination of an RPL_NAMREPLY list. */ protected static final int RPL_ENDOFNAMES = 366; /** Reply to NAMES (See RFC). */ @@ -72,6 +81,23 @@ public final class IrcPacket { /** CTCP message mark. */ protected static final String CTCP = "\u0001"; + private final static DateFormat dateFormat; + + static { + + SimpleDateFormat sdf = null; + try { + sdf = new SimpleDateFormat( + "yyyy-MM-dd'T'HH:mm:ss.SSSX"); + } catch (IllegalArgumentException e) { + // android fallback, it does not support 'X' + sdf = new SimpleDateFormat( + "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + } + dateFormat = sdf; + } + /** * Creates a new IrcPacket using the data from given raw IRC data. * @@ -81,6 +107,26 @@ public final class IrcPacket { * The IrcConnection used to send messages. */ protected IrcPacket(String line, final IrcConnection irc) { + rawLine = line; + if (line.startsWith("@time=")) { + int endTime = line.indexOf(" "); + if (endTime > 0) { + try { + synchronized (dateFormat) { + timestamp = dateFormat.parse( + line.substring(6 /* @time= */, endTime)); + } + } catch (ParseException e) { + // ignore + timestamp = new Date(); + } + line = line.substring(endTime + 1); + } else { + throw new IllegalArgumentException("malformed IRC line: " + line); + } + } else { + timestamp = new Date(); + } line = IrcColors.remove(line); final int locLineStart = line.indexOf(':') + 1; int locCommand; @@ -96,6 +142,7 @@ protected IrcPacket(String line, final IrcConnection irc) { } // space between command and receiver final int locArgs = line.indexOf(' ', locCommand + 1); + if (locArgs < 0) return; // rawLine only packet // retrieve command this.command = line.substring(locCommand + 1, locArgs); // colon between arguments and message @@ -117,7 +164,13 @@ protected IrcPacket(String line, final IrcConnection irc) { this.message = this.message.substring(1, this.message.length() - 1); } - } + } else { + // broken PRIVMSG with 1 message word and no colon: thelounge + String[] args = getArgumentsArray(); + if (args.length > 1) { + this.message = args[args.length - 1]; + } + } // check if the command is a server reply this.cmdNumeric = this.getInteger(this.command); if (this.cmdNumeric != -1) { @@ -232,7 +285,8 @@ public String getPrefix() { * * @return IRC String containing the data in this object. */ - protected String getRaw() { + public String getRaw() { + if (rawLine != null) return rawLine; final StringBuffer buffer = new StringBuffer(); if ((this.prefix != null) && (this.prefix.length() > 0)) { @@ -293,4 +347,12 @@ public boolean isCtcp() { public boolean isNumeric() { return this.numeric; } + + public Date getTimestamp() { + return timestamp; + } + + public long getTime() { + return timestamp.getTime(); + } } diff --git a/src/main/java/com/sorcix/sirc/IrcPacketFactory.java b/src/main/java/com/sorcix/sirc/IrcPacketFactory.java index f0a2ba1..705eacf 100644 --- a/src/main/java/com/sorcix/sirc/IrcPacketFactory.java +++ b/src/main/java/com/sorcix/sirc/IrcPacketFactory.java @@ -31,4 +31,15 @@ protected static IrcPacket createUSER(final String username, return new IrcPacket(null, "USER", username + " Sorcix.com *", realname); } + public static IrcPacket createCAPLS() { + return new IrcPacket(null, "CAP", "LS", null); + } + + public static IrcPacket createCAPEND() { + return new IrcPacket(null, "CAP", "END", null); + } + public static IrcPacket createCAPREQ(final String features) { + return new IrcPacket(null, "CAP", "REQ", features); + } + } diff --git a/src/main/java/com/sorcix/sirc/IrcParser.java b/src/main/java/com/sorcix/sirc/IrcParser.java index 5eed5da..5836bcd 100644 --- a/src/main/java/com/sorcix/sirc/IrcParser.java +++ b/src/main/java/com/sorcix/sirc/IrcParser.java @@ -27,6 +27,9 @@ */ package com.sorcix.sirc; +import com.sorcix.sirc.event.MessageEventListener; +import com.sorcix.sirc.event.ServerEventListener; + import java.util.Date; import java.util.Iterator; @@ -51,6 +54,9 @@ protected void parseCommand(final IrcConnection irc, final IrcPacket line) { if (line.isCtcp()) { // reply to CTCP commands if (line.getMessage().startsWith("ACTION ")) { + for (MessageEventListener l : irc.getMessageEventListeners()) { + l.onAction(new MessageEventListener.Action(irc, line)); + } if (Channel.CHANNEL_PREFIX.indexOf(line.getArguments().charAt(0)) >= 0) { // to channel final Channel chan = irc.getState().getChannel(line.getArguments()); @@ -88,6 +94,9 @@ protected void parseCommand(final IrcConnection irc, final IrcPacket line) { } else if (line.getArguments().startsWith("#") || line.getArguments().startsWith("&")) { // to channel final Channel chan = irc.getState().getChannel(line.getArguments()); + for (MessageEventListener l : irc.getMessageEventListeners()) { + l.onMessage(new MessageEventListener.Message(irc, line)); + } for (final Iterator it = irc.getMessageListeners(); it.hasNext();) { it.next().onMessage(irc, chan.updateUser(line.getSender(), true), chan, line.getMessage()); } @@ -96,6 +105,9 @@ protected void parseCommand(final IrcConnection irc, final IrcPacket line) { for (final Iterator it = irc.getMessageListeners(); it.hasNext();) { it.next().onPrivateMessage(irc, line.getSender(), line.getMessage()); } + for (MessageEventListener l : irc.getMessageEventListeners()) { + l.onPrivateMessage(new MessageEventListener.Message(irc, line)); + } } } else if (line.getCommand().equals("NOTICE") && (line.getArguments() != null)) { if (line.isCtcp()) { @@ -108,17 +120,26 @@ protected void parseCommand(final IrcConnection irc, final IrcPacket line) { it.next().onCtcpReply(irc, line.getSender(), command, args); } } + for (MessageEventListener l : irc.getMessageEventListeners()) { + l.onCtcpReply(new MessageEventListener.CtcpReply(irc, line)); + } } else if (Channel.CHANNEL_PREFIX.indexOf(line.getArguments().charAt(0)) >= 0) { // to channel final Channel chan = irc.getState().getChannel(line.getArguments()); for (final Iterator it = irc.getMessageListeners(); it.hasNext();) { it.next().onNotice(irc, chan.updateUser(line.getSender(), true), chan, line.getMessage()); } + for (MessageEventListener l : irc.getMessageEventListeners()) { + l.onNotice(new MessageEventListener.Notice(irc, line)); + } } else { // to user for (final Iterator it = irc.getMessageListeners(); it.hasNext();) { it.next().onNotice(irc, line.getSender(), line.getMessage()); } + for (MessageEventListener l : irc.getMessageEventListeners()) { + l.onNotice(new MessageEventListener.Notice(irc, line)); + } } } else if (line.getCommand().equals("JOIN")) { // some server seem to send the joined channel as message, @@ -141,6 +162,9 @@ protected void parseCommand(final IrcConnection irc, final IrcPacket line) { for (final Iterator it = irc.getServerListeners(); it.hasNext();) { it.next().onJoin(irc, irc.getState().getChannel(channel), line.getSender()); } + for (ServerEventListener l : irc.getServerEventListeners()) { + l.onJoin(new ServerEventListener.Join(irc, line)); + } } else if (line.getCommand().equals("PART")) { // someone left a channel if (line.getSender().isUs()) { @@ -156,12 +180,18 @@ protected void parseCommand(final IrcConnection irc, final IrcPacket line) { for (final Iterator it = irc.getServerListeners(); it.hasNext();) { it.next().onPart(irc, irc.getState().getChannel(line.getArguments()), line.getSender(), line.getMessage()); } + for (ServerEventListener l : irc.getServerEventListeners()) { + l.onPart(new ServerEventListener.Part(irc, line)); + } } else if (line.getCommand().equals("QUIT")) { // someone quit the IRC server final User quitter = line.getSender(); for (final Iterator it = irc.getServerListeners(); it.hasNext();) { it.next().onQuit(irc, quitter, line.getMessage()); } + for (ServerEventListener l : irc.getServerEventListeners()) { + l.onQuit(new ServerEventListener.Quit(irc, line)); + } for (final Iterator it = irc.getState().getChannels(); it.hasNext();) { final Channel channel = it.next(); if (channel.hasUser(quitter)) { @@ -184,6 +214,9 @@ protected void parseCommand(final IrcConnection irc, final IrcPacket line) { for (final Iterator it = irc.getServerListeners(); it.hasNext();) { it.next().onKick(irc, channel, line.getSender(), kicked, line.getMessage()); } + for (ServerEventListener l : irc.getServerEventListeners()) { + l.onKick(new ServerEventListener.Kick(irc, line)); + } } else if (line.getCommand().equals("MODE")) { this.parseMode(irc, line); } else if (line.getCommand().equals("TOPIC")) { @@ -192,6 +225,9 @@ protected void parseCommand(final IrcConnection irc, final IrcPacket line) { final Channel chan = irc.getState().getChannel(line.getArguments()); it.next().onTopic(irc, chan, chan.updateUser(line.getSender(), false), line.getMessage()); } + for (ServerEventListener l : irc.getServerEventListeners()) { + l.onTopic(new ServerEventListener.Topic(irc, line)); + } } else if (line.getCommand().equals("NICK")) { User newUser; if (line.hasMessage()) { @@ -210,6 +246,9 @@ protected void parseCommand(final IrcConnection irc, final IrcPacket line) { for (final Iterator it = irc.getServerListeners(); it.hasNext();) { it.next().onNick(irc, line.getSender(), newUser); } + for (ServerEventListener l : irc.getServerEventListeners()) { + l.onNick(new ServerEventListener.Nick(irc, line)); + } } else if (line.getCommand().equals("INVITE")) { // someone was invited final String[] args = line.getArgumentsArray(); @@ -218,6 +257,9 @@ protected void parseCommand(final IrcConnection irc, final IrcPacket line) { for (final Iterator it = irc.getServerListeners(); it.hasNext();) { it.next().onInvite(irc, line.getSender(), new User(args[0], irc), channel); } + for (ServerEventListener l : irc.getServerEventListeners()) { + l.onInvite(new ServerEventListener.Invite(irc, line)); + } } } else { if (irc.getAdvancedListener() != null) { @@ -239,6 +281,9 @@ private void parseMode(final IrcConnection irc, final IrcPacket line) { for (final Iterator it = irc.getServerListeners(); it.hasNext();) { it.next().onMode(irc, irc.getState().getChannel(args[0]), line.getSender(), line.getArguments().substring(args[0].length() + 1)); } + for (ServerEventListener l : irc.getServerEventListeners()) { + l.onMode(new ServerEventListener.Mode(irc, line)); + } if ((args.length >= 3)) { final Channel channel = irc.getState().getChannel(args[0]); final String mode = args[1]; @@ -327,6 +372,9 @@ protected void parseNumeric(final IrcConnection irc, final IrcPacket line) { for (final Iterator it = irc.getServerListeners(); it.hasNext();) { it.next().onTopic(irc, irc.getState().getChannel(line.getArgumentsArray()[1]), null, line.getMessage()); } + for (ServerEventListener l : irc.getServerEventListeners()) { + l.onTopic(new ServerEventListener.Topic(irc, line)); + } break; case IrcPacket.RPL_NAMREPLY: final String[] arguments = line.getArgumentsArray(); @@ -359,6 +407,9 @@ protected void parseNumeric(final IrcConnection irc, final IrcPacket line) { for (final Iterator it = irc.getServerListeners(); it.hasNext();) { it.next().onMotd(irc, motd); } + for (ServerEventListener l : irc.getServerEventListeners()) { + l.onMotd(new ServerEventListener.Motd(irc, line, motd)); + } } break; case IrcPacket.RPL_BOUNCE: diff --git a/src/main/java/com/sorcix/sirc/cap/CapNegotiator.java b/src/main/java/com/sorcix/sirc/cap/CapNegotiator.java new file mode 100644 index 0000000..8f1c79b --- /dev/null +++ b/src/main/java/com/sorcix/sirc/cap/CapNegotiator.java @@ -0,0 +1,114 @@ +package com.sorcix.sirc.cap; + +import com.sorcix.sirc.IrcOutput; +import com.sorcix.sirc.IrcPacket; +import com.sorcix.sirc.IrcPacketFactory; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * @author pfnguyen + */ +public class CapNegotiator { + public interface Listener { + /** + * successful feature request + * @param feature + * @return true if this listener needs to process packets + */ + boolean onNegotiateFeature(CapNegotiator capNegotiator, String feature); + + /** + * unsuccessful feature request + * @param feature + * @return true if this listener should not be ignored (used by CompoundNegotiator) + */ + boolean onNegotiateMissing(CapNegotiator capNegotiator, String feature); + + /** + * list of features available on the server + * @param features + * @return true if this listener will request feature(s) + */ + boolean onNegotiateList(CapNegotiator capNegotiator, String[] features) + throws IOException; + + /** + * while negotiation is active, packets are forwarded to the listener(s) + * @param packet + * @return true while this listener needs to receive more packets + */ + boolean onNegotiate(CapNegotiator capNegotiator, IrcPacket packet) + throws IOException; + } + + private final IrcOutput output; + + public CapNegotiator(IrcOutput output) { + this.output = output; + } + + public void cancel() { + negotiating = false; + listeners.clear(); + } + + public void process(IrcPacket packet) throws IOException { + String[] args = packet.getArgumentsArray(); + boolean isCapCmd = "CAP".equals(packet.getCommand()); + if (isCapCmd && args.length > 1) { + String command = args[1]; + if ("ACK".equals(command)) { + for (Listener l : listeners) { + if (!l.onNegotiateFeature(this, packet.getMessage().trim())) + listeners.remove(l); + } + } else if ("NAK".equals(command)) { + for (Listener l : listeners) { + l.onNegotiateMissing(this, packet.getMessage().trim()); + listeners.remove(l); + } + } else if ("LS".equals(command)) { + negotiating = true; + String[] features = packet.getMessage().split(" "); + for (Listener l : listeners) { + if (!l.onNegotiateList(this, features)) + listeners.remove(l); + } + } + } else { + for (Listener l : listeners) { + if (!l.onNegotiate(this, packet)) + listeners.remove(l); + } + } + if (listeners.size() == 0) { + cancel(); + send(IrcPacketFactory.createCAPEND().getRaw()); + } + } + + public void request(final String feature) throws IOException { + send(IrcPacketFactory.createCAPREQ(feature).getRaw()); + } + public void send(final String rawCommand) throws IOException { + output.sendNowEx(rawCommand); + } + + public void addListener(Listener l) { + listeners.add(l); + } + + public void removeListener(Listener l) { + listeners.remove(l); + } + + public boolean isNegotiating() { + return negotiating; + } + + private boolean negotiating = false; + private List listeners = new CopyOnWriteArrayList(); +} diff --git a/src/main/java/com/sorcix/sirc/cap/CompoundNegotiator.java b/src/main/java/com/sorcix/sirc/cap/CompoundNegotiator.java new file mode 100644 index 0000000..632303c --- /dev/null +++ b/src/main/java/com/sorcix/sirc/cap/CompoundNegotiator.java @@ -0,0 +1,73 @@ +package com.sorcix.sirc.cap; + +import com.sorcix.sirc.IrcPacket; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * @author pfnguyen + */ +public class CompoundNegotiator implements CapNegotiator.Listener { + private List listeners = + new CopyOnWriteArrayList(); + + public CompoundNegotiator(CapNegotiator.Listener... listeners) { + for (CapNegotiator.Listener listener : listeners) { + addListener(listener); + } + } + + public void addListener(CapNegotiator.Listener listener) { + listeners.add(listener); + } + + @Override + public boolean onNegotiateFeature(CapNegotiator capNegotiator, String feature) { + boolean more = false; + for (CapNegotiator.Listener listener : listeners) { + boolean keep = listener.onNegotiateFeature(capNegotiator, feature); + more = more || keep; + if (!keep) + listeners.remove(listener); + } + return more; + } + + @Override + public boolean onNegotiateMissing(CapNegotiator capNegotiator, String feature) { + boolean more = false; + for (CapNegotiator.Listener listener : listeners) { + boolean keep = listener.onNegotiateMissing(capNegotiator, feature); + more = more || keep; + if (!keep) + listeners.remove(listener); + } + return more; + } + + @Override + public boolean onNegotiateList(CapNegotiator capNegotiator, String[] features) throws IOException { + boolean more = false; + for (CapNegotiator.Listener listener : listeners) { + boolean keep = listener.onNegotiateList(capNegotiator, features); + more = more || keep; + if (!keep) + listeners.remove(listener); + } + return more; + } + + @Override + public boolean onNegotiate(CapNegotiator capNegotiator, IrcPacket packet) throws IOException { + boolean more = false; + for (CapNegotiator.Listener listener : listeners) { + boolean keep = listener.onNegotiate(capNegotiator, packet); + more = more || keep; + if (!keep) + listeners.remove(listener); + } + return more; + } +} diff --git a/src/main/java/com/sorcix/sirc/cap/ServerTimeNegotiator.java b/src/main/java/com/sorcix/sirc/cap/ServerTimeNegotiator.java new file mode 100644 index 0000000..e83a267 --- /dev/null +++ b/src/main/java/com/sorcix/sirc/cap/ServerTimeNegotiator.java @@ -0,0 +1,37 @@ +package com.sorcix.sirc.cap; + +import com.sorcix.sirc.IrcPacket; + +import java.io.IOException; + +/** + * @author pfnguyen + */ +public class ServerTimeNegotiator implements CapNegotiator.Listener { + @Override + public boolean onNegotiateFeature(CapNegotiator capNegotiator, String feature) { + return false; + } + + @Override + public boolean onNegotiateMissing(CapNegotiator capNegotiator, String feature) { + return false; + } + + @Override + public boolean onNegotiateList( + CapNegotiator capNegotiator, String[] features) throws IOException { + for (String feature : features) { + if (feature.contains("server-time")) { + capNegotiator.request(feature); + return true; + } + } + return false; + } + + @Override + public boolean onNegotiate(CapNegotiator capNegotiator, IrcPacket packet) throws IOException { + return false; + } +} diff --git a/src/main/java/com/sorcix/sirc/event/BaseEvent.java b/src/main/java/com/sorcix/sirc/event/BaseEvent.java new file mode 100644 index 0000000..fbdc24f --- /dev/null +++ b/src/main/java/com/sorcix/sirc/event/BaseEvent.java @@ -0,0 +1,19 @@ +package com.sorcix.sirc.event; + +import com.sorcix.sirc.IrcConnection; +import com.sorcix.sirc.IrcPacket; + +import java.util.Date; + +/** + * @author pfnguyen + */ +public class BaseEvent { + public final Date timestamp; + public final IrcConnection connection; + + public BaseEvent(IrcConnection c, IrcPacket p) { + this.timestamp = p.getTimestamp(); + connection = c; + } +} diff --git a/src/main/java/com/sorcix/sirc/event/MessageEventListener.java b/src/main/java/com/sorcix/sirc/event/MessageEventListener.java new file mode 100644 index 0000000..5eee5c8 --- /dev/null +++ b/src/main/java/com/sorcix/sirc/event/MessageEventListener.java @@ -0,0 +1,137 @@ +/* + * MessageListener.java + * + * This file is part of the Sorcix Java IRC Library (sIRC). + * + * Copyright (C) 2008-2010 Vic Demuzere http://sorcix.com + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, + * modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.sorcix.sirc.event; + +import com.sorcix.sirc.Channel; +import com.sorcix.sirc.IrcConnection; +import com.sorcix.sirc.IrcPacket; +import com.sorcix.sirc.User; + +/** + * Notified of new IRC messages. + */ +public interface MessageEventListener { + + public static class Action extends BaseEvent { + public final User sender; + public final Channel target; + public final String action; + + public Action(IrcConnection c, IrcPacket p) { + super(c, p); + action = p.getMessage().substring(7); + if (Channel.CHANNEL_PREFIX.indexOf(p.getArguments().charAt(0)) >= 0) { + // to channel + target = c.getState().getChannel(p.getArgumentsArray()[0]); + sender = target.updateUser(p.getSender(), true); + } else { + // to user + target = null; + sender = p.getSender(); + } + } + } + + public static class CtcpReply extends BaseEvent { + public final String command; + public final User sender; + public final String message; + + public CtcpReply(IrcConnection c, IrcPacket p) { + super(c, p); + final int cmdPos = p.getMessage().indexOf(' '); + command = p.getMessage().substring(0, cmdPos); + final String args = p.getMessage().substring(cmdPos + 1); + sender = p.getSender(); + message = args; + } + } + + public static class Message extends BaseEvent { + public final User sender; + public final Channel target; + public final String message; + public Message(IrcConnection c, IrcPacket p) { + super(c, p); + message = p.getMessage(); + if (Channel.CHANNEL_PREFIX.indexOf( + p.getArguments().charAt(0)) >= 0) { + target = c.getState().getChannel(p.getArgumentsArray()[0]); + sender = target.updateUser(p.getSender(), true); + } else { + target = null; + sender = p.getSender(); + } + } + } + + public static class Notice extends BaseEvent { + public final User sender; + public final Channel target; + public final String message; + public Notice(IrcConnection c, IrcPacket p) { + super(c, p); + message = p.getMessage(); + if (Channel.CHANNEL_PREFIX.indexOf( + p.getArguments().charAt(0)) >= 0) { + target = c.getState().getChannel(p.getArgumentsArray()[0]); + sender = target.updateUser(p.getSender(), true); + } else { + target = null; + sender = p.getSender(); + } + } + } + + /** + * Received an action in a channel. + */ + void onAction(Action action); + + /** + * Received a CTCP reply. Note that this event is only fired when + * receiving CTCP replies supported by sIRC. If you can't send a + * CTCP request, you won't get the reply. + */ + void onCtcpReply(CtcpReply reply); + + /** + * Received a message in a channel. + */ + void onMessage(Message message); + + /** + * Received a private notice. + */ + void onNotice(Notice notice); + + /** + * Received a private message. + */ + void onPrivateMessage(Message message); +} diff --git a/src/main/java/com/sorcix/sirc/event/ServerEventListener.java b/src/main/java/com/sorcix/sirc/event/ServerEventListener.java new file mode 100644 index 0000000..216872b --- /dev/null +++ b/src/main/java/com/sorcix/sirc/event/ServerEventListener.java @@ -0,0 +1,248 @@ +/* + * ServerListener.java + * + * This file is part of the Sorcix Java IRC Library (sIRC). + * + * Copyright (C) 2008-2010 Vic Demuzere http://sorcix.com + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, + * modify, merge, publish, distribute, sublicense, and/or sell copies + * of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.sorcix.sirc.event; + +import com.sorcix.sirc.Channel; +import com.sorcix.sirc.IrcConnection; +import com.sorcix.sirc.IrcPacket; +import com.sorcix.sirc.User; + +public interface ServerEventListener { + + public static class Invite extends BaseEvent { + public final User sender; + public final User target; + public final Channel channel; + + public Invite(IrcConnection c, IrcPacket p) { + super(c, p); + sender = p.getSender(); + target = new User(p.getArgumentsArray()[0], c); + channel = c.createChannel(p.getArgumentsArray()[1]); + } + } + + public static class Join extends BaseEvent { + public final Channel channel; + public final User sender; + + public Join(IrcConnection c, IrcPacket p) { + super(c, p); + String channel; + if (p.hasMessage()) { + channel = p.getMessage(); + } else { + channel = p.getArguments(); + } + // someone joined a channel + if (p.getSender().isUs()) { + // if the sender joining the channel is the client + // we need to add it to the channel list. + c.getState().addChannel(new Channel(channel, c, true)); + } else { + // add sender to channel list. + c.getState().getChannel(channel).addUser(p.getSender()); + } + this.channel = c.getState().getChannel(channel); + this.sender = p.getSender(); + } + } + + public static class Kick extends BaseEvent { + public final Channel channel; + public final User sender; + public final User target; + public final String message; + + public Kick(IrcConnection c, IrcPacket p) { + super(c, p); + + final String[] data = p.getArgumentsArray(); + final User kicked = new User(data[1], c); + final Channel channel = c.getState().getChannel(data[0]); + if (kicked.isUs()) { + // if the user leaving the channel is the client + // we need to remove it from the channel list + c.getState().removeChannel(data[0]); + } else { + // remove user from channel list. + channel.removeUser(kicked); + } + this.channel = channel; + this.sender = p.getSender(); + this.target = kicked; + this.message = p.getMessage(); + } + } + + public static class Motd extends BaseEvent { + public final String motd; + public Motd(IrcConnection c, IrcPacket p, String motd) { + super(c, p); + this.motd = motd; + } + } + public static class Mode extends BaseEvent { + public final Channel channel; + public final User sender; + public final String mode; + public Mode(IrcConnection c, IrcPacket p) { + super(c, p); + channel = c.getState().getChannel(p.getArgumentsArray()[0]); + sender = p.getSender(); + mode = p.getArguments().substring( + p.getArgumentsArray()[0].length() + 1); + } + } + + public static class Nick extends BaseEvent { + public final User oldUser; + public final User newUser; + public Nick(IrcConnection c, IrcPacket p) { + super(c, p); + newUser = new User( + p.hasMessage() ? p.getMessage() : p.getArguments(), c); + oldUser = p.getSender(); + } + + } + + public static class Part extends BaseEvent { + public final User sender; + public final Channel channel; + public final String message; + + public Part(IrcConnection c, IrcPacket p) { + super(c, p); + channel = c.getState().getChannel(p.getArguments()); + sender = p.getSender(); + message = p.getMessage(); + } + } + + public static class Quit extends BaseEvent { + public final User sender; + public final String message; + + public Quit(IrcConnection c, IrcPacket p) { + super(c, p); + sender = p.getSender(); + message = p.getMessage(); + } + } + + public static class Topic extends BaseEvent { + public final User sender; + public final Channel channel; + public final String topic; + public Topic(IrcConnection c, IrcPacket p) { + super(c, p); + + if ("TOPIC".equals(p.getCommand())) { + final Channel chan = c.getState().getChannel(p.getArguments()); + channel = chan; + sender = chan.updateUser(p.getSender(), false); + topic = p.getMessage(); + } else if (p.getNumericCommand() == IrcPacket.RPL_TOPIC) { + channel = c.getState().getChannel(p.getArgumentsArray()[1]); + sender = null; + topic = p.getMessage(); + } else { + channel = null; + sender = null; + topic = null; + } + } + } + + /** + * Someone (possibly us) was invited into a channel. + */ + void onInvite(Invite invite); + + /** + * Someone (possibly us) joined a channel. + */ + void onJoin(Join join); + + /** + * Someone (possibly us) was kicked from a channel. + *

+ * Note: This method does NOT return a shared + * user object. That means that it isn't possible to retrieve the + * user prefix (or any modes). + *

+ */ + void onKick(Kick kick); + + /** + * Someone (possibly us) changed a channel mode. + */ + void onMode(Mode mode); + + /** + * Someone (possibly us) changed his nickname. Note that the + * {@code oldUser} can not be used to send messages, as that + * nickname no longer exists. + *

+ * Note: This method does NOT return a shared + * sender object. That means that it isn't possible to retrieve the + * sender prefix (or any modes). + *

+ */ + void onNick(Nick nick); + + /** + * Someone (possibly us) left a channel. + *

+ * Note: This method does NOT return a shared + * sender object. That means that it isn't possible to retrieve the + * sender prefix (or any modes). + *

+ */ + void onPart(Part part); + + /** + * Someone quit the IRC server. + */ + void onQuit(Quit quit); + + /** + * Someone (possibly us) changed the topic of a channel, or we + * joined a new channel and discovered the topic. The {@code + * sender} will be {@code null} when we discovered the topic after + * joining. + */ + void onTopic(Topic topic); + + void onMotd(Motd motd); + + void onConnect(IrcConnection c); + + void onDisconnect(IrcConnection c); +} diff --git a/src/test/java/com/sorcix/sirc/IrcPacketTest.java b/src/test/java/com/sorcix/sirc/IrcPacketTest.java new file mode 100644 index 0000000..0ab6d09 --- /dev/null +++ b/src/test/java/com/sorcix/sirc/IrcPacketTest.java @@ -0,0 +1,78 @@ +package com.sorcix.sirc; + +import static org.mockito.Matchers.matches; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import static org.junit.Assert.*; +import org.mockito.runners.MockitoJUnitRunner; + +import java.util.Calendar; +import java.util.Date; + +@RunWith(MockitoJUnitRunner.class) +public class IrcPacketTest { + @Mock + IrcConnection connection; + + @Test + public void capLs() { + IrcPacket p = new IrcPacket(":leguin.freenode.net CAP * LS :" + + "account-notify extended-join identify-msg multi-prefix sasl", + connection); + assertEquals("CAP", p.getCommand()); + assertEquals( + "account-notify extended-join identify-msg multi-prefix sasl", + p.getMessage()); + assertEquals("* LS", p.getArguments()); + assertEquals("LS", p.getArgumentsArray()[1]); + } + + @Test + public void capReqMultiPrefix() { + IrcPacket p = new IrcPacket( + ":irc.colosolutions.net CAP * ACK :multi-prefix", connection); + assertEquals("CAP", p.getCommand()); + assertEquals("multi-prefix", p.getMessage()); + assertEquals("* ACK", p.getArguments()); + assertEquals("ACK", p.getArgumentsArray()[1]); + } + + @Test + public void capNak() { + IrcPacket p = new IrcPacket( + ":irc.znc.in CAP unknown-nick NAK :server-time-iso", connection); + assertEquals("CAP", p.getCommand()); + assertEquals("server-time-iso", p.getMessage()); + assertEquals("unknown-nick NAK", p.getArguments()); + assertEquals("NAK", p.getArgumentsArray()[1]); + } + + @Test + public void createCapLs() { + IrcPacket p = IrcPacketFactory.createCAPLS(); + assertEquals("CAP LS", p.getRaw()); + } + + @Test + public void welcomeNumeric() { + IrcPacket p = new IrcPacket("@time=2014-09-15T15:33:11.232Z :" + + "irc.choopa.net 001 pfn :" + + "Welcome to the EFNet Internet Relay Chat Network pfn", + connection); + Date ts = p.getTimestamp(); + assertEquals(new Date(1410795191232l), p.getTimestamp()); + assertEquals(1410795191232l, p.getTime()); + Calendar c = Calendar.getInstance(); + c.setTime(ts); + assertEquals(8, c.get(Calendar.MONTH)); + // only works in GMT-7 +// assertEquals(8, c.get(Calendar.HOUR)); + assertTrue(p.isNumeric()); + assertEquals(1, p.getNumericCommand()); + } + +}