diff --git a/client/pom.xml b/client/pom.xml index fda8858..7e85b4d 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -24,5 +24,4 @@ 3.1.1 - \ No newline at end of file diff --git a/client/src/main/java/module-info.java b/client/src/main/java/module-info.java index 53210a5..a6447ae 100644 --- a/client/src/main/java/module-info.java +++ b/client/src/main/java/module-info.java @@ -1,4 +1,5 @@ module concord_client { requires concord_core; requires com.googlecode.lanterna; + requires static lombok; } \ No newline at end of file diff --git a/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java b/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java index 5ab48a6..0fb0dc1 100644 --- a/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java +++ b/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java @@ -7,11 +7,15 @@ import com.googlecode.lanterna.screen.Screen; import com.googlecode.lanterna.screen.TerminalScreen; import com.googlecode.lanterna.terminal.DefaultTerminalFactory; import com.googlecode.lanterna.terminal.Terminal; +import lombok.Getter; +import lombok.Setter; import nl.andrewl.concord_client.gui.MainWindow; import nl.andrewl.concord_core.msg.Message; +import nl.andrewl.concord_core.msg.MessageUtils; import nl.andrewl.concord_core.msg.Serializer; import nl.andrewl.concord_core.msg.types.Chat; import nl.andrewl.concord_core.msg.types.Identification; +import nl.andrewl.concord_core.msg.types.ServerMetaData; import nl.andrewl.concord_core.msg.types.ServerWelcome; import java.io.DataInputStream; @@ -21,14 +25,20 @@ import java.net.Socket; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.UUID; public class ConcordClient implements Runnable { private final Socket socket; private final DataInputStream in; private final DataOutputStream out; - private final long id; + private final UUID id; private final String nickname; + @Getter + @Setter + private UUID currentChannelId; + @Getter + private ServerMetaData serverMetaData; private final Set messageListeners; private volatile boolean running; @@ -41,6 +51,8 @@ public class ConcordClient implements Runnable { Message reply = Serializer.readMessage(this.in); if (reply instanceof ServerWelcome welcome) { this.id = welcome.getClientId(); + this.currentChannelId = welcome.getCurrentChannelId(); + this.serverMetaData = welcome.getMetaData(); } else { throw new IOException("Unexpected response from the server after sending identification message."); } @@ -55,6 +67,10 @@ public class ConcordClient implements Runnable { this.messageListeners.remove(listener); } + public void sendMessage(Message message) throws IOException { + Serializer.writeMessage(message, this.out); + } + public void sendChat(String message) throws IOException { Serializer.writeMessage(new Chat(this.id, this.nickname, System.currentTimeMillis(), message), this.out); } diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/ChannelChatBox.java b/client/src/main/java/nl/andrewl/concord_client/gui/ChannelChatBox.java new file mode 100644 index 0000000..7732c8e --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/gui/ChannelChatBox.java @@ -0,0 +1,58 @@ +package nl.andrewl.concord_client.gui; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.input.KeyStroke; +import com.googlecode.lanterna.input.KeyType; +import lombok.Getter; +import nl.andrewl.concord_client.ConcordClient; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; + +public class ChannelChatBox extends Panel { + private final ConcordClient client; + private Border chatBorder; + @Getter + private final ChatList chatList; + private final TextBox inputTextBox; + public ChannelChatBox(ConcordClient client, Window window) { + super(new BorderLayout()); + this.client = client; + this.chatList = new ChatList(); + this.inputTextBox = new TextBox("", TextBox.Style.MULTI_LINE); + this.inputTextBox.setCaretWarp(true); + this.inputTextBox.setPreferredSize(new TerminalSize(0, 3)); + + window.addWindowListener(new WindowListenerAdapter() { + @Override + public void onInput(Window basePane, KeyStroke keyStroke, AtomicBoolean deliverEvent) { + if (keyStroke.getKeyType() == KeyType.Enter && inputTextBox.isFocused() && !keyStroke.isShiftDown()) { + String text = inputTextBox.getText(); + if (text != null && !text.isBlank()) { + try { + System.out.println("Sending: " + text.trim()); + client.sendChat(text.trim()); + inputTextBox.setText(""); + } catch (IOException e) { + e.printStackTrace(); + } + } + deliverEvent.set(false); + } + } + }); + this.refreshBorder(); + this.addComponent(this.inputTextBox, BorderLayout.Location.BOTTOM); + } + + public void refreshBorder() { + String name = client.getServerMetaData().getChannels().stream() + .filter(channelData -> channelData.getId().equals(client.getCurrentChannelId())) + .findAny().orElseThrow().getName(); + if (this.chatBorder != null) this.removeComponent(this.chatBorder); + this.chatBorder = Borders.doubleLine("#" + name); + this.chatBorder.setComponent(this.chatList); + this.addComponent(this.chatBorder, BorderLayout.Location.CENTER); + } +} diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/ChannelList.java b/client/src/main/java/nl/andrewl/concord_client/gui/ChannelList.java index a207f10..7891571 100644 --- a/client/src/main/java/nl/andrewl/concord_client/gui/ChannelList.java +++ b/client/src/main/java/nl/andrewl/concord_client/gui/ChannelList.java @@ -1,6 +1,38 @@ package nl.andrewl.concord_client.gui; -import com.googlecode.lanterna.gui2.AbstractListBox; +import com.googlecode.lanterna.gui2.Button; +import com.googlecode.lanterna.gui2.Direction; +import com.googlecode.lanterna.gui2.LinearLayout; +import com.googlecode.lanterna.gui2.Panel; +import nl.andrewl.concord_client.ConcordClient; +import nl.andrewl.concord_core.msg.types.MoveToChannel; -public class ChannelList extends AbstractListBox { +import java.io.IOException; + +public class ChannelList extends Panel { + private final ConcordClient client; + + public ChannelList(ConcordClient client) { + super(new LinearLayout(Direction.VERTICAL)); + this.client = client; + } + + public void setChannels() { + this.removeAllComponents(); + for (var channel : this.client.getServerMetaData().getChannels()) { + String name = channel.getName(); + if (client.getCurrentChannelId().equals(channel.getId())) { + name = "*" + name; + } + Button b = new Button(name, () -> { + System.out.println("Sending request to go to channel " + channel.getName()); + try { + client.sendMessage(new MoveToChannel(channel.getId())); + } catch (IOException e) { + e.printStackTrace(); + } + }); + this.addComponent(b, LinearLayout.createLayoutData(LinearLayout.Alignment.End)); + } + } } diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/ChatPanel.java b/client/src/main/java/nl/andrewl/concord_client/gui/ChatPanel.java index 52ecff0..a7c91c6 100644 --- a/client/src/main/java/nl/andrewl/concord_client/gui/ChatPanel.java +++ b/client/src/main/java/nl/andrewl/concord_client/gui/ChatPanel.java @@ -1,16 +1,14 @@ package nl.andrewl.concord_client.gui; -import com.googlecode.lanterna.TerminalSize; import com.googlecode.lanterna.gui2.*; -import com.googlecode.lanterna.input.KeyStroke; -import com.googlecode.lanterna.input.KeyType; +import lombok.Getter; import nl.andrewl.concord_client.ClientMessageListener; import nl.andrewl.concord_client.ConcordClient; import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.types.Chat; +import nl.andrewl.concord_core.msg.types.MoveToChannel; import java.io.IOException; -import java.util.concurrent.atomic.AtomicBoolean; /** * The main panel in which a user interacts with the application during normal @@ -20,8 +18,8 @@ import java.util.concurrent.atomic.AtomicBoolean; * threads and users in the server. */ public class ChatPanel extends Panel implements ClientMessageListener { - private final ChatList chatList; - private final TextBox inputTextBox; + @Getter + private final ChannelChatBox channelChatBox; private final ChannelList channelList; private final UserList userList; @@ -30,41 +28,13 @@ public class ChatPanel extends Panel implements ClientMessageListener { public ChatPanel(ConcordClient client, Window window) { super(new BorderLayout()); this.client = client; - this.chatList = new ChatList(); - this.inputTextBox = new TextBox("", TextBox.Style.MULTI_LINE); - this.inputTextBox.setCaretWarp(true); - this.inputTextBox.setPreferredSize(new TerminalSize(0, 3)); - - this.channelList = new ChannelList(); - this.channelList.addItem("general"); - this.channelList.addItem("memes"); - this.channelList.addItem("testing"); + this.channelChatBox = new ChannelChatBox(client, window); + this.channelList = new ChannelList(client); + this.channelList.setChannels(); this.userList = new UserList(); this.userList.addItem("andrew"); this.userList.addItem("tester"); - window.addWindowListener(new WindowListenerAdapter() { - @Override - public void onInput(Window basePane, KeyStroke keyStroke, AtomicBoolean deliverEvent) { - if (keyStroke.getKeyType() == KeyType.Enter) { - if (keyStroke.isShiftDown()) { - System.out.println("Adding newline"); - } else { - String text = inputTextBox.getText(); - if (text != null && !text.isBlank()) { - try { - System.out.println("Sending: " + text.trim()); - client.sendChat(text.trim()); - inputTextBox.setText(""); - } catch (IOException e) { - e.printStackTrace(); - } - } - deliverEvent.set(false); - } - } - } - }); Border b; b = Borders.doubleLine("Channels"); b.setComponent(this.channelList); @@ -74,18 +44,18 @@ public class ChatPanel extends Panel implements ClientMessageListener { b.setComponent(this.userList); this.addComponent(b, BorderLayout.Location.RIGHT); - b = Borders.doubleLine("#general"); - b.setComponent(this.chatList); - this.addComponent(b, BorderLayout.Location.CENTER); - - this.addComponent(this.inputTextBox, BorderLayout.Location.BOTTOM); - this.inputTextBox.takeFocus(); + this.addComponent(this.channelChatBox, BorderLayout.Location.CENTER); } @Override - public void messageReceived(ConcordClient client, Message message) throws IOException { + public void messageReceived(ConcordClient client, Message message) { if (message instanceof Chat chat) { - this.chatList.addItem(chat); + this.channelChatBox.getChatList().addItem(chat); + } else if (message instanceof MoveToChannel moveToChannel) { + client.setCurrentChannelId(moveToChannel.getChannelId()); + this.channelList.setChannels(); + this.channelChatBox.getChatList().clearItems(); + this.channelChatBox.refreshBorder(); } } } diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java b/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java index 67efc1d..91c51d4 100644 --- a/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java +++ b/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java @@ -55,7 +55,6 @@ public class MainWindow extends BasicWindow { client.addListener(chatPanel); new Thread(client).start(); this.setComponent(chatPanel); - Borders.joinLinesWithFrame(this.getTextGUI().getScreen().newTextGraphics()); } catch (IOException e) { e.printStackTrace(); } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/Message.java b/core/src/main/java/nl/andrewl/concord_core/msg/Message.java index 1d56223..f307ef0 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/Message.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/Message.java @@ -4,6 +4,7 @@ import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.UUID; /** * Represents any message which can be sent over the network. @@ -35,60 +36,4 @@ public interface Message { * @throws IOException If an error occurs while reading. */ void read(DataInputStream i) throws IOException; - - // Utility methods. - - /** - * Gets the number of bytes that the given string will occupy when it is - * serialized. - * @param s The string. - * @return The number of bytes used to serialize the string. - */ - default int getByteSize(String s) { - return Integer.BYTES + s.getBytes(StandardCharsets.UTF_8).length; - } - - /** - * Writes a string to the given output stream using a length-prefixed format - * where an integer length precedes the string's bytes, which are encoded in - * UTF-8. - * @param s The string to write. - * @param o The output stream to write to. - * @throws IOException If the stream could not be written to. - */ - default void writeString(String s, DataOutputStream o) throws IOException { - if (s == null) { - o.writeInt(-1); - } else { - o.writeInt(s.length()); - o.write(s.getBytes(StandardCharsets.UTF_8)); - } - } - - /** - * Reads a string from the given input stream, using a length-prefixed - * format, where an integer length precedes the string's bytes, which are - * encoded in UTF-8. - * @param i The input stream to read from. - * @return The string which was read. - * @throws IOException If the stream could not be read, or if the string is - * malformed. - */ - default String readString(DataInputStream i) throws IOException { - int length = i.readInt(); - if (length == -1) return null; - byte[] data = new byte[length]; - int read = i.read(data); - if (read != length) throw new IOException("Not all bytes of a string of length " + length + " could be read."); - return new String(data, StandardCharsets.UTF_8); - } - - default void writeEnum(Enum value, DataOutputStream o) throws IOException { - o.writeInt(value.ordinal()); - } - - default > T readEnum(Class e, DataInputStream i) throws IOException { - int ordinal = i.readInt(); - return e.getEnumConstants()[ordinal]; - } } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/MessageUtils.java b/core/src/main/java/nl/andrewl/concord_core/msg/MessageUtils.java new file mode 100644 index 0000000..e1bca5e --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/MessageUtils.java @@ -0,0 +1,109 @@ +package nl.andrewl.concord_core.msg; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Utility class which provides method for serializing and deserializing complex + * data types. + */ +public class MessageUtils { + public static final int UUID_BYTES = 2 * Long.BYTES; + + /** + * Gets the number of bytes that the given string will occupy when it is + * serialized. + * @param s The string. + * @return The number of bytes used to serialize the string. + */ + public static int getByteSize(String s) { + return Integer.BYTES + s.getBytes(StandardCharsets.UTF_8).length; + } + + /** + * Writes a string to the given output stream using a length-prefixed format + * where an integer length precedes the string's bytes, which are encoded in + * UTF-8. + * @param s The string to write. + * @param o The output stream to write to. + * @throws IOException If the stream could not be written to. + */ + public static void writeString(String s, DataOutputStream o) throws IOException { + if (s == null) { + o.writeInt(-1); + } else { + o.writeInt(s.length()); + o.write(s.getBytes(StandardCharsets.UTF_8)); + } + } + + /** + * Reads a string from the given input stream, using a length-prefixed + * format, where an integer length precedes the string's bytes, which are + * encoded in UTF-8. + * @param i The input stream to read from. + * @return The string which was read. + * @throws IOException If the stream could not be read, or if the string is + * malformed. + */ + public static String readString(DataInputStream i) throws IOException { + int length = i.readInt(); + if (length == -1) return null; + byte[] data = new byte[length]; + int read = i.read(data); + if (read != length) throw new IOException("Not all bytes of a string of length " + length + " could be read."); + return new String(data, StandardCharsets.UTF_8); + } + + public static void writeEnum(Enum value, DataOutputStream o) throws IOException { + o.writeInt(value.ordinal()); + } + + public static > T readEnum(Class e, DataInputStream i) throws IOException { + int ordinal = i.readInt(); + return e.getEnumConstants()[ordinal]; + } + + public static void writeUUID(UUID value, DataOutputStream o) throws IOException { + o.writeLong(value.getMostSignificantBits()); + o.writeLong(value.getLeastSignificantBits()); + } + + public static UUID readUUID(DataInputStream i) throws IOException { + long a = i.readLong(); + long b = i.readLong(); + return new UUID(a, b); + } + + public static int getByteSize(List items) { + int count = Integer.BYTES; + for (var item : items) { + count += item.getByteCount(); + } + return count; + } + + public static void writeList(List items, DataOutputStream o) throws IOException { + o.writeInt(items.size()); + for (var i : items) { + i.write(o); + } + } + + public static List readList(Class type, DataInputStream i) throws IOException, ReflectiveOperationException { + int size = i.readInt(); + var constructor = type.getConstructor(); + List items = new ArrayList<>(size); + for (int k = 0; k < size; k++) { + var item = constructor.newInstance(); + item.read(i); + items.add(item); + } + return items; + } +} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java b/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java index 4a45756..50827b5 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java @@ -1,8 +1,6 @@ package nl.andrewl.concord_core.msg; -import nl.andrewl.concord_core.msg.types.Chat; -import nl.andrewl.concord_core.msg.types.Identification; -import nl.andrewl.concord_core.msg.types.ServerWelcome; +import nl.andrewl.concord_core.msg.types.*; import java.io.*; import java.util.HashMap; @@ -18,6 +16,9 @@ public class Serializer { registerType(0, Identification.class); registerType(1, ServerWelcome.class); registerType(2, Chat.class); + registerType(3, MoveToChannel.class); + registerType(4, ChatHistoryRequest.class); + registerType(5, ChatHistoryResponse.class); } private static void registerType(int id, Class clazz) { diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/Chat.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/Chat.java index a288255..ec536e5 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/Chat.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/Chat.java @@ -7,6 +7,9 @@ import nl.andrewl.concord_core.msg.Message; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.util.UUID; + +import static nl.andrewl.concord_core.msg.MessageUtils.*; /** * This message contains information about a chat message that a user sent. @@ -14,12 +17,12 @@ import java.io.IOException; @Data @NoArgsConstructor public class Chat implements Message { - private long senderId; + private UUID senderId; private String senderNickname; private long timestamp; private String message; - public Chat(long senderId, String senderNickname, long timestamp, String message) { + public Chat(UUID senderId, String senderNickname, long timestamp, String message) { this.senderId = senderId; this.senderNickname = senderNickname; this.timestamp = timestamp; @@ -27,17 +30,17 @@ public class Chat implements Message { } public Chat(String message) { - this(-1, null, System.currentTimeMillis(), message); + this(null, null, System.currentTimeMillis(), message); } @Override public int getByteCount() { - return 2 * Long.BYTES + getByteSize(this.message) + getByteSize(this.senderNickname); + return UUID_BYTES + Long.BYTES + getByteSize(this.senderNickname) + getByteSize(this.message); } @Override public void write(DataOutputStream o) throws IOException { - o.writeLong(this.senderId); + writeUUID(this.senderId, o); writeString(this.senderNickname, o); o.writeLong(this.timestamp); writeString(this.message, o); @@ -45,7 +48,7 @@ public class Chat implements Message { @Override public void read(DataInputStream i) throws IOException { - this.senderId = i.readLong(); + this.senderId = readUUID(i); this.senderNickname = readString(i); this.timestamp = i.readLong(); this.message = readString(i); @@ -53,6 +56,6 @@ public class Chat implements Message { @Override public String toString() { - return String.format("%s(%d): %s", this.senderNickname, this.senderId, this.message); + return String.format("%s: %s", this.senderNickname, this.message); } } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryRequest.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryRequest.java index 04e1a09..a1d3de2 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryRequest.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryRequest.java @@ -7,6 +7,9 @@ import nl.andrewl.concord_core.msg.Message; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.util.UUID; + +import static nl.andrewl.concord_core.msg.MessageUtils.*; /** * A message which clients can send to the server to request some messages from @@ -46,25 +49,25 @@ import java.io.IOException; public class ChatHistoryRequest implements Message { public enum Source {CHANNEL, THREAD, DIRECT_MESSAGE} - private long sourceId; + private UUID sourceId; private Source sourceType; private String query; @Override public int getByteCount() { - return Long.BYTES + Integer.BYTES + getByteSize(this.query); + return UUID_BYTES + Integer.BYTES + getByteSize(this.query); } @Override public void write(DataOutputStream o) throws IOException { - o.writeLong(sourceId); + writeUUID(this.sourceId, o); writeEnum(this.sourceType, o); writeString(this.query, o); } @Override public void read(DataInputStream i) throws IOException { - this.sourceId = i.readLong(); + this.sourceId = readUUID(i); this.sourceType = readEnum(Source.class, i); this.query = readString(i); } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryResponse.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryResponse.java index 84aa044..2f25a0c 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryResponse.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryResponse.java @@ -3,19 +3,22 @@ package nl.andrewl.concord_core.msg.types; import lombok.Data; import lombok.NoArgsConstructor; import nl.andrewl.concord_core.msg.Message; +import nl.andrewl.concord_core.msg.MessageUtils; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; import java.util.List; +import java.util.UUID; /** - * The response that a server sends to a {@link ChatHistoryRequest}. + * The response that a server sends to a {@link ChatHistoryRequest}. The list of + * messages is ordered by timestamp, with the newest messages appearing first. */ @Data @NoArgsConstructor public class ChatHistoryResponse implements Message { - private long sourceId; + private UUID sourceId; private ChatHistoryRequest.Source sourceType; List messages; @@ -30,8 +33,8 @@ public class ChatHistoryResponse implements Message { @Override public void write(DataOutputStream o) throws IOException { - o.writeLong(this.sourceId); - writeEnum(this.sourceType, o); + MessageUtils.writeUUID(this.sourceId, o); + MessageUtils.writeEnum(this.sourceType, o); o.writeInt(messages.size()); for (var message : this.messages) { message.write(o); @@ -40,8 +43,8 @@ public class ChatHistoryResponse implements Message { @Override public void read(DataInputStream i) throws IOException { - this.sourceId = i.readInt(); - this.sourceType = readEnum(ChatHistoryRequest.Source.class, i); + this.sourceId = MessageUtils.readUUID(i); + this.sourceType = MessageUtils.readEnum(ChatHistoryRequest.Source.class, i); int messageCount = i.readInt(); Chat[] messages = new Chat[messageCount]; for (int k = 0; k < messageCount; k++) { diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java index 7f3e267..7a5d9a6 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java @@ -3,6 +3,7 @@ package nl.andrewl.concord_core.msg.types; import lombok.Data; import lombok.NoArgsConstructor; import nl.andrewl.concord_core.msg.Message; +import nl.andrewl.concord_core.msg.MessageUtils; import java.io.DataInputStream; import java.io.DataOutputStream; @@ -23,16 +24,16 @@ public class Identification implements Message { @Override public int getByteCount() { - return getByteSize(this.nickname); + return MessageUtils.getByteSize(this.nickname); } @Override public void write(DataOutputStream o) throws IOException { - writeString(this.nickname, o); + MessageUtils.writeString(this.nickname, o); } @Override public void read(DataInputStream i) throws IOException { - this.nickname = readString(i); + this.nickname = MessageUtils.readString(i); } } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/MoveToChannel.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/MoveToChannel.java new file mode 100644 index 0000000..d173913 --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/MoveToChannel.java @@ -0,0 +1,46 @@ +package nl.andrewl.concord_core.msg.types; + +import lombok.Data; +import lombok.NoArgsConstructor; +import nl.andrewl.concord_core.msg.Message; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.UUID; + +import static nl.andrewl.concord_core.msg.MessageUtils.*; + +/** + * A message that's sent to a client when they've been moved to another channel. + * This indicates to the client that they should perform the necessary requests + * to update their view to indicate that they're now in a different channel. + *

+ * Conversely, a client can send this request to the server to indicate that + * they would like to switch to the specified channel. + *

+ */ +@Data +@NoArgsConstructor +public class MoveToChannel implements Message { + private UUID channelId; + + public MoveToChannel(UUID channelId) { + this.channelId = channelId; + } + + @Override + public int getByteCount() { + return UUID_BYTES; + } + + @Override + public void write(DataOutputStream o) throws IOException { + writeUUID(this.channelId, o); + } + + @Override + public void read(DataInputStream i) throws IOException { + this.channelId = readUUID(i); + } +} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerMetaData.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerMetaData.java new file mode 100644 index 0000000..ea2612e --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerMetaData.java @@ -0,0 +1,68 @@ +package nl.andrewl.concord_core.msg.types; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import nl.andrewl.concord_core.msg.Message; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +import static nl.andrewl.concord_core.msg.MessageUtils.*; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ServerMetaData implements Message { + private String name; + private List channels; + + @Override + public int getByteCount() { + return getByteSize(this.name) + getByteSize(this.channels); + } + + @Override + public void write(DataOutputStream o) throws IOException { + writeString(this.name, o); + writeList(this.channels, o); + } + + @Override + public void read(DataInputStream i) throws IOException { + this.name = readString(i); + try { + this.channels = readList(ChannelData.class, i); + } catch (ReflectiveOperationException e) { + throw new IOException("Reflection exception", e); + } + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class ChannelData implements Message { + private UUID id; + private String name; + + @Override + public int getByteCount() { + return UUID_BYTES + getByteSize(this.name); + } + + @Override + public void write(DataOutputStream o) throws IOException { + writeUUID(this.id, o); + writeString(this.name, o); + } + + @Override + public void read(DataInputStream i) throws IOException { + this.id = readUUID(i); + this.name = readString(i); + } + } +} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java index 8fd6a90..167de23 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java @@ -1,32 +1,46 @@ package nl.andrewl.concord_core.msg.types; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; import nl.andrewl.concord_core.msg.Message; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.util.UUID; + +import static nl.andrewl.concord_core.msg.MessageUtils.*; /** * This message is sent from the server to the client after the server accepts * the client's identification and registers the client in the server. */ @Data +@NoArgsConstructor +@AllArgsConstructor public class ServerWelcome implements Message { - private long clientId; + private UUID clientId; + private UUID currentChannelId; + private ServerMetaData metaData; @Override public int getByteCount() { - return Long.BYTES; + return 2 * UUID_BYTES + this.metaData.getByteCount(); } @Override public void write(DataOutputStream o) throws IOException { - o.writeLong(this.clientId); + writeUUID(this.clientId, o); + writeUUID(this.currentChannelId, o); + this.metaData.write(o); } @Override public void read(DataInputStream i) throws IOException { - this.clientId = i.readLong(); + this.clientId = readUUID(i); + this.currentChannelId = readUUID(i); + this.metaData = new ServerMetaData(); + this.metaData.read(i); } } diff --git a/server/pom.xml b/server/pom.xml index 80f9012..5b86686 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -25,23 +25,4 @@ - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.8.1 - - - - org.projectlombok - lombok - 1.18.20 - - - - - - \ No newline at end of file diff --git a/server/src/main/java/nl/andrewl/concord_server/Channel.java b/server/src/main/java/nl/andrewl/concord_server/Channel.java new file mode 100644 index 0000000..a79a9d3 --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/Channel.java @@ -0,0 +1,72 @@ +package nl.andrewl.concord_server; + +import lombok.Getter; +import nl.andrewl.concord_core.msg.Message; +import nl.andrewl.concord_core.msg.Serializer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Represents a single communication area in which messages are sent by clients + * and received by all connected clients. + */ +@Getter +public class Channel { + private final ConcordServer server; + private UUID id; + private String name; + + private final Set connectedClients; + + public Channel(ConcordServer server, UUID id, String name) { + this.server = server; + this.id = id; + this.name = name; + this.connectedClients = ConcurrentHashMap.newKeySet(); + } + + public void addClient(ClientThread clientThread) { + this.connectedClients.add(clientThread); + } + + public void removeClient(ClientThread clientThread) { + this.connectedClients.remove(clientThread); + } + + /** + * Sends a message to all clients that are currently connected to this + * channel. + * @param msg The message to send. + * @throws IOException If an error occurs. + */ + public void sendMessage(Message msg) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(msg.getByteCount() + 1); + Serializer.writeMessage(msg, baos); + byte[] data = baos.toByteArray(); + for (var client : this.connectedClients) { + client.sendToClient(data); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Channel channel)) return false; + return name.equals(channel.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + + @Override + public String toString() { + return this.name; + } +} diff --git a/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java b/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java new file mode 100644 index 0000000..4e247c0 --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java @@ -0,0 +1,54 @@ +package nl.andrewl.concord_server; + +import nl.andrewl.concord_core.msg.types.MoveToChannel; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class ChannelManager { + private final ConcordServer server; + private final Map channelNameMap; + private final Map channelIdMap; + + public ChannelManager(ConcordServer server) { + this.server = server; + this.channelNameMap = new ConcurrentHashMap<>(); + this.channelIdMap = new ConcurrentHashMap<>(); + Channel general = new Channel(server, server.getIdProvider().newId(), "general"); + Channel memes = new Channel(server, server.getIdProvider().newId(), "memes"); + this.addChannel(general); + this.addChannel(memes); + } + + public Set getChannels() { + return Set.copyOf(this.channelIdMap.values()); + } + + public void addChannel(Channel channel) { + this.channelNameMap.put(channel.getName(), channel); + this.channelIdMap.put(channel.getId(), channel); + } + + public void removeChannel(Channel channel) { + this.channelNameMap.remove(channel.getName()); + this.channelIdMap.remove(channel.getId()); + } + + public Optional getChannelByName(String name) { + return Optional.ofNullable(this.channelNameMap.get(name)); + } + + public Optional getChannelById(UUID id) { + return Optional.ofNullable(this.channelIdMap.get(id)); + } + + public void moveToChannel(ClientThread client, Channel channel) { + if (client.getCurrentChannel() != null) { + client.getCurrentChannel().removeClient(client); + } + channel.addClient(client); + client.setCurrentChannel(channel); + client.sendToClient(new MoveToChannel(channel.getId())); + System.out.println("Moved client " + client.getClientNickname() + " to channel " + channel.getName()); + } +} diff --git a/server/src/main/java/nl/andrewl/concord_server/ChatThread.java b/server/src/main/java/nl/andrewl/concord_server/ChatThread.java new file mode 100644 index 0000000..7aeed1d --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/ChatThread.java @@ -0,0 +1,4 @@ +package nl.andrewl.concord_server; + +public class ChatThread { +} diff --git a/server/src/main/java/nl/andrewl/concord_server/ClientThread.java b/server/src/main/java/nl/andrewl/concord_server/ClientThread.java index d4d99f5..988d17c 100644 --- a/server/src/main/java/nl/andrewl/concord_server/ClientThread.java +++ b/server/src/main/java/nl/andrewl/concord_server/ClientThread.java @@ -1,11 +1,10 @@ package nl.andrewl.concord_server; import lombok.Getter; +import lombok.Setter; import lombok.extern.java.Log; import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Serializer; -import nl.andrewl.concord_core.msg.types.Chat; -import nl.andrewl.concord_core.msg.types.ChatHistoryRequest; import nl.andrewl.concord_core.msg.types.Identification; import nl.andrewl.concord_core.msg.types.ServerWelcome; @@ -13,6 +12,7 @@ import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; import java.net.Socket; +import java.util.UUID; /** * This thread is responsible for handling the connection to a single client of @@ -26,10 +26,16 @@ public class ClientThread extends Thread { private final ConcordServer server; - private Long clientId = null; + private UUID clientId = null; @Getter private String clientNickname = null; + @Getter + @Setter + private Channel currentChannel; + + private volatile boolean running; + public ClientThread(Socket socket, ConcordServer server) throws IOException { this.socket = socket; this.server = server; @@ -54,42 +60,60 @@ public class ClientThread extends Thread { } } + public void shutdown() { + try { + this.socket.close(); + } catch (IOException e) { + e.printStackTrace(); + } + this.running = false; + } + @Override public void run() { + this.running = true; if (!identifyClient()) { log.warning("Could not identify the client; aborting connection."); - return; + this.running = false; } - while (true) { + while (this.running) { try { var msg = Serializer.readMessage(this.in); - if (msg instanceof Chat chat) { - this.server.handleChat(chat); - } else if (msg instanceof ChatHistoryRequest historyRequest) { - this.server.handleHistoryRequest(historyRequest, this); - } + this.server.getEventManager().handle(msg, this); } catch (IOException e) { log.info("Client disconnected: " + e.getMessage()); - if (this.clientId != null) { - this.server.deregisterClient(this.clientId); - } - break; + this.running = false; } } + + if (this.clientId != null) { + this.server.deregisterClient(this.clientId); + } + try { + if (!this.socket.isClosed()) { + this.socket.close(); + } + } catch (IOException e) { + e.printStackTrace(); + } } + /** + * Initial method that attempts to obtain identification information from a + * newly-connected client. It is the intent that we should close the socket + * if the client is not able to identify itself. + * @return True if we were able to obtain identification from the client, or + * false otherwise. + */ private boolean identifyClient() { int attempts = 0; while (attempts < 5) { try { var msg = Serializer.readMessage(this.in); if (msg instanceof Identification id) { - this.clientId = this.server.registerClient(this); this.clientNickname = id.getNickname(); - var reply = new ServerWelcome(); - reply.setClientId(this.clientId); - Serializer.writeMessage(reply, this.out); + this.clientId = this.server.registerClient(id, this); return true; } } catch (IOException e) { diff --git a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java index e10936a..13c13b6 100644 --- a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java +++ b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java @@ -1,85 +1,100 @@ package nl.andrewl.concord_server; +import lombok.Getter; import lombok.extern.java.Log; -import nl.andrewl.concord_core.msg.Serializer; -import nl.andrewl.concord_core.msg.types.Chat; -import nl.andrewl.concord_core.msg.types.ChatHistoryRequest; -import org.dizitart.no2.Document; +import nl.andrewl.concord_core.msg.types.Identification; +import nl.andrewl.concord_core.msg.types.ServerMetaData; +import nl.andrewl.concord_core.msg.types.ServerWelcome; import org.dizitart.no2.Nitrite; -import java.io.ByteArrayOutputStream; -import java.io.DataOutputStream; import java.io.IOException; import java.net.ServerSocket; import java.net.Socket; -import java.security.SecureRandom; +import java.util.Comparator; import java.util.Map; -import java.util.Random; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; @Log public class ConcordServer implements Runnable { - private final Map clients = new ConcurrentHashMap<>(32); + private final Map clients; private final int port; - private final Random random; + @Getter + private final IdProvider idProvider; + @Getter private final Nitrite db; + private volatile boolean running; + @Getter + private final ExecutorService executorService; + @Getter + private final EventManager eventManager; + @Getter + private final ChannelManager channelManager; public ConcordServer(int port) { this.port = port; - this.random = new SecureRandom(); + this.idProvider = new UUIDProvider(); this.db = Nitrite.builder() .filePath("concord-server.db") .openOrCreate(); + this.clients = new ConcurrentHashMap<>(32); + + this.executorService = Executors.newCachedThreadPool(); + this.eventManager = new EventManager(this); + this.channelManager = new ChannelManager(this); } - public long registerClient(ClientThread clientThread) { - long id = this.random.nextLong(); - log.info("Registering new client " + clientThread.getClientNickname() + " with id " + id); + /** + * Registers a new client as connected to the server. This is done once the + * client thread has received the correct identification information from + * the client. The server will register the client in its global set of + * connected clients, and it will immediately move the client to the default + * channel. + * @param identification The client's identification data. + * @param clientThread The client manager thread. + * @return The id of the client. + */ + public UUID registerClient(Identification identification, ClientThread clientThread) { + var id = this.idProvider.newId(); + log.info("Registering new client " + identification.getNickname() + " with id " + id); this.clients.put(id, clientThread); + // Send a welcome reply containing all the initial server info the client needs. + ServerMetaData metaData = new ServerMetaData( + "Testing Server", + this.channelManager.getChannels().stream() + .map(channel -> new ServerMetaData.ChannelData(channel.getId(), channel.getName())) + .sorted(Comparator.comparing(ServerMetaData.ChannelData::getName)) + .collect(Collectors.toList()) + ); + var defaultChannel = this.channelManager.getChannelByName("general").orElseThrow(); + defaultChannel.addClient(clientThread); + clientThread.setCurrentChannel(defaultChannel); + clientThread.sendToClient(new ServerWelcome(id, defaultChannel.getId(), metaData)); + return id; } - public void deregisterClient(long clientId) { - this.clients.remove(clientId); - } - - public void handleChat(Chat chat) { - var collection = db.getCollection("channel-TEST"); - long messageId = this.random.nextLong(); - Document doc = Document.createDocument(Long.toHexString(messageId), "message") - .put("senderId", Long.toHexString(chat.getSenderId())) - .put("senderNickname", chat.getSenderNickname()) - .put("timestamp", chat.getTimestamp()) - .put("message", chat.getMessage()); - collection.insert(doc); - db.commit(); - System.out.println(chat.getSenderNickname() + ": " + chat.getMessage()); - ByteArrayOutputStream baos = new ByteArrayOutputStream(chat.getByteCount()); - try { - Serializer.writeMessage(chat, new DataOutputStream(baos)); - } catch (IOException e) { - e.printStackTrace(); - return; + public void deregisterClient(UUID clientId) { + var client = this.clients.remove(clientId); + if (client != null) { + client.getCurrentChannel().removeClient(client); + client.shutdown(); } - byte[] data = baos.toByteArray(); - for (var client : clients.values()) { - client.sendToClient(data); - } - } - - public void handleHistoryRequest(ChatHistoryRequest request, ClientThread clientThread) { - } @Override public void run() { + this.running = true; ServerSocket serverSocket; try { serverSocket = new ServerSocket(this.port); log.info("Opened server on port " + this.port); - while (true) { + while (this.running) { Socket socket = serverSocket.accept(); - log.info("Accepted new socket connection."); + log.info("Accepted new socket connection from " + socket.getInetAddress().getHostAddress()); ClientThread clientThread = new ClientThread(socket, this); clientThread.start(); } diff --git a/server/src/main/java/nl/andrewl/concord_server/EventManager.java b/server/src/main/java/nl/andrewl/concord_server/EventManager.java new file mode 100644 index 0000000..cdc1deb --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/EventManager.java @@ -0,0 +1,37 @@ +package nl.andrewl.concord_server; + +import lombok.extern.java.Log; +import nl.andrewl.concord_core.msg.Message; +import nl.andrewl.concord_core.msg.types.Chat; +import nl.andrewl.concord_core.msg.types.MoveToChannel; +import nl.andrewl.concord_server.event.ChannelMoveHandler; +import nl.andrewl.concord_server.event.ChatHandler; +import nl.andrewl.concord_server.event.MessageHandler; + +import java.util.HashMap; +import java.util.Map; + +@Log +public class EventManager { + private final Map, MessageHandler> messageHandlers; + private final ConcordServer server; + + public EventManager(ConcordServer server) { + this.server = server; + this.messageHandlers = new HashMap<>(); + this.messageHandlers.put(Chat.class, new ChatHandler()); + this.messageHandlers.put(MoveToChannel.class, new ChannelMoveHandler()); + } + + @SuppressWarnings("unchecked") + public void handle(T message, ClientThread client) { + MessageHandler handler = (MessageHandler) this.messageHandlers.get(message.getClass()); + if (handler != null) { + try { + handler.handle(message, client, this.server); + } catch (Exception e) { + log.warning("Exception occurred while handling message: " + e.getMessage()); + } + } + } +} diff --git a/server/src/main/java/nl/andrewl/concord_server/IdProvider.java b/server/src/main/java/nl/andrewl/concord_server/IdProvider.java new file mode 100644 index 0000000..c3318c9 --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/IdProvider.java @@ -0,0 +1,7 @@ +package nl.andrewl.concord_server; + +import java.util.UUID; + +public interface IdProvider { + UUID newId(); +} diff --git a/server/src/main/java/nl/andrewl/concord_server/UUIDProvider.java b/server/src/main/java/nl/andrewl/concord_server/UUIDProvider.java new file mode 100644 index 0000000..bea51ca --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/UUIDProvider.java @@ -0,0 +1,10 @@ +package nl.andrewl.concord_server; + +import java.util.UUID; + +public class UUIDProvider implements IdProvider { + @Override + public UUID newId() { + return UUID.randomUUID(); + } +} diff --git a/server/src/main/java/nl/andrewl/concord_server/event/ChannelMoveHandler.java b/server/src/main/java/nl/andrewl/concord_server/event/ChannelMoveHandler.java new file mode 100644 index 0000000..6688985 --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/event/ChannelMoveHandler.java @@ -0,0 +1,16 @@ +package nl.andrewl.concord_server.event; + +import nl.andrewl.concord_core.msg.types.MoveToChannel; +import nl.andrewl.concord_server.ClientThread; +import nl.andrewl.concord_server.ConcordServer; + +/** + * Handles client requests to move to another channel. + */ +public class ChannelMoveHandler implements MessageHandler { + @Override + public void handle(MoveToChannel msg, ClientThread client, ConcordServer server) { + var optionalChannel = server.getChannelManager().getChannelById(msg.getChannelId()); + optionalChannel.ifPresent(channel -> server.getChannelManager().moveToChannel(client, channel)); + } +} diff --git a/server/src/main/java/nl/andrewl/concord_server/event/ChatHandler.java b/server/src/main/java/nl/andrewl/concord_server/event/ChatHandler.java new file mode 100644 index 0000000..7f395ad --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/event/ChatHandler.java @@ -0,0 +1,29 @@ +package nl.andrewl.concord_server.event; + +import nl.andrewl.concord_core.msg.types.Chat; +import nl.andrewl.concord_server.ClientThread; +import nl.andrewl.concord_server.ConcordServer; +import org.dizitart.no2.Document; + +import java.io.IOException; +import java.util.Map; + +public class ChatHandler implements MessageHandler { + @Override + public void handle(Chat msg, ClientThread client, ConcordServer server) throws IOException { + server.getExecutorService().submit(() -> { + var collection = server.getDb().getCollection("channel-" + client.getCurrentChannel().getId()); + var messageId = server.getIdProvider().newId(); + Document doc = new Document(Map.of( + "_id", messageId, + "senderId", msg.getSenderId(), + "senderNickname", msg.getSenderNickname(), + "timestamp", msg.getTimestamp(), + "message", msg.getMessage() + )); + collection.insert(doc); + }); + System.out.printf("#%s | %s: %s\n", client.getCurrentChannel(), client.getClientNickname(), msg.getMessage()); + client.getCurrentChannel().sendMessage(msg); + } +} diff --git a/server/src/main/java/nl/andrewl/concord_server/event/EventListener.java b/server/src/main/java/nl/andrewl/concord_server/event/EventListener.java new file mode 100644 index 0000000..b4385bc --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/event/EventListener.java @@ -0,0 +1,9 @@ +package nl.andrewl.concord_server.event; + +import nl.andrewl.concord_core.msg.types.Chat; +import nl.andrewl.concord_server.ClientThread; +import nl.andrewl.concord_server.ConcordServer; + +public interface EventListener { + default void chatMessageReceived(ConcordServer server, Chat chat, ClientThread client) {} +} diff --git a/server/src/main/java/nl/andrewl/concord_server/event/MessageHandler.java b/server/src/main/java/nl/andrewl/concord_server/event/MessageHandler.java new file mode 100644 index 0000000..aa5df2b --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/event/MessageHandler.java @@ -0,0 +1,9 @@ +package nl.andrewl.concord_server.event; + +import nl.andrewl.concord_core.msg.Message; +import nl.andrewl.concord_server.ClientThread; +import nl.andrewl.concord_server.ConcordServer; + +public interface MessageHandler { + void handle(T msg, ClientThread client, ConcordServer server) throws Exception; +}