Improved client event handling, and server consistency and chat history requests.

This commit is contained in:
Andrew Lalis 2021-08-25 14:36:49 +02:00
parent 11e49696a4
commit 960f66cf13
28 changed files with 443 additions and 164 deletions

View File

@ -21,7 +21,7 @@
<dependency> <dependency>
<groupId>com.googlecode.lanterna</groupId> <groupId>com.googlecode.lanterna</groupId>
<artifactId>lanterna</artifactId> <artifactId>lanterna</artifactId>
<version>3.1.1</version> <version>3.2.0-alpha1</version>
</dependency> </dependency>
</dependencies> </dependencies>
</project> </project>

View File

@ -1,9 +0,0 @@
package nl.andrewl.concord_client;
import nl.andrewl.concord_core.msg.Message;
import java.io.IOException;
public interface ClientMessageListener {
void messageReceived(ConcordClient client, Message message) throws IOException;
}

View File

@ -8,8 +8,12 @@ import com.googlecode.lanterna.screen.TerminalScreen;
import com.googlecode.lanterna.terminal.DefaultTerminalFactory; import com.googlecode.lanterna.terminal.DefaultTerminalFactory;
import com.googlecode.lanterna.terminal.Terminal; import com.googlecode.lanterna.terminal.Terminal;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import nl.andrewl.concord_client.event.EventManager;
import nl.andrewl.concord_client.event.handlers.ChannelMovedHandler;
import nl.andrewl.concord_client.event.handlers.ChannelUsersResponseHandler;
import nl.andrewl.concord_client.event.handlers.ChatHistoryResponseHandler;
import nl.andrewl.concord_client.gui.MainWindow; import nl.andrewl.concord_client.gui.MainWindow;
import nl.andrewl.concord_client.model.ClientModel;
import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.Serializer; import nl.andrewl.concord_core.msg.Serializer;
import nl.andrewl.concord_core.msg.types.*; import nl.andrewl.concord_core.msg.types.*;
@ -18,52 +22,41 @@ import java.io.DataInputStream;
import java.io.DataOutputStream; import java.io.DataOutputStream;
import java.io.IOException; import java.io.IOException;
import java.net.Socket; import java.net.Socket;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.UUID;
public class ConcordClient implements Runnable { public class ConcordClient implements Runnable {
private final Socket socket; private final Socket socket;
private final DataInputStream in; private final DataInputStream in;
private final DataOutputStream out; private final DataOutputStream out;
private final UUID id;
private final String nickname;
@Getter @Getter
@Setter private final ClientModel model;
private UUID currentChannelId;
@Getter private final EventManager eventManager;
private ServerMetaData serverMetaData;
private final Set<ClientMessageListener> messageListeners;
private volatile boolean running; private volatile boolean running;
public ConcordClient(String host, int port, String nickname) throws IOException { public ConcordClient(String host, int port, String nickname) throws IOException {
this.eventManager = new EventManager(this);
this.socket = new Socket(host, port); this.socket = new Socket(host, port);
this.in = new DataInputStream(this.socket.getInputStream()); this.in = new DataInputStream(this.socket.getInputStream());
this.out = new DataOutputStream(this.socket.getOutputStream()); this.out = new DataOutputStream(this.socket.getOutputStream());
this.nickname = nickname;
Serializer.writeMessage(new Identification(nickname), this.out); Serializer.writeMessage(new Identification(nickname), this.out);
Message reply = Serializer.readMessage(this.in); Message reply = Serializer.readMessage(this.in);
if (reply instanceof ServerWelcome welcome) { if (reply instanceof ServerWelcome welcome) {
this.id = welcome.getClientId(); this.model = new ClientModel(welcome.getClientId(), nickname, welcome.getCurrentChannelId(), welcome.getMetaData());
this.currentChannelId = welcome.getCurrentChannelId();
this.serverMetaData = welcome.getMetaData();
// Start fetching initial data for the channel we were initially put into. // Start fetching initial data for the channel we were initially put into.
this.sendMessage(new ChannelUsersRequest(this.currentChannelId)); this.sendMessage(new ChannelUsersRequest(this.model.getCurrentChannelId()));
this.sendMessage(new ChatHistoryRequest(this.currentChannelId, ChatHistoryRequest.Source.CHANNEL, "")); this.sendMessage(new ChatHistoryRequest(this.model.getCurrentChannelId(), ""));
} else { } else {
throw new IOException("Unexpected response from the server after sending identification message."); throw new IOException("Unexpected response from the server after sending identification message.");
} }
this.messageListeners = new HashSet<>();
}
public void addListener(ClientMessageListener listener) { // Add event listeners.
this.messageListeners.add(listener); this.eventManager.addHandler(MoveToChannel.class, new ChannelMovedHandler());
} this.eventManager.addHandler(ChannelUsersResponse.class, new ChannelUsersResponseHandler());
this.eventManager.addHandler(ChatHistoryResponse.class, new ChatHistoryResponseHandler());
public void removeListener(ClientMessageListener listener) { this.eventManager.addHandler(Chat.class, (msg, client) -> client.getModel().getChatHistory().addChat(msg));
this.messageListeners.remove(listener);
} }
public void sendMessage(Message message) throws IOException { public void sendMessage(Message message) throws IOException {
@ -71,7 +64,7 @@ public class ConcordClient implements Runnable {
} }
public void sendChat(String message) throws IOException { public void sendChat(String message) throws IOException {
Serializer.writeMessage(new Chat(this.id, this.nickname, System.currentTimeMillis(), message), this.out); Serializer.writeMessage(new Chat(this.model.getId(), this.model.getNickname(), System.currentTimeMillis(), message), this.out);
} }
public void shutdown() { public void shutdown() {
@ -91,9 +84,7 @@ public class ConcordClient implements Runnable {
while (this.running) { while (this.running) {
try { try {
Message msg = Serializer.readMessage(this.in); Message msg = Serializer.readMessage(this.in);
for (var listener : this.messageListeners) { this.eventManager.handle(msg);
listener.messageReceived(this, msg);
}
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); e.printStackTrace();
this.running = false; this.running = false;

View File

@ -0,0 +1,12 @@
package nl.andrewl.concord_client.event;
import nl.andrewl.concord_client.model.ChatHistory;
import nl.andrewl.concord_core.msg.types.Chat;
public interface ChatHistoryListener {
default void chatAdded(Chat chat) {}
default void chatRemoved(Chat chat) {}
default void chatUpdated(ChatHistory history) {}
}

View File

@ -0,0 +1,12 @@
package nl.andrewl.concord_client.event;
import nl.andrewl.concord_core.msg.types.ChannelUsersResponse;
import java.util.List;
import java.util.UUID;
public interface ClientModelListener {
default void channelMoved(UUID oldChannelId, UUID newChannelId) {}
default void usersUpdated(List<ChannelUsersResponse.UserData> users) {}
}

View File

@ -0,0 +1,38 @@
package nl.andrewl.concord_client.event;
import nl.andrewl.concord_client.ConcordClient;
import nl.andrewl.concord_core.msg.Message;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
public class EventManager {
private final Map<Class<? extends Message>, List<MessageHandler<?>>> messageHandlers;
private final ConcordClient client;
public EventManager(ConcordClient client) {
this.client = client;
this.messageHandlers = new ConcurrentHashMap<>();
}
public <T extends Message> void addHandler(Class<T> messageClass, MessageHandler<T> handler) {
var handlers = this.messageHandlers.computeIfAbsent(messageClass, k -> new CopyOnWriteArrayList<>());
handlers.add(handler);
}
@SuppressWarnings("unchecked")
public <T extends Message> void handle(T message) {
var handlers = this.messageHandlers.get(message.getClass());
if (handlers != null) {
for (var handler : handlers) {
try {
((MessageHandler<T>) handler).handle(message, this.client);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}

View File

@ -0,0 +1,8 @@
package nl.andrewl.concord_client.event;
import nl.andrewl.concord_client.ConcordClient;
import nl.andrewl.concord_core.msg.Message;
public interface MessageHandler<T extends Message> {
void handle(T msg, ConcordClient client) throws Exception;
}

View File

@ -0,0 +1,16 @@
package nl.andrewl.concord_client.event.handlers;
import nl.andrewl.concord_client.ConcordClient;
import nl.andrewl.concord_client.event.MessageHandler;
import nl.andrewl.concord_core.msg.types.ChannelUsersRequest;
import nl.andrewl.concord_core.msg.types.ChatHistoryRequest;
import nl.andrewl.concord_core.msg.types.MoveToChannel;
public class ChannelMovedHandler implements MessageHandler<MoveToChannel> {
@Override
public void handle(MoveToChannel msg, ConcordClient client) throws Exception {
client.getModel().setCurrentChannelId(msg.getChannelId());
client.sendMessage(new ChatHistoryRequest(msg.getChannelId(), ""));
client.sendMessage(new ChannelUsersRequest(msg.getChannelId()));
}
}

View File

@ -0,0 +1,12 @@
package nl.andrewl.concord_client.event.handlers;
import nl.andrewl.concord_client.ConcordClient;
import nl.andrewl.concord_client.event.MessageHandler;
import nl.andrewl.concord_core.msg.types.ChannelUsersResponse;
public class ChannelUsersResponseHandler implements MessageHandler<ChannelUsersResponse> {
@Override
public void handle(ChannelUsersResponse msg, ConcordClient client) throws Exception {
client.getModel().setKnownUsers(msg.getUsers());
}
}

View File

@ -0,0 +1,12 @@
package nl.andrewl.concord_client.event.handlers;
import nl.andrewl.concord_client.ConcordClient;
import nl.andrewl.concord_client.event.MessageHandler;
import nl.andrewl.concord_core.msg.types.ChatHistoryResponse;
public class ChatHistoryResponseHandler implements MessageHandler<ChatHistoryResponse> {
@Override
public void handle(ChatHistoryResponse msg, ConcordClient client) throws Exception {
client.getModel().getChatHistory().setChats(msg.getMessages());
}
}

View File

@ -26,6 +26,7 @@ public class ChannelChatBox extends Panel {
super(new BorderLayout()); super(new BorderLayout());
this.client = client; this.client = client;
this.chatList = new ChatList(); this.chatList = new ChatList();
this.client.getModel().getChatHistory().addListener(this.chatList);
this.inputTextBox = new TextBox("", TextBox.Style.MULTI_LINE); this.inputTextBox = new TextBox("", TextBox.Style.MULTI_LINE);
this.inputTextBox.setCaretWarp(true); this.inputTextBox.setCaretWarp(true);
this.inputTextBox.setPreferredSize(new TerminalSize(0, 3)); this.inputTextBox.setPreferredSize(new TerminalSize(0, 3));
@ -37,7 +38,6 @@ public class ChannelChatBox extends Panel {
String text = inputTextBox.getText(); String text = inputTextBox.getText();
if (text != null && !text.isBlank()) { if (text != null && !text.isBlank()) {
try { try {
System.out.println("Sending: " + text.trim());
client.sendChat(text.trim()); client.sendChat(text.trim());
inputTextBox.setText(""); inputTextBox.setText("");
} catch (IOException e) { } catch (IOException e) {
@ -53,8 +53,8 @@ public class ChannelChatBox extends Panel {
} }
public void refreshBorder() { public void refreshBorder() {
String name = client.getServerMetaData().getChannels().stream() String name = client.getModel().getServerMetaData().getChannels().stream()
.filter(channelData -> channelData.getId().equals(client.getCurrentChannelId())) .filter(channelData -> channelData.getId().equals(client.getModel().getCurrentChannelId()))
.findAny().orElseThrow().getName(); .findAny().orElseThrow().getName();
if (this.chatBorder != null) this.removeComponent(this.chatBorder); if (this.chatBorder != null) this.removeComponent(this.chatBorder);
this.chatBorder = Borders.doubleLine("#" + name); this.chatBorder = Borders.doubleLine("#" + name);

View File

@ -6,6 +6,10 @@ import nl.andrewl.concord_core.msg.types.MoveToChannel;
import java.io.IOException; import java.io.IOException;
/**
* Panel that contains a list of channels. A user can interact with a channel to
* move to that channel. The current channel is indicated via a "*".
*/
public class ChannelList extends Panel { public class ChannelList extends Panel {
private final ConcordClient client; private final ConcordClient client;
public ChannelList(ConcordClient client) { public ChannelList(ConcordClient client) {
@ -15,13 +19,13 @@ public class ChannelList extends Panel {
public void setChannels() { public void setChannels() {
this.removeAllComponents(); this.removeAllComponents();
for (var channel : this.client.getServerMetaData().getChannels()) { for (var channel : this.client.getModel().getServerMetaData().getChannels()) {
String name = channel.getName(); String name = channel.getName();
if (client.getCurrentChannelId().equals(channel.getId())) { if (client.getModel().getCurrentChannelId().equals(channel.getId())) {
name = "*" + name; name = "*" + name;
} }
Button b = new Button(name, () -> { Button b = new Button(name, () -> {
if (!client.getCurrentChannelId().equals(channel.getId())) { if (!client.getModel().getCurrentChannelId().equals(channel.getId())) {
try { try {
client.sendMessage(new MoveToChannel(channel.getId())); client.sendMessage(new MoveToChannel(channel.getId()));
} catch (IOException e) { } catch (IOException e) {

View File

@ -1,9 +1,15 @@
package nl.andrewl.concord_client.gui; package nl.andrewl.concord_client.gui;
import com.googlecode.lanterna.gui2.AbstractListBox; import com.googlecode.lanterna.gui2.AbstractListBox;
import nl.andrewl.concord_client.event.ChatHistoryListener;
import nl.andrewl.concord_client.model.ChatHistory;
import nl.andrewl.concord_core.msg.types.Chat; import nl.andrewl.concord_core.msg.types.Chat;
public class ChatList extends AbstractListBox<Chat, ChatList> { /**
* This chat list shows a section of chat messages that have been sent in a
* single channel (server channel, thread, or direct message).
*/
public class ChatList extends AbstractListBox<Chat, ChatList> implements ChatHistoryListener {
/** /**
* Adds one more item to the list box, at the end. * Adds one more item to the list box, at the end.
* *
@ -28,4 +34,31 @@ public class ChatList extends AbstractListBox<Chat, ChatList> {
protected ListItemRenderer<Chat, ChatList> createDefaultListItemRenderer() { protected ListItemRenderer<Chat, ChatList> createDefaultListItemRenderer() {
return new ChatRenderer(); return new ChatRenderer();
} }
@Override
public void chatAdded(Chat chat) {
this.getTextGUI().getGUIThread().invokeLater(() -> {
this.addItem(chat);
});
}
@Override
public void chatRemoved(Chat chat) {
for (int i = 0; i < this.getItemCount(); i++) {
if (this.getItemAt(i).equals(chat)) {
this.removeItem(i);
return;
}
}
}
@Override
public void chatUpdated(ChatHistory history) {
this.getTextGUI().getGUIThread().invokeLater(() -> {
this.clearItems();
for (var chat : history.getChats()) {
this.addItem(chat);
}
});
}
} }

View File

@ -52,7 +52,7 @@ public class MainWindow extends BasicWindow {
try { try {
var client = new ConcordClient(host, port, nickname); var client = new ConcordClient(host, port, nickname);
var chatPanel = new ServerPanel(client, this); var chatPanel = new ServerPanel(client, this);
client.addListener(chatPanel); client.getModel().addListener(chatPanel);
new Thread(client).start(); new Thread(client).start();
this.setComponent(chatPanel); this.setComponent(chatPanel);
} catch (IOException e) { } catch (IOException e) {

View File

@ -2,12 +2,12 @@ package nl.andrewl.concord_client.gui;
import com.googlecode.lanterna.gui2.*; import com.googlecode.lanterna.gui2.*;
import lombok.Getter; import lombok.Getter;
import nl.andrewl.concord_client.ClientMessageListener;
import nl.andrewl.concord_client.ConcordClient; import nl.andrewl.concord_client.ConcordClient;
import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_client.event.ClientModelListener;
import nl.andrewl.concord_core.msg.types.*; import nl.andrewl.concord_core.msg.types.ChannelUsersResponse;
import java.io.IOException; import java.util.List;
import java.util.UUID;
/** /**
* The main panel in which a user interacts with the application during normal * The main panel in which a user interacts with the application during normal
@ -16,19 +16,17 @@ import java.io.IOException;
* meta information in the sidebars which provides the user with a list of all * meta information in the sidebars which provides the user with a list of all
* threads and users in the server. * threads and users in the server.
*/ */
public class ServerPanel extends Panel implements ClientMessageListener { public class ServerPanel extends Panel implements ClientModelListener {
@Getter @Getter
private final ChannelChatBox channelChatBox; private final ChannelChatBox channelChatBox;
private final ChannelList channelList; private final ChannelList channelList;
private final UserList userList; private final UserList userList;
private final ConcordClient client;
private final TextGUIThread guiThread; private final TextGUIThread guiThread;
public ServerPanel(ConcordClient client, Window window) { public ServerPanel(ConcordClient client, Window window) {
super(new BorderLayout()); super(new BorderLayout());
this.guiThread = window.getTextGUI().getGUIThread(); this.guiThread = window.getTextGUI().getGUIThread();
this.client = client;
this.channelChatBox = new ChannelChatBox(client, window); this.channelChatBox = new ChannelChatBox(client, window);
this.channelList = new ChannelList(client); this.channelList = new ChannelList(client);
this.channelList.setChannels(); this.channelList.setChannels();
@ -47,36 +45,19 @@ public class ServerPanel extends Panel implements ClientMessageListener {
} }
@Override @Override
public void messageReceived(ConcordClient client, Message message) { public void channelMoved(UUID oldChannelId, UUID newChannelId) {
if (message instanceof Chat chat) { this.getTextGUI().getGUIThread().invokeLater(() -> {
this.channelChatBox.getChatList().addItem(chat);
} else if (message instanceof MoveToChannel moveToChannel) {
client.setCurrentChannelId(moveToChannel.getChannelId());
try {
client.sendMessage(new ChatHistoryRequest(moveToChannel.getChannelId(), ChatHistoryRequest.Source.CHANNEL, ""));
client.sendMessage(new ChannelUsersRequest(moveToChannel.getChannelId()));
} catch (IOException e) {
e.printStackTrace();
}
this.guiThread.invokeLater(() -> {
this.channelList.setChannels(); this.channelList.setChannels();
this.channelChatBox.getChatList().clearItems(); this.channelChatBox.getChatList().clearItems();
this.channelChatBox.refreshBorder(); this.channelChatBox.refreshBorder();
this.channelChatBox.getInputTextBox().takeFocus(); this.channelChatBox.getInputTextBox().takeFocus();
}); });
} else if (message instanceof ChannelUsersResponse channelUsersResponse) {
this.guiThread.invokeLater(() -> {
this.userList.updateUsers(channelUsersResponse);
});
} else if (message instanceof ChatHistoryResponse chatHistoryResponse) {
System.out.println("Got chat history response: " + chatHistoryResponse.getSourceId());
System.out.println(chatHistoryResponse.getMessages());
this.guiThread.invokeLater(() -> {
this.channelChatBox.getChatList().clearItems();
for (var chat : chatHistoryResponse.getMessages()) {
this.channelChatBox.getChatList().addItem(chat);
} }
@Override
public void usersUpdated(List<ChannelUsersResponse.UserData> users) {
this.guiThread.invokeLater(() -> {
this.userList.updateUsers(users);
}); });
} }
}
} }

View File

@ -7,6 +7,8 @@ import com.googlecode.lanterna.gui2.Panel;
import nl.andrewl.concord_client.ConcordClient; import nl.andrewl.concord_client.ConcordClient;
import nl.andrewl.concord_core.msg.types.ChannelUsersResponse; import nl.andrewl.concord_core.msg.types.ChannelUsersResponse;
import java.util.List;
public class UserList extends Panel { public class UserList extends Panel {
private final ConcordClient client; private final ConcordClient client;
@ -15,9 +17,9 @@ public class UserList extends Panel {
this.client = client; this.client = client;
} }
public void updateUsers(ChannelUsersResponse usersResponse) { public void updateUsers(List<ChannelUsersResponse.UserData> usersResponse) {
this.removeAllComponents(); this.removeAllComponents();
for (var user : usersResponse.getUsers()) { for (var user : usersResponse) {
Button b = new Button(user.getName(), () -> { Button b = new Button(user.getName(), () -> {
System.out.println("Opening DM channel with user " + user.getName() + ", id: " + user.getId()); System.out.println("Opening DM channel with user " + user.getName() + ", id: " + user.getId());
}); });

View File

@ -0,0 +1,44 @@
package nl.andrewl.concord_client.model;
import lombok.Getter;
import nl.andrewl.concord_client.event.ChatHistoryListener;
import nl.andrewl.concord_core.msg.types.Chat;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* Stores information about a snippet of chat history that the client is
* currently viewing. This might be some older section of chats, or it could be
* the currently-in-use channel chats.
*/
public class ChatHistory {
@Getter
private List<Chat> chats;
private final List<ChatHistoryListener> chatHistoryListeners;
public ChatHistory() {
this.chats = new CopyOnWriteArrayList<>();
this.chatHistoryListeners = new CopyOnWriteArrayList<>();
}
public void setChats(List<Chat> chats) {
this.chats.clear();
this.chats.addAll(chats);
this.chatHistoryListeners.forEach(listener -> listener.chatUpdated(this));
}
public void addChat(Chat chat) {
this.chats.add(chat);
this.chatHistoryListeners.forEach(listener -> listener.chatAdded(chat));
}
public void addListener(ChatHistoryListener listener) {
this.chatHistoryListeners.add(listener);
}
public void removeListener(ChatHistoryListener listener) {
this.chatHistoryListeners.remove(listener);
}
}

View File

@ -0,0 +1,53 @@
package nl.andrewl.concord_client.model;
import lombok.Getter;
import nl.andrewl.concord_client.event.ClientModelListener;
import nl.andrewl.concord_core.msg.types.ChannelUsersResponse;
import nl.andrewl.concord_core.msg.types.ServerMetaData;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
@Getter
public class ClientModel {
private UUID id;
private String nickname;
private ServerMetaData serverMetaData;
private UUID currentChannelId;
private List<ChannelUsersResponse.UserData> knownUsers;
private final ChatHistory chatHistory;
private final List<ClientModelListener> modelListeners;
public ClientModel(UUID id, String nickname, UUID currentChannelId, ServerMetaData serverMetaData) {
this.modelListeners = new CopyOnWriteArrayList<>();
this.id = id;
this.nickname = nickname;
this.currentChannelId = currentChannelId;
this.serverMetaData = serverMetaData;
this.knownUsers = new ArrayList<>();
this.chatHistory = new ChatHistory();
}
public void setCurrentChannelId(UUID newChannelId) {
UUID oldId = this.currentChannelId;
this.currentChannelId = newChannelId;
this.modelListeners.forEach(listener -> listener.channelMoved(oldId, newChannelId));
}
public void setKnownUsers(List<ChannelUsersResponse.UserData> users) {
this.knownUsers = users;
this.modelListeners.forEach(listener -> listener.usersUpdated(this.knownUsers));
}
public void addListener(ClientModelListener listener) {
this.modelListeners.add(listener);
}
public void removeListener(ClientModelListener listener) {
this.modelListeners.remove(listener);
}
}

View File

@ -58,4 +58,16 @@ public class Chat implements Message {
public String toString() { public String toString() {
return String.format("%s: %s", this.senderNickname, this.message); return String.format("%s: %s", this.senderNickname, this.message);
} }
@Override
public boolean equals(Object o) {
if (o.getClass().equals(this.getClass())) {
Chat other = (Chat) o;
return this.getSenderId().equals(other.getSenderId()) &&
this.getTimestamp() == other.getTimestamp() &&
this.getSenderNickname().equals(other.getSenderNickname()) &&
this.message.length() == other.message.length();
}
return false;
}
} }

View File

@ -8,7 +8,10 @@ import nl.andrewl.concord_core.msg.Message;
import java.io.DataInputStream; import java.io.DataInputStream;
import java.io.DataOutputStream; import java.io.DataOutputStream;
import java.io.IOException; import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors;
import static nl.andrewl.concord_core.msg.MessageUtils.*; import static nl.andrewl.concord_core.msg.MessageUtils.*;
@ -49,12 +52,36 @@ import static nl.andrewl.concord_core.msg.MessageUtils.*;
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
public class ChatHistoryRequest implements Message { public class ChatHistoryRequest implements Message {
public enum Source {CHANNEL, THREAD, DIRECT_MESSAGE} private UUID channelId;
private UUID sourceId;
private Source sourceType;
private String query; private String query;
public ChatHistoryRequest(UUID channelId, Map<String, String> params) {
this.channelId = channelId;
this.query = params.entrySet().stream()
.map(entry -> {
if (entry.getKey().contains(";") || entry.getKey().contains("=")) {
throw new IllegalArgumentException("Parameter key \"" + entry.getKey() + "\" contains invalid characters.");
}
if (entry.getValue().contains(";") || entry.getValue().contains("=")) {
throw new IllegalArgumentException("Parameter value \"" + entry.getValue() + "\" contains invalid characters.");
}
return entry.getKey() + "=" + entry.getValue();
})
.collect(Collectors.joining(";"));
}
public Map<String, String> getQueryAsMap() {
String[] pairs = this.query.split(";");
if (pairs.length == 0) return Map.of();
Map<String, String> params = new HashMap<>(pairs.length);
for (var pair : pairs) {
String[] keyAndValue = pair.split("=");
if (keyAndValue.length != 2) continue;
params.put(keyAndValue[0], keyAndValue[1]);
}
return params;
}
@Override @Override
public int getByteCount() { public int getByteCount() {
return UUID_BYTES + Integer.BYTES + getByteSize(this.query); return UUID_BYTES + Integer.BYTES + getByteSize(this.query);
@ -62,15 +89,13 @@ public class ChatHistoryRequest implements Message {
@Override @Override
public void write(DataOutputStream o) throws IOException { public void write(DataOutputStream o) throws IOException {
writeUUID(this.sourceId, o); writeUUID(this.channelId, o);
writeEnum(this.sourceType, o);
writeString(this.query, o); writeString(this.query, o);
} }
@Override @Override
public void read(DataInputStream i) throws IOException { public void read(DataInputStream i) throws IOException {
this.sourceId = readUUID(i); this.channelId = readUUID(i);
this.sourceType = readEnum(Source.class, i);
this.query = readString(i); this.query = readString(i);
} }
} }

View File

@ -20,13 +20,12 @@ import java.util.UUID;
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
public class ChatHistoryResponse implements Message { public class ChatHistoryResponse implements Message {
private UUID sourceId; private UUID channelId;
private ChatHistoryRequest.Source sourceType;
List<Chat> messages; List<Chat> messages;
@Override @Override
public int getByteCount() { public int getByteCount() {
int count = Long.BYTES + Integer.BYTES + Integer.BYTES; int count = Long.BYTES + Integer.BYTES;
for (var message : this.messages) { for (var message : this.messages) {
count += message.getByteCount(); count += message.getByteCount();
} }
@ -35,8 +34,7 @@ public class ChatHistoryResponse implements Message {
@Override @Override
public void write(DataOutputStream o) throws IOException { public void write(DataOutputStream o) throws IOException {
MessageUtils.writeUUID(this.sourceId, o); MessageUtils.writeUUID(this.channelId, o);
MessageUtils.writeEnum(this.sourceType, o);
o.writeInt(messages.size()); o.writeInt(messages.size());
for (var message : this.messages) { for (var message : this.messages) {
message.write(o); message.write(o);
@ -45,8 +43,7 @@ public class ChatHistoryResponse implements Message {
@Override @Override
public void read(DataInputStream i) throws IOException { public void read(DataInputStream i) throws IOException {
this.sourceId = MessageUtils.readUUID(i); this.channelId = MessageUtils.readUUID(i);
this.sourceType = MessageUtils.readEnum(ChatHistoryRequest.Source.class, i);
int messageCount = i.readInt(); int messageCount = i.readInt();
Chat[] messages = new Chat[messageCount]; Chat[] messages = new Chat[messageCount];
for (int k = 0; k < messageCount; k++) { for (int k = 0; k < messageCount; k++) {

View File

@ -89,6 +89,6 @@ public class Channel {
@Override @Override
public String toString() { public String toString() {
return this.name; return this.name + " (" + this.id + ")";
} }
} }

View File

@ -49,6 +49,6 @@ public class ChannelManager {
channel.addClient(client); channel.addClient(client);
client.setCurrentChannel(channel); client.setCurrentChannel(channel);
client.sendToClient(new MoveToChannel(channel.getId())); client.sendToClient(new MoveToChannel(channel.getId()));
System.out.println("Moved client " + client.getClientNickname() + " to channel " + channel.getName()); System.out.println("Moved client " + client + " to channel " + channel);
} }
} }

View File

@ -2,7 +2,6 @@ package nl.andrewl.concord_server;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import lombok.extern.java.Log;
import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.Serializer; import nl.andrewl.concord_core.msg.Serializer;
import nl.andrewl.concord_core.msg.types.Identification; import nl.andrewl.concord_core.msg.types.Identification;
@ -17,7 +16,6 @@ import java.util.UUID;
* This thread is responsible for handling the connection to a single client of * This thread is responsible for handling the connection to a single client of
* a server. The client thread acts as the server's representation of a client. * a server. The client thread acts as the server's representation of a client.
*/ */
@Log
public class ClientThread extends Thread { public class ClientThread extends Thread {
private final Socket socket; private final Socket socket;
private final DataInputStream in; private final DataInputStream in;
@ -75,7 +73,7 @@ public class ClientThread extends Thread {
public void run() { public void run() {
this.running = true; this.running = true;
if (!identifyClient()) { if (!identifyClient()) {
log.warning("Could not identify the client; aborting connection."); System.err.println("Could not identify the client; aborting connection.");
this.running = false; this.running = false;
} }
@ -84,7 +82,6 @@ public class ClientThread extends Thread {
var msg = Serializer.readMessage(this.in); var msg = Serializer.readMessage(this.in);
this.server.getEventManager().handle(msg, this); this.server.getEventManager().handle(msg, this);
} catch (IOException e) { } catch (IOException e) {
log.info("Client disconnected: " + e.getMessage());
this.running = false; this.running = false;
} }
} }
@ -124,4 +121,9 @@ public class ClientThread extends Thread {
} }
return false; return false;
} }
@Override
public String toString() {
return this.clientNickname + " (" + this.clientId + ")";
}
} }

View File

@ -22,16 +22,19 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Log /**
* The main server implementation, which handles accepting new clients.
*/
public class ConcordServer implements Runnable { public class ConcordServer implements Runnable {
private final Map<UUID, ClientThread> clients; private final Map<UUID, ClientThread> clients;
private final int port; private volatile boolean running;
private final String name;
@Getter
private final ServerConfig config;
@Getter @Getter
private final IdProvider idProvider; private final IdProvider idProvider;
@Getter @Getter
private final Nitrite db; private final Nitrite db;
private volatile boolean running;
@Getter @Getter
private final ExecutorService executorService; private final ExecutorService executorService;
@Getter @Getter
@ -41,9 +44,7 @@ public class ConcordServer implements Runnable {
public ConcordServer() { public ConcordServer() {
this.idProvider = new UUIDProvider(); this.idProvider = new UUIDProvider();
ServerConfig config = ServerConfig.loadOrCreate(Path.of("server-config.json"), idProvider); this.config = ServerConfig.loadOrCreate(Path.of("server-config.json"), idProvider);
this.port = config.port();
this.name = config.name();
this.db = Nitrite.builder() this.db = Nitrite.builder()
.filePath("concord-server.db") .filePath("concord-server.db")
.openOrCreate(); .openOrCreate();
@ -67,15 +68,15 @@ public class ConcordServer implements Runnable {
for (var channel : this.channelManager.getChannels()) { for (var channel : this.channelManager.getChannels()) {
var col = channel.getMessageCollection(); var col = channel.getMessageCollection();
if (!col.hasIndex("timestamp")) { if (!col.hasIndex("timestamp")) {
log.info("Adding timestamp index to collection for channel " + channel.getName()); System.out.println("Adding timestamp index to collection for channel " + channel.getName());
col.createIndex("timestamp", IndexOptions.indexOptions(IndexType.NonUnique)); col.createIndex("timestamp", IndexOptions.indexOptions(IndexType.NonUnique));
} }
if (!col.hasIndex("senderNickname")) { if (!col.hasIndex("senderNickname")) {
log.info("Adding senderNickname index to collection for channel " + channel.getName()); System.out.println("Adding senderNickname index to collection for channel " + channel.getName());
col.createIndex("senderNickname", IndexOptions.indexOptions(IndexType.Fulltext)); col.createIndex("senderNickname", IndexOptions.indexOptions(IndexType.Fulltext));
} }
if (!col.hasIndex("message")) { if (!col.hasIndex("message")) {
log.info("Adding message index to collection for channel " + channel.getName()); System.out.println("Adding message index to collection for channel " + channel.getName());
col.createIndex("message", IndexOptions.indexOptions(IndexType.Fulltext)); col.createIndex("message", IndexOptions.indexOptions(IndexType.Fulltext));
} }
} }
@ -92,13 +93,13 @@ public class ConcordServer implements Runnable {
*/ */
public void registerClient(Identification identification, ClientThread clientThread) { public void registerClient(Identification identification, ClientThread clientThread) {
var id = this.idProvider.newId(); var id = this.idProvider.newId();
log.info("Registering new client " + identification.getNickname() + " with id " + id); System.out.printf("Client \"%s\" joined with id %s.\n", identification.getNickname(), id);
this.clients.put(id, clientThread); this.clients.put(id, clientThread);
clientThread.setClientId(id); clientThread.setClientId(id);
clientThread.setClientNickname(identification.getNickname()); clientThread.setClientNickname(identification.getNickname());
// Send a welcome reply containing all the initial server info the client needs. // Send a welcome reply containing all the initial server info the client needs.
ServerMetaData metaData = new ServerMetaData( ServerMetaData metaData = new ServerMetaData(
this.name, this.config.name(),
this.channelManager.getChannels().stream() this.channelManager.getChannels().stream()
.map(channel -> new ServerMetaData.ChannelData(channel.getId(), channel.getName())) .map(channel -> new ServerMetaData.ChannelData(channel.getId(), channel.getName()))
.sorted(Comparator.comparing(ServerMetaData.ChannelData::getName)) .sorted(Comparator.comparing(ServerMetaData.ChannelData::getName))
@ -110,6 +111,7 @@ public class ConcordServer implements Runnable {
// It is important that we send the welcome message first. The client expects this as the initial response to their identification message. // It is important that we send the welcome message first. The client expects this as the initial response to their identification message.
defaultChannel.addClient(clientThread); defaultChannel.addClient(clientThread);
clientThread.setCurrentChannel(defaultChannel); clientThread.setCurrentChannel(defaultChannel);
System.out.println("Moved client " + clientThread + " to " + defaultChannel);
} }
/** /**
@ -122,6 +124,7 @@ public class ConcordServer implements Runnable {
if (client != null) { if (client != null) {
client.getCurrentChannel().removeClient(client); client.getCurrentChannel().removeClient(client);
client.shutdown(); client.shutdown();
System.out.println("Client " + client + " has disconnected.");
} }
} }
@ -130,11 +133,15 @@ public class ConcordServer implements Runnable {
this.running = true; this.running = true;
ServerSocket serverSocket; ServerSocket serverSocket;
try { try {
serverSocket = new ServerSocket(this.port); serverSocket = new ServerSocket(this.config.port());
log.info("Opened server on port " + this.port); StringBuilder startupMessage = new StringBuilder();
startupMessage.append("Opened server on port ").append(config.port()).append("\n");
for (var channel : this.channelManager.getChannels()) {
startupMessage.append("\tChannel \"").append(channel).append('\n');
}
System.out.println(startupMessage);
while (this.running) { while (this.running) {
Socket socket = serverSocket.accept(); Socket socket = serverSocket.accept();
log.info("Accepted new socket connection from " + socket.getInetAddress().getHostAddress());
ClientThread clientThread = new ClientThread(socket, this); ClientThread clientThread = new ClientThread(socket, this);
clientThread.start(); clientThread.start();
} }

View File

@ -9,10 +9,15 @@ import java.io.UncheckedIOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@Log
public record ServerConfig( public record ServerConfig(
String name, String name,
int port, int port,
// Global Channel configuration
int chatHistoryMaxCount,
int chatHistoryDefaultCount,
int maxMessageLength,
ChannelConfig[] channels ChannelConfig[] channels
) { ) {
@ -29,6 +34,9 @@ public record ServerConfig(
config = new ServerConfig( config = new ServerConfig(
"My Concord Server", "My Concord Server",
8123, 8123,
100,
50,
8192,
new ServerConfig.ChannelConfig[]{ new ServerConfig.ChannelConfig[]{
new ServerConfig.ChannelConfig(idProvider.newId().toString(), "general", "Default channel for general discussion.") new ServerConfig.ChannelConfig(idProvider.newId().toString(), "general", "Default channel for general discussion.")
} }
@ -38,14 +46,14 @@ public record ServerConfig(
} catch (IOException e) { } catch (IOException e) {
throw new UncheckedIOException(e); throw new UncheckedIOException(e);
} }
log.info(filePath + " does not exist. Creating it with initial values. Edit and restart to apply changes."); System.err.println(filePath + " does not exist. Creating it with initial values. Edit and restart to apply changes.");
} else { } else {
try { try {
config = mapper.readValue(Files.newInputStream(filePath), ServerConfig.class); config = mapper.readValue(Files.newInputStream(filePath), ServerConfig.class);
} catch (IOException e) { } catch (IOException e) {
throw new UncheckedIOException(e); throw new UncheckedIOException(e);
} }
log.info("Loaded configuration from " + filePath); System.out.println("Loaded configuration from " + filePath);
} }
return config; return config;
} }

View File

@ -3,30 +3,62 @@ package nl.andrewl.concord_server.event;
import nl.andrewl.concord_core.msg.types.Chat; import nl.andrewl.concord_core.msg.types.Chat;
import nl.andrewl.concord_core.msg.types.ChatHistoryRequest; import nl.andrewl.concord_core.msg.types.ChatHistoryRequest;
import nl.andrewl.concord_core.msg.types.ChatHistoryResponse; import nl.andrewl.concord_core.msg.types.ChatHistoryResponse;
import nl.andrewl.concord_server.Channel;
import nl.andrewl.concord_server.ClientThread; import nl.andrewl.concord_server.ClientThread;
import nl.andrewl.concord_server.ConcordServer; import nl.andrewl.concord_server.ConcordServer;
import org.dizitart.no2.Document; import org.dizitart.no2.*;
import org.dizitart.no2.FindOptions; import org.dizitart.no2.filters.Filters;
import org.dizitart.no2.SortOrder;
import java.util.ArrayList; import java.util.*;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
/**
* Handles client requests for sections of chat history for a particular channel.
*/
public class ChatHistoryRequestHandler implements MessageHandler<ChatHistoryRequest> { public class ChatHistoryRequestHandler implements MessageHandler<ChatHistoryRequest> {
@Override @Override
public void handle(ChatHistoryRequest msg, ClientThread client, ConcordServer server) { public void handle(ChatHistoryRequest msg, ClientThread client, ConcordServer server) {
var optionalChannel = server.getChannelManager().getChannelById(msg.getSourceId()); var optionalChannel = server.getChannelManager().getChannelById(msg.getChannelId());
if (optionalChannel.isPresent()) { if (optionalChannel.isPresent()) {
var channel = optionalChannel.get(); var channel = optionalChannel.get();
System.out.println("Looking for chats in channel-" + channel.getId()); var params = msg.getQueryAsMap();
var col = server.getDb().getCollection("channel-" + channel.getId()); Long count = this.getOrDefault(params, "count", (long) server.getConfig().chatHistoryDefaultCount());
var cursor = col.find( if (count > server.getConfig().chatHistoryMaxCount()) {
FindOptions.sort("timestamp", SortOrder.Descending) return;
.thenLimit(0, 10) }
); Long from = this.getOrDefault(params, "from", null);
List<Chat> chats = new ArrayList<>(10); Long to = this.getOrDefault(params, "to", null);
client.sendToClient(this.getResponse(channel, count, from, to));
}
}
private Long getOrDefault(Map<String, String> params, String key, Long defaultValue) {
String value = params.get(key);
if (value == null) return defaultValue;
try {
return Long.parseLong(value);
} catch (NumberFormatException e) {
return defaultValue;
}
}
private ChatHistoryResponse getResponse(Channel channel, long count, Long from, Long to) {
var col = channel.getServer().getDb().getCollection("channel-" + channel.getId());
Cursor cursor;
FindOptions options = FindOptions.sort("timestamp", SortOrder.Descending).thenLimit(0, (int) count);
List<Filter> filters = new ArrayList<>(2);
if (from != null) {
filters.add(Filters.gt("timestamp", from));
}
if (to != null) {
filters.add(Filters.lt("timestamp", to));
}
if (filters.isEmpty()) {
cursor = col.find(options);
} else {
cursor = col.find(Filters.and(filters.toArray(new Filter[0])), options);
}
List<Chat> chats = new ArrayList<>((int) count);
for (Document doc : cursor) { for (Document doc : cursor) {
chats.add(new Chat( chats.add(new Chat(
doc.get("senderId", UUID.class), doc.get("senderId", UUID.class),
@ -37,7 +69,6 @@ public class ChatHistoryRequestHandler implements MessageHandler<ChatHistoryRequ
} }
col.close(); col.close();
chats.sort(Comparator.comparingLong(Chat::getTimestamp)); chats.sort(Comparator.comparingLong(Chat::getTimestamp));
client.sendToClient(new ChatHistoryResponse(msg.getSourceId(), msg.getSourceType(), chats)); return new ChatHistoryResponse(channel.getId(), chats);
}
} }
} }

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{dd.MM.yyyy HH:mm:ss} %boldCyan(%-34.-34thread) %red(%10.10X{jda.shard}) %boldGreen(%-15.-15logger{0}) %highlight(%-6level) %msg%n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="STDOUT" />
</root>
</configuration>