Added initial prototype.

This commit is contained in:
Andrew Lalis 2021-08-21 21:49:01 +02:00
parent ae50a15c00
commit 468758c7ab
29 changed files with 980 additions and 1 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/.idea
/target

View File

@ -1,2 +1,11 @@
# Concord
Console-based real-time messaging platform.
Console-based real-time messaging platform, inspired by Discord.
This platform is organized by many independent servers, each of which supports the following:
- Multiple message channels. By default, there's one `general` channel.
- Broadcasting itself on certain discovery servers for users to find. The server decides where it wants to be discovered, if at all.
- Starting threads as spin-offs of a source message (with infinite recursion, i.e. threads within threads).
- Private message between users in a server. **No support for private messaging users outside the context of a server.**
- Banning users from the server.
Each server uses a single [Nitrite](https://www.dizitart.org/nitrite-database/#what-is-nitrite) database to hold messages and other information.

1
client/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

28
client/pom.xml Normal file
View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>concord</artifactId>
<groupId>nl.andrewl</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>concord-client</artifactId>
<dependencies>
<dependency>
<groupId>nl.andrewl</groupId>
<artifactId>concord-core</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>com.googlecode.lanterna</groupId>
<artifactId>lanterna</artifactId>
<version>3.1.1</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,4 @@
module concord_client {
requires concord_core;
requires com.googlecode.lanterna;
}

View File

@ -0,0 +1,9 @@
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

@ -0,0 +1,109 @@
package nl.andrewl.concord_client;
import com.googlecode.lanterna.gui2.MultiWindowTextGUI;
import com.googlecode.lanterna.gui2.Window;
import com.googlecode.lanterna.gui2.WindowBasedTextGUI;
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 nl.andrewl.concord_client.gui.MainWindow;
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.Identification;
import nl.andrewl.concord_core.msg.types.ServerWelcome;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class ConcordClient implements Runnable {
private final Socket socket;
private final DataInputStream in;
private final DataOutputStream out;
private final long id;
private final String nickname;
private final Set<ClientMessageListener> messageListeners;
private volatile boolean running;
public ConcordClient(String host, int port, String nickname) throws IOException {
this.socket = new Socket(host, port);
this.in = new DataInputStream(this.socket.getInputStream());
this.out = new DataOutputStream(this.socket.getOutputStream());
this.nickname = nickname;
Serializer.writeMessage(new Identification(nickname), this.out);
Message reply = Serializer.readMessage(this.in);
if (reply instanceof ServerWelcome welcome) {
this.id = welcome.getClientId();
} else {
throw new IOException("Unexpected response from the server after sending identification message.");
}
this.messageListeners = new HashSet<>();
}
public void addListener(ClientMessageListener listener) {
this.messageListeners.add(listener);
}
public void removeListener(ClientMessageListener listener) {
this.messageListeners.remove(listener);
}
public void sendChat(String message) throws IOException {
Serializer.writeMessage(new Chat(this.id, this.nickname, System.currentTimeMillis(), message), this.out);
}
public void shutdown() {
this.running = false;
if (!this.socket.isClosed()) {
try {
this.socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
@Override
public void run() {
this.running = true;
while (this.running) {
try {
Message msg = Serializer.readMessage(this.in);
for (var listener : this.messageListeners) {
listener.messageReceived(this, msg);
}
} catch (IOException e) {
e.printStackTrace();
this.running = false;
}
}
try {
this.socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
Terminal term = new DefaultTerminalFactory().createTerminal();
Screen screen = new TerminalScreen(term);
WindowBasedTextGUI gui = new MultiWindowTextGUI(screen);
screen.startScreen();
Window window = new MainWindow();
window.setHints(List.of(Window.Hint.FULL_SCREEN));
gui.addWindow(window);
window.waitUntilClosed();
screen.stopScreen();
}
}

View File

@ -0,0 +1,6 @@
package nl.andrewl.concord_client.gui;
import com.googlecode.lanterna.gui2.AbstractListBox;
public class ChannelList extends AbstractListBox<String, ChannelList> {
}

View File

@ -0,0 +1,31 @@
package nl.andrewl.concord_client.gui;
import com.googlecode.lanterna.gui2.AbstractListBox;
import nl.andrewl.concord_core.msg.types.Chat;
public class ChatList extends AbstractListBox<Chat, ChatList> {
/**
* Adds one more item to the list box, at the end.
*
* @param item Item to add to the list box
* @return Itself
*/
@Override
public synchronized ChatList addItem(Chat item) {
super.addItem(item);
this.setSelectedIndex(this.getItemCount() - 1);
return this;
}
/**
* Method that constructs the {@code ListItemRenderer} that this list box should use to draw the elements of the
* list box. This can be overridden to supply a custom renderer. Note that this is not the renderer used for the
* entire list box but for each item, called one by one.
*
* @return {@code ListItemRenderer} to use when drawing the items in the list
*/
@Override
protected ListItemRenderer<Chat, ChatList> createDefaultListItemRenderer() {
return new ChatRenderer();
}
}

View File

@ -0,0 +1,91 @@
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 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 java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* The main panel in which a user interacts with the application during normal
* operation. In here, the user is shown a list of the most recent chats in
* their current channel or thread, a text-box for sending a message, and some
* meta information in the sidebars which provides the user with a list of all
* threads and users in the server.
*/
public class ChatPanel extends Panel implements ClientMessageListener {
private final ChatList chatList;
private final TextBox inputTextBox;
private final ChannelList channelList;
private final UserList userList;
private final ConcordClient client;
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.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);
this.addComponent(b, BorderLayout.Location.LEFT);
b = Borders.doubleLine("Users");
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();
}
@Override
public void messageReceived(ConcordClient client, Message message) throws IOException {
if (message instanceof Chat chat) {
this.chatList.addItem(chat);
}
}
}

View File

@ -0,0 +1,33 @@
package nl.andrewl.concord_client.gui;
import com.googlecode.lanterna.TerminalTextUtils;
import com.googlecode.lanterna.graphics.ThemeDefinition;
import com.googlecode.lanterna.gui2.AbstractListBox;
import com.googlecode.lanterna.gui2.TextGUIGraphics;
import nl.andrewl.concord_core.msg.types.Chat;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
public class ChatRenderer extends AbstractListBox.ListItemRenderer<Chat, ChatList> {
@Override
public void drawItem(TextGUIGraphics graphics, ChatList listBox, int index, Chat chat, boolean selected, boolean focused) {
ThemeDefinition themeDefinition = listBox.getTheme().getDefinition(AbstractListBox.class);
if(selected && focused) {
graphics.applyThemeStyle(themeDefinition.getSelected());
}
else {
graphics.applyThemeStyle(themeDefinition.getNormal());
}
graphics.putString(0, 0, chat.getSenderNickname());
Instant timestamp = Instant.ofEpochMilli(chat.getTimestamp());
String timeStr = timestamp.atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("HH:mm:ss"));
String label = chat.getSenderNickname() + " @ " + timeStr + " : " + chat.getMessage();
label = TerminalTextUtils.fitString(label, graphics.getSize().getColumns());
while(TerminalTextUtils.getColumnWidth(label) < graphics.getSize().getColumns()) {
label += " ";
}
graphics.putString(0, 0, label);
}
}

View File

@ -0,0 +1,63 @@
package nl.andrewl.concord_client.gui;
import com.googlecode.lanterna.gui2.*;
import com.googlecode.lanterna.gui2.dialogs.TextInputDialogBuilder;
import com.googlecode.lanterna.input.KeyStroke;
import nl.andrewl.concord_client.ConcordClient;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;
public class MainWindow extends BasicWindow {
public MainWindow() {
super("Concord");
Panel panel = new Panel(new LinearLayout(Direction.VERTICAL));
Button button = new Button("Connect to Server");
button.addListener(b -> connectToServer());
panel.addComponent(button);
this.setComponent(panel);
this.addWindowListener(new WindowListenerAdapter() {
@Override
public void onInput(Window basePane, KeyStroke keyStroke, AtomicBoolean hasBeenHandled) {
if (keyStroke.getCharacter() != null && keyStroke.getCharacter() == 'c' && keyStroke.isCtrlDown()) {
System.exit(0);
}
}
});
}
public void connectToServer() {
System.out.println("Connecting to server!");
var addressDialog = new TextInputDialogBuilder()
.setTitle("Server Address")
.setDescription("Enter the address of the server to connect to. For example, \"localhost:1234\".")
.setInitialContent("localhost:8123")
.build();
String address = addressDialog.showDialog(this.getTextGUI());
if (address == null) return;
String[] parts = address.split(":");
if (parts.length != 2) return;
String host = parts[0];
int port = Integer.parseInt(parts[1]);
var nameDialog = new TextInputDialogBuilder()
.setTitle("Nickname")
.setDescription("Enter a nickname to use while in the server.")
.build();
String nickname = nameDialog.showDialog(this.getTextGUI());
if (nickname == null) return;
try {
var client = new ConcordClient(host, port, nickname);
var chatPanel = new ChatPanel(client, this);
client.addListener(chatPanel);
new Thread(client).start();
this.setComponent(chatPanel);
Borders.joinLinesWithFrame(this.getTextGUI().getScreen().newTextGraphics());
} catch (IOException e) {
e.printStackTrace();
}
}
}

View File

@ -0,0 +1,6 @@
package nl.andrewl.concord_client.gui;
import com.googlecode.lanterna.gui2.AbstractListBox;
public class UserList extends AbstractListBox<String, UserList> {
}

1
core/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

13
core/pom.xml Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>concord</artifactId>
<groupId>nl.andrewl</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>concord-core</artifactId>
</project>

View File

@ -0,0 +1,6 @@
module concord_core {
requires static lombok;
exports nl.andrewl.concord_core.msg to concord_server, concord_client;
exports nl.andrewl.concord_core.msg.types to concord_server, concord_client;
}

View File

@ -0,0 +1,85 @@
package nl.andrewl.concord_core.msg;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* Represents any message which can be sent over the network.
* <p>
* All messages consist of a single byte type identifier, followed by a
* payload whose structure depends on the message.
* </p>
*/
public interface Message {
/**
* @return The exact number of bytes that this message will use when written
* to a stream.
*/
int getByteCount();
/**
* Writes this message to the given output stream.
* @param o The output stream to write to.
* @throws IOException If an error occurs while writing.
*/
void write(DataOutputStream o) throws IOException;
/**
* Reads all of this message's properties from the given input stream.
* <p>
* The single byte type identifier has already been read.
* </p>
* @param i The input stream to read from.
* @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);
}
}

View File

@ -0,0 +1,55 @@
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 java.io.*;
import java.util.HashMap;
import java.util.Map;
/**
* This class is responsible for reading and writing messages from streams.
*/
public class Serializer {
private static final Map<Byte, Class<? extends Message>> messageTypes = new HashMap<>();
private static final Map<Class<? extends Message>, Byte> inverseMessageTypes = new HashMap<>();
static {
registerType(0, Identification.class);
registerType(1, ServerWelcome.class);
registerType(2, Chat.class);
}
private static void registerType(int id, Class<? extends Message> clazz) {
messageTypes.put((byte) id, clazz);
inverseMessageTypes.put(clazz, (byte) id);
}
public static Message readMessage(InputStream i) throws IOException {
DataInputStream d = new DataInputStream(i);
byte type = d.readByte();
var clazz = messageTypes.get(type);
if (clazz == null) {
throw new IOException("Unsupported message type: " + type);
}
try {
var constructor = clazz.getConstructor();
var message = constructor.newInstance();
message.read(d);
return message;
} catch (Throwable e) {
throw new IOException("Could not instantiate new message object of type " + clazz.getSimpleName(), e);
}
}
public static void writeMessage(Message msg, OutputStream o) throws IOException {
DataOutputStream d = new DataOutputStream(o);
Byte type = inverseMessageTypes.get(msg.getClass());
if (type == null) {
throw new IOException("Unsupported message type: " + msg.getClass().getSimpleName());
}
d.writeByte(type);
msg.write(d);
d.flush();
}
}

View File

@ -0,0 +1,58 @@
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;
/**
* This message contains information about a chat message that a user sent.
*/
@Data
@NoArgsConstructor
public class Chat implements Message {
private long senderId;
private String senderNickname;
private long timestamp;
private String message;
public Chat(long senderId, String senderNickname, long timestamp, String message) {
this.senderId = senderId;
this.senderNickname = senderNickname;
this.timestamp = timestamp;
this.message = message;
}
public Chat(String message) {
this(-1, null, System.currentTimeMillis(), message);
}
@Override
public int getByteCount() {
return 2 * Long.BYTES + getByteSize(this.message) + getByteSize(this.senderNickname);
}
@Override
public void write(DataOutputStream o) throws IOException {
o.writeLong(this.senderId);
writeString(this.senderNickname, o);
o.writeLong(this.timestamp);
writeString(this.message, o);
}
@Override
public void read(DataInputStream i) throws IOException {
this.senderId = i.readLong();
this.senderNickname = readString(i);
this.timestamp = i.readLong();
this.message = readString(i);
}
@Override
public String toString() {
return String.format("%s(%d): %s", this.senderNickname, this.senderId, this.message);
}
}

View File

@ -0,0 +1,38 @@
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;
/**
* This message is sent from the client to a server, to provide identification
* information about the client to the server when the connection is started.
*/
@Data
@NoArgsConstructor
public class Identification implements Message {
private String nickname;
public Identification(String nickname) {
this.nickname = nickname;
}
@Override
public int getByteCount() {
return getByteSize(this.nickname);
}
@Override
public void write(DataOutputStream o) throws IOException {
writeString(this.nickname, o);
}
@Override
public void read(DataInputStream i) throws IOException {
this.nickname = readString(i);
}
}

View File

@ -0,0 +1,32 @@
package nl.andrewl.concord_core.msg.types;
import lombok.Data;
import nl.andrewl.concord_core.msg.Message;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
/**
* 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
public class ServerWelcome implements Message {
private long clientId;
@Override
public int getByteCount() {
return Long.BYTES;
}
@Override
public void write(DataOutputStream o) throws IOException {
o.writeLong(this.clientId);
}
@Override
public void read(DataInputStream i) throws IOException {
this.clientId = i.readLong();
}
}

View File

@ -0,0 +1,5 @@
/**
* This package contains all the components needed by both the server and the
* client.
*/
package nl.andrewl.concord_core;

51
pom.xml Normal file
View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>nl.andrewl</groupId>
<artifactId>concord</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<modules>
<module>server</module>
<module>core</module>
<module>client</module>
</modules>
<properties>
<maven.compiler.source>16</maven.compiler.source>
<maven.compiler.target>16</maven.compiler.target>
<java.version>16</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>

1
server/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

47
server/pom.xml Normal file
View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>concord</artifactId>
<groupId>nl.andrewl</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>concord-server</artifactId>
<dependencies>
<dependency>
<groupId>nl.andrewl</groupId>
<artifactId>concord-core</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>org.dizitart</groupId>
<artifactId>nitrite</artifactId>
<version>3.4.3</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,9 @@
module concord_server {
requires nitrite;
requires static lombok;
requires java.base;
requires java.logging;
requires concord_core;
}

View File

@ -0,0 +1,99 @@
package nl.andrewl.concord_server;
import lombok.Getter;
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.Identification;
import nl.andrewl.concord_core.msg.types.ServerWelcome;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
/**
* This thread is responsible for handling the connection to a single client of
* a server.
*/
@Log
public class ClientThread extends Thread {
private final Socket socket;
private final DataInputStream in;
private final DataOutputStream out;
private final ConcordServer server;
private Long clientId = null;
@Getter
private String clientNickname = null;
public ClientThread(Socket socket, ConcordServer server) throws IOException {
this.socket = socket;
this.server = server;
this.in = new DataInputStream(socket.getInputStream());
this.out = new DataOutputStream(socket.getOutputStream());
}
public void sendToClient(Message message) {
try {
Serializer.writeMessage(message, this.out);
} catch (IOException e) {
e.printStackTrace();
}
}
public void sendToClient(byte[] bytes) {
try {
this.out.write(bytes);
this.out.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void run() {
if (!identifyClient()) {
log.warning("Could not identify the client; aborting connection.");
return;
}
while (true) {
try {
var msg = Serializer.readMessage(this.in);
if (msg instanceof Chat chat) {
this.server.handleChat(chat);
}
} catch (IOException e) {
log.info("Client disconnected: " + e.getMessage());
if (this.clientId != null) {
this.server.deregisterClient(this.clientId);
}
break;
}
}
}
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);
return true;
}
} catch (IOException e) {
e.printStackTrace();
}
attempts++;
}
return false;
}
}

