Added initial prototype.
This commit is contained in:
parent
ae50a15c00
commit
468758c7ab
|
@ -0,0 +1,2 @@
|
||||||
|
/.idea
|
||||||
|
/target
|
11
README.md
11
README.md
|
@ -1,2 +1,11 @@
|
||||||
# Concord
|
# 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.
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
/target
|
|
@ -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>
|
|
@ -0,0 +1,4 @@
|
||||||
|
module concord_client {
|
||||||
|
requires concord_core;
|
||||||
|
requires com.googlecode.lanterna;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
package nl.andrewl.concord_client.gui;
|
||||||
|
|
||||||
|
import com.googlecode.lanterna.gui2.AbstractListBox;
|
||||||
|
|
||||||
|
public class ChannelList extends AbstractListBox<String, ChannelList> {
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
package nl.andrewl.concord_client.gui;
|
||||||
|
|
||||||
|
import com.googlecode.lanterna.gui2.AbstractListBox;
|
||||||
|
|
||||||
|
public class UserList extends AbstractListBox<String, UserList> {
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
/target
|
|
@ -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>
|
|
@ -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;
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
/**
|
||||||
|
* This package contains all the components needed by both the server and the
|
||||||
|
* client.
|
||||||
|
*/
|
||||||
|
package nl.andrewl.concord_core;
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
||||||
|
/target
|
|
@ -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>
|
|
@ -0,0 +1,9 @@
|
||||||
|
module concord_server {
|
||||||
|
requires nitrite;
|
||||||
|
requires static lombok;
|
||||||
|
|
||||||
|
requires java.base;
|
||||||
|
requires java.logging;
|
||||||
|
|
||||||
|
requires concord_core;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
Loading…
Reference in New Issue