Added ability to add and remove channels.

This commit is contained in:
Andrew Lalis 2021-08-25 19:04:26 +02:00
parent b21137a767
commit f159708fa2
19 changed files with 370 additions and 100 deletions

View File

@ -12,6 +12,7 @@ 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.event.handlers.ServerMetaDataHandler;
import nl.andrewl.concord_client.gui.MainWindow;
import nl.andrewl.concord_client.model.ClientModel;
import nl.andrewl.concord_core.msg.Message;
@ -57,6 +58,7 @@ public class ConcordClient implements Runnable {
this.eventManager.addHandler(ChannelUsersResponse.class, new ChannelUsersResponseHandler());
this.eventManager.addHandler(ChatHistoryResponse.class, new ChatHistoryResponseHandler());
this.eventManager.addHandler(Chat.class, (msg, client) -> client.getModel().getChatHistory().addChat(msg));
this.eventManager.addHandler(ServerMetaData.class, new ServerMetaDataHandler());
}
public void sendMessage(Message message) throws IOException {

View File

@ -1,6 +1,7 @@
package nl.andrewl.concord_client.event;
import nl.andrewl.concord_core.msg.types.ChannelUsersResponse;
import nl.andrewl.concord_core.msg.types.ServerMetaData;
import nl.andrewl.concord_core.msg.types.UserData;
import java.util.List;
import java.util.UUID;
@ -8,5 +9,7 @@ import java.util.UUID;
public interface ClientModelListener {
default void channelMoved(UUID oldChannelId, UUID newChannelId) {}
default void usersUpdated(List<ChannelUsersResponse.UserData> users) {}
default void usersUpdated(List<UserData> users) {}
default void serverMetaDataUpdated(ServerMetaData metaData) {}
}

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.ServerMetaData;
public class ServerMetaDataHandler implements MessageHandler<ServerMetaData> {
@Override
public void handle(ServerMetaData msg, ConcordClient client) {
client.getModel().setServerMetaData(msg);
}
}

View File

@ -4,7 +4,8 @@ import com.googlecode.lanterna.gui2.*;
import lombok.Getter;
import nl.andrewl.concord_client.ConcordClient;
import nl.andrewl.concord_client.event.ClientModelListener;
import nl.andrewl.concord_core.msg.types.ChannelUsersResponse;
import nl.andrewl.concord_core.msg.types.ServerMetaData;
import nl.andrewl.concord_core.msg.types.UserData;
import java.util.List;
import java.util.UUID;
@ -22,11 +23,8 @@ public class ServerPanel extends Panel implements ClientModelListener {
private final ChannelList channelList;
private final UserList userList;
private final TextGUIThread guiThread;
public ServerPanel(ConcordClient client, Window window) {
super(new BorderLayout());
this.guiThread = window.getTextGUI().getGUIThread();
this.channelChatBox = new ChannelChatBox(client, window);
this.channelList = new ChannelList(client);
this.channelList.setChannels();
@ -55,9 +53,16 @@ public class ServerPanel extends Panel implements ClientModelListener {
}
@Override
public void usersUpdated(List<ChannelUsersResponse.UserData> users) {
this.guiThread.invokeLater(() -> {
public void usersUpdated(List<UserData> users) {
this.getTextGUI().getGUIThread().invokeLater(() -> {
this.userList.updateUsers(users);
});
}
@Override
public void serverMetaDataUpdated(ServerMetaData metaData) {
this.getTextGUI().getGUIThread().invokeLater(() -> {
this.channelList.setChannels();
});
}
}

View File

@ -6,6 +6,7 @@ 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.ChannelUsersResponse;
import nl.andrewl.concord_core.msg.types.UserData;
import java.util.List;
@ -17,7 +18,7 @@ public class UserList extends Panel {
this.client = client;
}
public void updateUsers(List<ChannelUsersResponse.UserData> usersResponse) {
public void updateUsers(List<UserData> usersResponse) {
this.removeAllComponents();
for (var user : usersResponse) {
Button b = new Button(user.getName(), () -> {

View File

@ -2,8 +2,8 @@ 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 nl.andrewl.concord_core.msg.types.UserData;
import java.util.ArrayList;
import java.util.List;
@ -17,7 +17,7 @@ public class ClientModel {
private ServerMetaData serverMetaData;
private UUID currentChannelId;
private List<ChannelUsersResponse.UserData> knownUsers;
private List<UserData> knownUsers;
private final ChatHistory chatHistory;
private final List<ClientModelListener> modelListeners;
@ -38,11 +38,16 @@ public class ClientModel {
this.modelListeners.forEach(listener -> listener.channelMoved(oldId, newChannelId));
}
public void setKnownUsers(List<ChannelUsersResponse.UserData> users) {
public void setKnownUsers(List<UserData> users) {
this.knownUsers = users;
this.modelListeners.forEach(listener -> listener.usersUpdated(this.knownUsers));
}
public void setServerMetaData(ServerMetaData metaData) {
this.serverMetaData = metaData;
this.modelListeners.forEach(listener -> listener.serverMetaDataUpdated(metaData));
}
public void addListener(ClientModelListener listener) {
this.modelListeners.add(listener);
}

View File

@ -21,6 +21,7 @@ public class Serializer {
registerType(5, ChatHistoryResponse.class);
registerType(6, ChannelUsersRequest.class);
registerType(7, ChannelUsersResponse.class);
registerType(8, ServerMetaData.class);
}
private static void registerType(int id, Class<? extends Message> clazz) {

View File

@ -9,7 +9,6 @@ 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.*;
@ -37,29 +36,4 @@ public class ChannelUsersResponse implements Message {
throw new IOException(e);
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class UserData 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);
}
}
}

View File

@ -0,0 +1,43 @@
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.*;
import static nl.andrewl.concord_core.msg.MessageUtils.readString;
/**
* Standard set of user data that is used mainly as a component of other more
* complex messages.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserData 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);
}
}

View File

@ -4,6 +4,9 @@ import lombok.Getter;
import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.Serializer;
import nl.andrewl.concord_core.msg.types.ChannelUsersResponse;
import nl.andrewl.concord_core.msg.types.UserData;
import org.dizitart.no2.IndexOptions;
import org.dizitart.no2.IndexType;
import org.dizitart.no2.NitriteCollection;
import java.io.ByteArrayOutputStream;
@ -31,6 +34,29 @@ public class Channel {
this.name = name;
this.connectedClients = ConcurrentHashMap.newKeySet();
this.messageCollection = messageCollection;
this.initCollection();
}
private void initCollection() {
if (!this.messageCollection.hasIndex("timestamp")) {
System.out.println("Adding index on \"timestamp\" field to collection " + this.messageCollection.getName());
this.messageCollection.createIndex("timestamp", IndexOptions.indexOptions(IndexType.NonUnique));
}
if (!this.messageCollection.hasIndex("senderNickname")) {
System.out.println("Adding index on \"senderNickname\" field to collection " + this.messageCollection.getName());
this.messageCollection.createIndex("senderNickname", IndexOptions.indexOptions(IndexType.Fulltext));
}
if (!this.messageCollection.hasIndex("message")) {
System.out.println("Adding index on \"message\" field to collection " + this.messageCollection.getName());
this.messageCollection.createIndex("message", IndexOptions.indexOptions(IndexType.Fulltext));
}
var fields = List.of("timestamp", "senderNickname", "message");
for (var index : this.messageCollection.listIndices()) {
if (!fields.contains(index.getField())) {
System.out.println("Dropping unknown index " + index.getField() + " from collection " + index.getCollectionName());
this.messageCollection.dropIndex(index.getField());
}
}
}
public void addClient(ClientThread clientThread) {
@ -66,12 +92,12 @@ public class Channel {
}
}
public List<ChannelUsersResponse.UserData> getUserData() {
List<ChannelUsersResponse.UserData> users = new ArrayList<>();
public List<UserData> getUserData() {
List<UserData> users = new ArrayList<>(this.connectedClients.size());
for (var clientThread : this.getConnectedClients()) {
users.add(new ChannelUsersResponse.UserData(clientThread.getClientId(), clientThread.getClientNickname()));
users.add(clientThread.toData());
}
users.sort(Comparator.comparing(ChannelUsersResponse.UserData::getName));
users.sort(Comparator.comparing(UserData::getName));
return users;
}

View File

@ -5,6 +5,7 @@ import lombok.Setter;
import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.Serializer;
import nl.andrewl.concord_core.msg.types.Identification;
import nl.andrewl.concord_core.msg.types.UserData;
import java.io.DataInputStream;
import java.io.DataOutputStream;
@ -122,6 +123,10 @@ public class ClientThread extends Thread {
return false;
}
public UserData toData() {
return new UserData(this.clientId, this.clientNickname);
}
@Override
public String toString() {
return this.clientNickname + " (" + this.clientId + ")";

View File

@ -1,19 +1,24 @@
package nl.andrewl.concord_server;
import lombok.Getter;
import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.Serializer;
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 nl.andrewl.concord_core.msg.types.UserData;
import nl.andrewl.concord_server.cli.ServerCli;
import nl.andrewl.concord_server.config.ServerConfig;
import org.dizitart.no2.IndexOptions;
import org.dizitart.no2.IndexType;
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.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@ -52,33 +57,14 @@ public class ConcordServer implements Runnable {
this.executorService = Executors.newCachedThreadPool();
this.eventManager = new EventManager(this);
this.channelManager = new ChannelManager(this);
for (var channelConfig : config.channels()) {
for (var channelConfig : config.getChannels()) {
this.channelManager.addChannel(new Channel(
this,
UUID.fromString(channelConfig.id()),
channelConfig.name(),
this.db.getCollection("channel-" + channelConfig.id())
UUID.fromString(channelConfig.getId()),
channelConfig.getName(),
this.db.getCollection("channel-" + channelConfig.getId())
));
}
this.updateDatabase();
}
private void updateDatabase() {
for (var channel : this.channelManager.getChannels()) {
var col = channel.getMessageCollection();
if (!col.hasIndex("timestamp")) {
System.out.println("Adding timestamp index to collection for channel " + channel.getName());
col.createIndex("timestamp", IndexOptions.indexOptions(IndexType.NonUnique));
}
if (!col.hasIndex("senderNickname")) {
System.out.println("Adding senderNickname index to collection for channel " + channel.getName());
col.createIndex("senderNickname", IndexOptions.indexOptions(IndexType.Fulltext));
}
if (!col.hasIndex("message")) {
System.out.println("Adding message index to collection for channel " + channel.getName());
col.createIndex("message", IndexOptions.indexOptions(IndexType.Fulltext));
}
}
}
/**
@ -96,17 +82,9 @@ public class ConcordServer implements Runnable {
this.clients.put(id, clientThread);
clientThread.setClientId(id);
clientThread.setClientNickname(identification.getNickname());
// Send a welcome reply containing all the initial server info the client needs.
ServerMetaData metaData = new ServerMetaData(
this.config.name(),
this.channelManager.getChannels().stream()
.map(channel -> new ServerMetaData.ChannelData(channel.getId(), channel.getName()))
.sorted(Comparator.comparing(ServerMetaData.ChannelData::getName))
.collect(Collectors.toList())
);
// Immediately add the client to the default channel and send the initial welcome message.
var defaultChannel = this.channelManager.getChannelByName("general").orElseThrow();
clientThread.sendToClient(new ServerWelcome(id, defaultChannel.getId(), metaData));
clientThread.sendToClient(new ServerWelcome(id, defaultChannel.getId(), this.getMetaData()));
// 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);
clientThread.setCurrentChannel(defaultChannel);
@ -127,14 +105,52 @@ public class ConcordServer implements Runnable {
}
}
public boolean isRunning() {
return running;
}
public List<UserData> getClients() {
return this.clients.values().stream()
.sorted(Comparator.comparing(ClientThread::getClientNickname))
.map(ClientThread::toData)
.collect(Collectors.toList());
}
public ServerMetaData getMetaData() {
return new ServerMetaData(
this.config.getName(),
this.channelManager.getChannels().stream()
.map(channel -> new ServerMetaData.ChannelData(channel.getId(), channel.getName()))
.sorted(Comparator.comparing(ServerMetaData.ChannelData::getName))
.collect(Collectors.toList())
);
}
/**
* Sends a message to every connected client.
* @param message The message to send.
*/
public void broadcast(Message message) {
ByteArrayOutputStream baos = new ByteArrayOutputStream(message.getByteCount());
try {
Serializer.writeMessage(message, baos);
byte[] data = baos.toByteArray();
for (var client : this.clients.values()) {
client.sendToClient(data);
}
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void run() {
this.running = true;
ServerSocket serverSocket;
try {
serverSocket = new ServerSocket(this.config.port());
serverSocket = new ServerSocket(this.config.getPort());
StringBuilder startupMessage = new StringBuilder();
startupMessage.append("Opened server on port ").append(config.port()).append("\n");
startupMessage.append("Opened server on port ").append(config.getPort()).append("\n");
for (var channel : this.channelManager.getChannels()) {
startupMessage.append("\tChannel \"").append(channel).append('\n');
}
@ -151,6 +167,7 @@ public class ConcordServer implements Runnable {
public static void main(String[] args) {
var server = new ConcordServer();
server.run();
new Thread(server).start();
new ServerCli(server).run();
}
}

View File

@ -0,0 +1,53 @@
package nl.andrewl.concord_server.cli;
import nl.andrewl.concord_server.ConcordServer;
import nl.andrewl.concord_server.cli.command.AddChannelCommand;
import nl.andrewl.concord_server.cli.command.ListClientsCommand;
import nl.andrewl.concord_server.cli.command.RemoveChannelCommand;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
public class ServerCli implements Runnable {
private final ConcordServer server;
private final Map<String, ServerCliCommand> commands;
public ServerCli(ConcordServer server) {
this.server = server;
this.commands = new HashMap<>();
this.commands.put("list-clients", new ListClientsCommand());
this.commands.put("add-channel", new AddChannelCommand());
this.commands.put("remove-channel", new RemoveChannelCommand());
}
@Override
public void run() {
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
String line;
try {
while (this.server.isRunning() && (line = reader.readLine()) != null) {
if (!line.isBlank()) {
String[] words = line.split("\\s+");
String command = words[0];
String[] args = Arrays.copyOfRange(words, 1, words.length);
var cliCommand = this.commands.get(command.trim().toLowerCase());
if (cliCommand != null) {
try {
cliCommand.handle(this.server, args);
} catch (Exception e) {
e.printStackTrace();
}
} else {
System.err.println("Unknown command.");
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}

View File

@ -0,0 +1,7 @@
package nl.andrewl.concord_server.cli;
import nl.andrewl.concord_server.ConcordServer;
public interface ServerCliCommand {
void handle(ConcordServer server, String[] args) throws Exception;
}

View File

@ -0,0 +1,36 @@
package nl.andrewl.concord_server.cli.command;
import nl.andrewl.concord_server.Channel;
import nl.andrewl.concord_server.ConcordServer;
import nl.andrewl.concord_server.cli.ServerCliCommand;
import nl.andrewl.concord_server.config.ServerConfig;
import java.util.UUID;
public class AddChannelCommand implements ServerCliCommand {
@Override
public void handle(ConcordServer server, String[] args) throws Exception {
if (args.length < 1) {
System.err.println("Missing required name argument.");
}
String name = args[0].trim().toLowerCase().replaceAll("\\s+", "-");
if (name.isBlank()) {
System.err.println("Cannot create channel with blank name.");
}
if (server.getChannelManager().getChannelByName(name).isPresent()) {
System.err.println("Channel with that name already exists.");
}
String description = null;
if (args.length > 1) {
description = args[1].trim();
}
UUID id = server.getIdProvider().newId();
var channelConfig = new ServerConfig.ChannelConfig(id.toString(), name, description);
server.getConfig().getChannels().add(channelConfig);
server.getConfig().save();
var col = server.getDb().getCollection("channel-" + id);
server.getChannelManager().addChannel(new Channel(server, id, name, col));
server.broadcast(server.getMetaData());
}
}

View File

@ -0,0 +1,20 @@
package nl.andrewl.concord_server.cli.command;
import nl.andrewl.concord_server.ConcordServer;
import nl.andrewl.concord_server.cli.ServerCliCommand;
public class ListClientsCommand implements ServerCliCommand {
@Override
public void handle(ConcordServer server, String[] args) throws Exception {
var users = server.getClients();
if (users.isEmpty()) {
System.out.println("There are no connected clients.");
} else {
StringBuilder sb = new StringBuilder("Online Users:\n");
for (var userData : users) {
sb.append("\t").append(userData.getName()).append(" (").append(userData.getId()).append(")\n");
}
System.out.print(sb);
}
}
}

View File

@ -0,0 +1,41 @@
package nl.andrewl.concord_server.cli.command;
import nl.andrewl.concord_server.Channel;
import nl.andrewl.concord_server.ConcordServer;
import nl.andrewl.concord_server.cli.ServerCliCommand;
import java.util.Optional;
public class RemoveChannelCommand implements ServerCliCommand {
@Override
public void handle(ConcordServer server, String[] args) throws Exception {
if (args.length != 1) {
System.err.println("Missing required channel name.");
return;
}
String name = args[0].trim().toLowerCase();
Optional<Channel> optionalChannel = server.getChannelManager().getChannelByName(name);
if (optionalChannel.isEmpty()) {
System.err.println("No channel with that name exists.");
return;
}
Channel channelToRemove = optionalChannel.get();
Channel alternative = null;
for (var c : server.getChannelManager().getChannels()) {
if (!c.equals(channelToRemove)) {
alternative = c;
break;
}
}
if (alternative == null) {
System.err.println("No alternative channel could be found. A server must always have at least one channel.");
return;
}
for (var client : channelToRemove.getConnectedClients()) {
server.getChannelManager().moveToChannel(client, alternative);
}
server.getChannelManager().removeChannel(channelToRemove);
server.getDb().getContext().dropCollection(channelToRemove.getMessageCollection().getName());
server.broadcast(server.getMetaData());
}
}

View File

@ -1,31 +1,43 @@
package nl.andrewl.concord_server.config;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.java.Log;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import nl.andrewl.concord_server.IdProvider;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public record ServerConfig(
String name,
int port,
@Data
@NoArgsConstructor
@AllArgsConstructor
public final class ServerConfig {
private String name;
private int port;
private int chatHistoryMaxCount;
private int chatHistoryDefaultCount;
private int maxMessageLength;
private List<ChannelConfig> channels;
// Global Channel configuration
int chatHistoryMaxCount,
int chatHistoryDefaultCount,
int maxMessageLength,
/**
* The path at which this config is stored.
*/
@JsonIgnore
private transient Path filePath;
ChannelConfig[] channels
) {
public static record ChannelConfig (
String id,
String name,
String description
) {}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static final class ChannelConfig {
private String id;
private String name;
private String description;
}
public static ServerConfig loadOrCreate(Path filePath, IdProvider idProvider) {
ObjectMapper mapper = new ObjectMapper();
@ -37,9 +49,8 @@ public record ServerConfig(
100,
50,
8192,
new ServerConfig.ChannelConfig[]{
new ServerConfig.ChannelConfig(idProvider.newId().toString(), "general", "Default channel for general discussion.")
}
List.of(new ChannelConfig(idProvider.newId().toString(), "general", "Default channel for general discussion.")),
filePath
);
try (var out = Files.newOutputStream(filePath)) {
mapper.writerWithDefaultPrettyPrinter().writeValue(out, config);
@ -50,6 +61,7 @@ public record ServerConfig(
} else {
try {
config = mapper.readValue(Files.newInputStream(filePath), ServerConfig.class);
config.setFilePath(filePath);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
@ -57,4 +69,11 @@ public record ServerConfig(
}
return config;
}
public void save() throws IOException {
ObjectMapper mapper = new ObjectMapper();
try (var out = Files.newOutputStream(filePath)) {
mapper.writerWithDefaultPrettyPrinter().writeValue(out, this);
}
}
}

View File

@ -21,8 +21,8 @@ public class ChatHistoryRequestHandler implements MessageHandler<ChatHistoryRequ
if (optionalChannel.isPresent()) {
var channel = optionalChannel.get();
var params = msg.getQueryAsMap();
Long count = this.getOrDefault(params, "count", (long) server.getConfig().chatHistoryDefaultCount());
if (count > server.getConfig().chatHistoryMaxCount()) {
Long count = this.getOrDefault(params, "count", (long) server.getConfig().getChatHistoryDefaultCount());
if (count > server.getConfig().getChatHistoryMaxCount()) {
return;
}
Long from = this.getOrDefault(params, "from", null);