View File

@ -0,0 +1,75 @@
package nl.andrewl.concord_server;
import lombok.extern.java.Log;
import nl.andrewl.concord_core.msg.Serializer;
import nl.andrewl.concord_core.msg.types.Chat;
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.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
@Log
public class ConcordServer implements Runnable {
private final Map<Long, ClientThread> clients = new ConcurrentHashMap<>(32);
private final int port;
private final Random random;
public ConcordServer(int port) {
this.port = port;
this.random = new SecureRandom();
}
public long registerClient(ClientThread clientThread) {
long id = this.random.nextLong();
log.info("Registering new client " + clientThread.getClientNickname() + " with id " + id);
this.clients.put(id, clientThread);
return id;
}
public void deregisterClient(long clientId) {
this.clients.remove(clientId);
}
public void handleChat(Chat chat) {
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;
}
byte[] data = baos.toByteArray();
for (var client : clients.values()) {
client.sendToClient(data);
}
}
@Override
public void run() {
ServerSocket serverSocket;
try {
serverSocket = new ServerSocket(this.port);
log.info("Opened server on port " + this.port);
while (true) {
Socket socket = serverSocket.accept();
log.info("Accepted new socket connection.");
ClientThread clientThread = new ClientThread(socket, this);
clientThread.start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
var server = new ConcordServer(8123);
server.run();
}
}

View File

@ -0,0 +1,12 @@
<?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>