initial commit

main
jbb01 1 year ago
commit eadf1eaf5b

42
.gitignore vendored

@ -0,0 +1,42 @@
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store

@ -0,0 +1,43 @@
plugins {
id("java-library")
id("chat-bot.java-conventions")
}
group = "eu.jonahbauer.chat"
version = "0.1.0-SNAPSHOT"
sourceSets {
create("config") {
java {
srcDir("src/config/java")
}
}
}
java {
registerFeature("config") {
usingSourceSet(sourceSets["config"])
}
}
val configApi by configurations
val configCompileOnly by configurations
val configAnnotationProcessor by configurations
dependencies {
api(libs.annotations)
api(project(path)) {
capabilities {
requireCapability("${project.group}:${project.name}-config")
}
}
implementation(libs.jackson.annotations)
configApi(libs.annotations)
configCompileOnly(libs.lombok)
configAnnotationProcessor(libs.lombok)
}
tasks.withType<JavaCompile> {
options.compilerArgs.add("-Xlint:-module")
}

@ -0,0 +1,464 @@
package eu.jonahbauer.chat.bot.config;
import lombok.AccessLevel;
import lombok.EqualsAndHashCode;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.*;
@SuppressWarnings("unused")
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public final class BotConfig {
public static final Key.OfString NAME = Key.ofString("name");
public static final Key.OfBoolean BOT_TAG = Key.ofBoolean("bottag");
public static final Key.OfBoolean IGNORE_BOTS = Key.ofBoolean("ignore_bots");
public static final Key.OfBoolean PUBLIC_ID = Key.ofBoolean("public_id");
public static final Key.OfStringArray CHANNELS = Key.ofStringArray("channels");
public static final Key.OfString TYPE = Key.ofString("type");
public static final BotConfig EMPTY = new BotConfig(Collections.emptyMap());
@EqualsAndHashCode.Include
private final @Unmodifiable @NotNull Map<@NotNull String, @NotNull Value> data;
public static @NotNull Builder builder() {
return new Builder();
}
/**
* {@return a new builder initialized with this config's data}
*/
public @NotNull Builder mutate() {
return new Builder(this.data);
}
@Override
public @NotNull String toString() {
return "BotConfig" + data;
}
/**
* Merges the other config into this one, returning a new config container properties of both this and the provided
* config. Properties present in both this and the provided config will contain the value of the provided config.
* @param config another config
* @return a merged config
*/
public @NotNull BotConfig merge(@NotNull BotConfig config) {
var data = new HashMap<>(this.data);
data.putAll(config.data);
return new BotConfig(Map.copyOf(data));
}
/**
* {@return the set of all keys to this config}
*/
public @NotNull Set<@NotNull String> keySet() {
return data.keySet();
}
private transient @Nullable Set<@NotNull Entry<?>> entrySet;
public @NotNull Set<@NotNull Entry<?>> entrySet() {
if (entrySet == null) {
var entries = new HashSet<Entry<?>>();
data.forEach((key, v) -> entries.add(switch (v) {
case Value.OfLong(long value) -> Entry.ofLong(key, value);
case Value.OfDouble(double value) -> Entry.ofDouble(key, value);
case Value.OfString(String value) -> Entry.ofString(key, value);
case Value.OfBoolean(boolean value) -> Entry.ofBoolean(key, value);
case Value.OfStringArray(List<String> value) -> Entry.ofStringArray(key, value);
}));
entrySet = Collections.unmodifiableSet(entries);
}
return entrySet;
}
/*
* getters for default keys
*/
public @NotNull String getType() {
return get(TYPE).orElseThrow(() -> new BotConfigurationException("missing property: " + TYPE));
}
public @NotNull String getName() {
return get(NAME).orElseThrow(() -> new BotConfigurationException("missing property: " + NAME));
}
public @NotNull List<@NotNull String> getChannels() {
return get(CHANNELS).orElseThrow(() -> new BotConfigurationException("missing property: " + CHANNELS));
}
public boolean isBotTag() {
return get(BOT_TAG).orElse(true);
}
public boolean isIgnoreBots() {
return get(IGNORE_BOTS).orElse(true);
}
public boolean isPublicId() {
return get(PUBLIC_ID).orElse(true);
}
/*
* generic getters
*/
/**
* {@return whether this config contains the given key}
*/
public boolean has(@NotNull String key) {
return data.containsKey(key);
}
/**
* {@return whether this config contains the given key and the value associated with the key matches the key type}
*/
public boolean has(@NotNull Key<?, ?> key) {
return switch (key) {
case Key.OfBoolean(var k) -> getBoolean(k).isPresent();
case Key.OfLong(var k) -> getLong(k).isPresent();
case Key.OfDouble(var k) -> getDouble(k).isPresent();
case Key.OfStringArray(var k) -> getStringArray(k).isPresent();
case Key.OfString(var k) -> getString(k).isPresent();
};
}
/**
* {@return the value associated with the given key}
* @throws BotConfigurationException if no value is present for the given key or the value does not match the key
* type
*/
@SuppressWarnings("unchecked")
public <T> @NotNull T require(@NotNull Key<T, ?> key) {
try {
return (T) switch (key) {
case Key.OfBoolean(var k) -> getBoolean(k).orElseThrow();
case Key.OfLong(var k) -> getLong(k).orElseThrow();
case Key.OfDouble(var k) -> getDouble(k).orElseThrow();
case Key.OfStringArray(var k) -> getStringArray(k).orElseThrow();
case Key.OfString(var k) -> getString(k).orElseThrow();
};
} catch (NoSuchElementException ex) {
throw new BotConfigurationException("Missing property value:" + key.name(), ex);
}
}
/**
* {@return the value associated with the given key} Returns an empty optional if no value is present for the given
* key or the value does not match the key type.
*/
@SuppressWarnings("unchecked")
public <T> @NotNull T get(@NotNull Key<?, T> key) {
return (T) switch (key) {
case Key.OfBoolean(var k) -> getBoolean(k);
case Key.OfLong(var k) -> getLong(k);
case Key.OfDouble(var k) -> getDouble(k);
case Key.OfStringArray(var k) -> getStringArray(k);
case Key.OfString(var k) -> getString(k);
};
}
/**
* {@return the value associated with the given key} If not empty, then the returned {@link Optional} will
* contain an object of type {@link String}, {@link Long}, {@link Double}, {@link Boolean} or {@link List List&lt;String&gt;}.
*/
public @NotNull Optional<?> get(@NotNull String key) {
var value = data.get(key);
return value == null ? Optional.empty() : Optional.of(value.asObject());
}
/**
* {@return the value associated with the given key} Returns an {@linkplain Optional#empty() empty optional} if no
* value is present for the given key or the value is not of type {@link String}.
*/
public @NotNull Optional<String> getString(@NotNull String key) {
var value = data.get(key);
return value == null ? Optional.empty() : Optional.of(value.asString(key));
}
/**
* {@return the value associated with the given key} Returns an {@linkplain OptionalLong#empty() empty optional} if
* no value is present for the given key or the value is not of type {@link Long}.
*/
public @NotNull OptionalLong getLong(@NotNull String key) {
var value = data.get(key);
return value == null ? OptionalLong.empty() : OptionalLong.of(value.asLong(key));
}
/**
* {@return the value associated with the given key} Returns an {@linkplain OptionalDouble#empty() empty optional}
* if no value is present for the given key or the value is not of type {@link Double}.
*/
public @NotNull OptionalDouble getDouble(@NotNull String key) {
var value = data.get(key);
return value == null ? OptionalDouble.empty() : OptionalDouble.of(value.asDouble(key));
}
/**
* {@return the value associated with the given key} Returns an {@linkplain Optional#empty() empty optional} if no
* value is present for the given key or the value is not of type {@link Boolean}.
*/
public @NotNull Optional<Boolean> getBoolean(@NotNull String key) {
var value = data.get(key);
return value == null ? Optional.empty() : Optional.of(value.asBoolean(key));
}
/**
* {@return the value associated with the given key} Returns an {@linkplain Optional#empty() empty optional} if no
* value is present for the given key or the value is not of type {@link List List&lt;String&gt;}.
*/
public @NotNull Optional<List<String>> getStringArray(@NotNull String key) {
var value = data.get(key);
return value == null ? Optional.empty() : Optional.of(value.asArray(key));
}
private static BotConfigurationException wrongType(@NotNull String key, @NotNull String expected, @NotNull String actual) {
return new BotConfigurationException("Wrong property type for " + key + " (expected " + expected + ", got " + actual + ")");
}
public static class Builder {
private final Map<String, Value> data;
private Builder() {
this(Collections.emptyMap());
}
private Builder(Map<String, Value> data) {
this.data = new HashMap<>(data);
}
public Builder type(@NotNull String type) {
return value(TYPE, type);
}
public Builder name(@NotNull String name) {
return value(NAME, name);
}
public Builder channels(@NotNull String @NotNull... channels) {
return value(CHANNELS, List.of(channels));
}
public Builder channels(@NotNull List<@NotNull String> channels) {
return value(CHANNELS, List.copyOf(channels));
}
public Builder noPublicId() {
return value(PUBLIC_ID, false);
}
public Builder noBotTag() {
return value(BOT_TAG, false);
}
public Builder noIgnoreBots() {
return value(IGNORE_BOTS, false);
}
public Builder value(@NotNull String key, long value) {
data.put(key, new Value.OfLong(value));
return this;
}
public Builder value(@NotNull String key, double value) {
data.put(key, new Value.OfDouble(value));
return this;
}
public Builder value(@NotNull String key, @NotNull String value) {
data.put(key, new Value.OfString(value));
return this;
}
public Builder value(@NotNull String key, boolean value) {
data.put(key, new Value.OfBoolean(value));
return this;
}
public Builder value(@NotNull String key, @NotNull List<@NotNull String> value) {
data.put(key, new Value.OfStringArray(value));
return this;
}
public <S> Builder value(@NotNull Key<S, ?> key, S value) {
data.put(key.name(), Value.forKey(key, value));
return this;
}
public BotConfig build() {
return new BotConfig(Map.copyOf(data));
}
}
/**
* A typed key to a {@link BotConfig}.
* @param <S> the raw value type
* @param <T> the optional value type
*/
public sealed interface Key<S, T> {
@NotNull String name();
static @NotNull Key.OfLong ofLong(@NotNull String key) {
return new OfLong(key);
}
static @NotNull Key.OfDouble ofDouble(@NotNull String key) {
return new OfDouble(key);
}
static @NotNull Key.OfString ofString(@NotNull String key) {
return new OfString(key);
}
static @NotNull Key.OfBoolean ofBoolean(@NotNull String key) {
return new OfBoolean(key);
}
static @NotNull Key.OfStringArray ofStringArray(@NotNull String key) {
return new OfStringArray(key);
}
record OfLong(@NotNull String name) implements Key<Long, OptionalLong> {}
record OfDouble(@NotNull String name) implements Key<Double, OptionalDouble> {}
record OfString(@NotNull String name) implements Key<String, Optional<String>> {}
record OfBoolean(@NotNull String name) implements Key<Boolean, Optional<Boolean>> {}
record OfStringArray(@NotNull String name) implements Key<List<String>, Optional<List<String>>> {}
}
public sealed interface Entry<T> extends Map.Entry<String, T> {
@NotNull String key();
@NotNull T value();
@Override
default @NotNull String getKey() {
return key();
}
@Override
default @NotNull T getValue() {
return value();
}
@Override
default T setValue(T value) {
throw new UnsupportedOperationException();
}
static @NotNull Entry.OfLong ofLong(@NotNull String key, long value) {
return new Entry.OfLong(key, value);
}
static @NotNull Entry.OfDouble ofDouble(@NotNull String key, double value) {
return new Entry.OfDouble(key, value);
}
static @NotNull Entry.OfString ofString(@NotNull String key, @NotNull String value) {
return new Entry.OfString(key, value);
}
static @NotNull Entry.OfBoolean ofBoolean(@NotNull String key, boolean value) {
return new Entry.OfBoolean(key, value);
}
static @NotNull Entry.OfStringArray ofStringArray(@NotNull String key, @NotNull List<@NotNull String> value) {
return new Entry.OfStringArray(key, value);
}
record OfLong(@NotNull String key, long valueAsLong) implements Entry<Long> {
@Override
public @NotNull Long value() {
return valueAsLong;
}
}
record OfDouble(@NotNull String key, double valueAsDouble) implements Entry<Double> {
@Override
public @NotNull Double value() {
return valueAsDouble;
}
}
record OfString(@NotNull String key, @NotNull String value) implements Entry<String> {}
record OfBoolean(@NotNull String key, boolean valueAsBoolean) implements Entry<Boolean> {
@Override
public @NotNull Boolean value() {
return valueAsBoolean;
}
}
record OfStringArray(@NotNull String key, @NotNull List<@NotNull String> value) implements Entry<List<String>> {}
}
private sealed interface Value {
String name();
default long asLong(@NotNull String key) { throw wrongType(key, "integer", name()); }
default double asDouble(@NotNull String key) { throw wrongType(key, "float", name()); }
default String asString(@NotNull String key) { throw wrongType(key, "string", name()); }
default boolean asBoolean(@NotNull String key) { throw wrongType(key, "boolean", name()); }
default List<String> asArray(@NotNull String key) { throw wrongType(key, "array", name()); }
Object asObject();
@SuppressWarnings("unchecked")
static <S> Value forKey(Key<S,?> key, S value) {
return switch (key) {
case Key.OfString _ -> new Value.OfString((String) value);
case Key.OfDouble _ -> new Value.OfDouble((double) value);
case Key.OfLong _ -> new Value.OfLong((long) value);
case Key.OfBoolean _ -> new Value.OfBoolean((boolean) value);
case Key.OfStringArray _ -> new Value.OfStringArray((List<String>) value);
};
}
record OfLong(long value) implements Value {
public String name() { return "integer"; }
@Override
public long asLong(@NotNull String key) { return value; }
@Override
public Object asObject() { return value; }
}
record OfDouble(double value) implements Value {
public String name() { return "float"; }
@Override
public double asDouble(@NotNull String key) { return value; }
@Override
public Object asObject() { return value; }
}
record OfString(@NotNull String value) implements Value {
public OfString { Objects.requireNonNull(value); }
public String name() { return "string"; }
@Override
public String asString(@NotNull String key) { return value; }
@Override
public Object asObject() { return value; }
}
record OfBoolean(boolean value) implements Value {
public String name() { return "boolean"; }
@Override
public boolean asBoolean(@NotNull String key) { return value; }
@Override
public Object asObject() { return value; }
}
record OfStringArray(@NotNull List<@NotNull String> value) implements Value {
public OfStringArray { value = List.copyOf(value); }
public String name() { return "array"; }
@Override
public List<String> asArray(@NotNull String key) { return value; }
@Override
public Object asObject() { return value; }
}
}
}

@ -0,0 +1,7 @@
package eu.jonahbauer.chat.bot.config;
import lombok.experimental.StandardException;
@StandardException
public class BotConfigurationException extends RuntimeException {
}

@ -0,0 +1,6 @@
module eu.jonahbauer.chat.bot.config {
exports eu.jonahbauer.chat.bot.config;
requires static lombok;
requires static transitive org.jetbrains.annotations;
}

@ -0,0 +1,7 @@
package eu.jonahbauer.chat.bot.api;
import org.jetbrains.annotations.NotNull;
public interface Chat {
boolean send(@NotNull String channel, @NotNull String name, @NotNull String message, boolean bottag, boolean publicId);
}

@ -0,0 +1,61 @@
package eu.jonahbauer.chat.bot.api;
import eu.jonahbauer.chat.bot.config.BotConfig;
import eu.jonahbauer.chat.bot.impl.ChatBotFactory;
import lombok.Getter;
import lombok.experimental.Accessors;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
@Getter
@Accessors(makeFinal = true)
public abstract class ChatBot {
private static final ScopedValue<String> CHANNEL = ScopedValue.newInstance();
private static final ScopedValue<Chat> CHAT = ScopedValue.newInstance();
protected final @NotNull BotConfig config;
protected ChatBot(@NotNull String name) {
this(BotConfig.builder().name(name).build());
}
protected ChatBot(@NotNull BotConfig defaultConfig) {
var config = ChatBotFactory.BOT_CONFIG
.orElseThrow(() -> new IllegalCallerException("ChatBot may only be instantiated via ChatBotFactory."));
this.config = defaultConfig.merge(config);
}
public final void onMessage(@NotNull Chat chat, @NotNull Message.Post message) {
ScopedValue
.where(CHANNEL, message.channel())
.where(CHAT, chat)
.run(() -> onMessage(message));
}
@ApiStatus.OverrideOnly
protected abstract void onMessage(@NotNull Message.Post message);
/**
* Called during the normal shutdown process for a bot.
*/
@ApiStatus.OverrideOnly
public void onStop() {}
protected void post(@NotNull String message) {
post(message, null, null, null);
}
protected void post(@NotNull String message, @Nullable String name, @Nullable Boolean bottag, @Nullable Boolean publicId) {
Objects.requireNonNull(message, "message");
name = Objects.requireNonNullElse(name, this.config.getName());
bottag = Objects.requireNonNullElse(bottag, this.config.isBotTag());
publicId = Objects.requireNonNullElse(publicId, this.config.isPublicId());
var chat = CHAT.orElseThrow(() -> new IllegalStateException("post() may only be called from inside onMessage(Post)"));
var channel = CHANNEL.orElseThrow(() -> new IllegalStateException("post() may only be called from inside onMessage(Post)"));
chat.send(channel, name, message, bottag, publicId);
}
}

@ -0,0 +1,51 @@
package eu.jonahbauer.chat.bot.api;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.time.LocalDateTime;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = Message.Ping.class, name = "ping"),
@JsonSubTypes.Type(value = Message.Pong.class, name = "pong"),
@JsonSubTypes.Type(value = Message.Ack.class, name = "ack"),
@JsonSubTypes.Type(value = Message.Post.class, name = "post")
})
public sealed interface Message {
Ping PING = new Ping();
record Ping() implements Message { }
record Pong() implements Message { }
record Ack() implements Message { }
record Post(
long id,
@NotNull String name,
@NotNull String message,
@NotNull String channel,
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
@NotNull LocalDateTime date,
@Nullable Long delay,
@JsonProperty("user_id")
@Nullable Long userId,
@JsonProperty("username")
@Nullable String userName,
@NotNull String color,
int bottag
) implements Message {
public boolean bot() {
return bottag != 0;
}
}
}

@ -0,0 +1,7 @@
package eu.jonahbauer.chat.bot.impl;
import lombok.experimental.StandardException;
@StandardException
public class BotCreationException extends RuntimeException {
}

@ -0,0 +1,57 @@
package eu.jonahbauer.chat.bot.impl;
import eu.jonahbauer.chat.bot.api.ChatBot;
import eu.jonahbauer.chat.bot.config.BotConfig;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.TestOnly;
import java.util.Objects;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Collectors;
public final class ChatBotFactory<T extends ChatBot> {
public static final ScopedValue<BotConfig> BOT_CONFIG = ScopedValue.newInstance();
private static final ServiceLoader<ChatBot> SERVICE_LOADER = ServiceLoader.load(ChatBot.class);
private final @NotNull Class<T> type;
private final @NotNull Supplier<T> delegate;
public static @NotNull Set<@NotNull Class<? extends ChatBot>> implementations() {
return SERVICE_LOADER.stream().map(ServiceLoader.Provider::type).collect(Collectors.toUnmodifiableSet());
}
@SuppressWarnings("unchecked")
public ChatBotFactory(@NotNull Class<T> type) {
if (!ChatBot.class.isAssignableFrom(type)) throw new IllegalArgumentException();
this.type = type;
this.delegate = (Supplier<T>) SERVICE_LOADER.stream()
.filter(p -> p.type().equals(type)).findFirst()
.orElseThrow(() -> new BotCreationException("No suitable provider found: " + type.getName()));
}
@TestOnly
ChatBotFactory(@NotNull Class<T> type, @NotNull Supplier<T> supplier) {
if (!ChatBot.class.isAssignableFrom(type)) throw new IllegalArgumentException();
this.type = type;
this.delegate = Objects.requireNonNull(supplier, "supplier");
}
public @NotNull Class<T> type() {
return type;
}
public @NotNull T create() {
return create(BotConfig.EMPTY);
}
public @NotNull T create(@NotNull BotConfig config) {
try {
return ScopedValue.where(BOT_CONFIG, config).get(delegate);
} catch (Throwable t) {
throw new BotCreationException("Exception in constructor: " + type.getName(), t);
}
}
}

@ -0,0 +1,14 @@
import eu.jonahbauer.chat.bot.api.ChatBot;
module eu.jonahbauer.chat.bot.api {
exports eu.jonahbauer.chat.bot.api;
exports eu.jonahbauer.chat.bot.impl to eu.jonahbauer.chat.server;
requires transitive eu.jonahbauer.chat.bot.config;
requires static transitive org.jetbrains.annotations;
requires static com.fasterxml.jackson.annotation;
requires static lombok;
uses ChatBot;
}

@ -0,0 +1,53 @@
package eu.jonahbauer.chat.bot.api;
import eu.jonahbauer.chat.bot.config.BotConfig;
import eu.jonahbauer.chat.bot.impl.ChatBotFactory;
import eu.jonahbauer.chat.bot.impl.ChatBotFactoryAccess;
import eu.jonahbauer.chat.bot.test.MockChat;
import eu.jonahbauer.chat.bot.test.TestChatBot;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class ChatBotTest {
private static ChatBotFactory<TestChatBot> factory;
@BeforeAll
static void init() {
factory = ChatBotFactoryAccess.newInstance(TestChatBot.class, TestChatBot::new);
}
@Test
void postUsesDefaultValuesFromBotConfig() {
var chat = new MockChat();
var post = new Message.Post(0, "Test", "Hello World!", "", LocalDateTime.MIN, 0L, null, null, "ffffff", 0);
var post2 = new Message.Post(0, "Test", "Hello World!", "test", LocalDateTime.MIN, 0L, null, null, "ffffff", 0);
var bot = factory.create(BotConfig.EMPTY);
bot.setMessage("Hello, TestChatBot#1");
bot.onMessage(chat, post);
bot.onMessage(chat, post2);
var bot2 = factory.create(BotConfig.builder().name("TestChatBot#2").noBotTag().noPublicId().build());
bot2.setMessage("Hello, TestChatBot#2");
bot2.onMessage(chat, post);
bot2.onMessage(chat, post2);
var messages = chat.getMessages();
assertEquals(4, messages.size());
assertEquals(new MockChat.Message("", "TestBot", "Hello, TestChatBot#1", true, true), messages.get(0));
assertEquals(new MockChat.Message("test", "TestBot", "Hello, TestChatBot#1", true, true), messages.get(1));
assertEquals(new MockChat.Message("", "TestChatBot#2", "Hello, TestChatBot#2", false, false), messages.get(2));
assertEquals(new MockChat.Message("test", "TestChatBot#2", "Hello, TestChatBot#2", false, false), messages.get(3));
}
@Test
void postMayNotBeInvokedOutsideOfOnMessage() {
var bot = factory.create(BotConfig.EMPTY);
assertThrows(IllegalStateException.class, () -> bot.post("Hello, World!"));
}
}

@ -0,0 +1,116 @@
package eu.jonahbauer.chat.bot.config;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import java.util.OptionalDouble;
import java.util.OptionalLong;
import static org.junit.jupiter.api.Assertions.*;
public class BotConfigTest {
@Test
void buildAndAccessWithStringKeys() {
BotConfig config = BotConfig.builder()
.value("string", "value")
.value("long", Long.MAX_VALUE)
.value("double", Double.MAX_VALUE)
.value("boolean", true)
.value("array", List.of("foo", "bar"))
.build();
assertEquals(Optional.of("value"), config.getString("string"));
assertEquals(OptionalLong.of(Long.MAX_VALUE), config.getLong("long"));
assertEquals(OptionalDouble.of(Double.MAX_VALUE), config.getDouble("double"));
assertEquals(Optional.of(true), config.getBoolean("boolean"));
assertEquals(Optional.of(List.of("foo", "bar")), config.getStringArray("array"));
assertThrows(BotConfigurationException.class, () -> config.getLong("string"));
assertThrows(BotConfigurationException.class, () -> config.getDouble("string"));
assertThrows(BotConfigurationException.class, () -> config.getBoolean("string"));
assertThrows(BotConfigurationException.class, () -> config.getStringArray("string"));
}
@Test
void accessUninitializedProperty() {
var unset = BotConfig.Key.ofString("unset");
var config = BotConfig.EMPTY;
assertEquals(Optional.empty(), config.get(unset));
assertEquals(Optional.empty(), config.getString("unset"));
assertEquals(OptionalLong.empty(), config.getLong("unset"));
assertEquals(OptionalDouble.empty(), config.getDouble("unset"));
assertEquals(Optional.empty(), config.getBoolean("unset"));
assertEquals(Optional.empty(), config.getStringArray("unset"));
assertThrows(BotConfigurationException.class, () -> config.require(unset));
}
@Test
void buildAndAccessWithTypedKeys() {
var string = BotConfig.Key.ofString("string");
var long_ = BotConfig.Key.ofLong("long");
var double_ = BotConfig.Key.ofDouble("double");
var boolean_ = BotConfig.Key.ofBoolean("boolean");
var array = BotConfig.Key.ofStringArray("array");
var config = BotConfig.builder()
.value(string, "value")
.value(long_, Long.MAX_VALUE)
.value(double_, Double.MAX_VALUE)
.value(boolean_, true)
.value(array, List.of("foo", "bar"))
.build();
assertEquals(Optional.of("value"), config.getString("string"));
assertEquals(OptionalLong.of(Long.MAX_VALUE), config.getLong("long"));
assertEquals(OptionalDouble.of(Double.MAX_VALUE), config.getDouble("double"));
assertEquals(Optional.of(true), config.getBoolean("boolean"));
assertEquals(Optional.of(List.of("foo", "bar")), config.getStringArray("array"));
assertEquals(Optional.of("value"), config.get(string));
assertEquals(OptionalLong.of(Long.MAX_VALUE), config.get(long_));
assertEquals(OptionalDouble.of(Double.MAX_VALUE), config.get(double_));
assertEquals(Optional.of(true), config.get(boolean_));
assertEquals(Optional.of(List.of("foo", "bar")), config.get(array));
assertEquals("value", config.require(string));
assertEquals(Long.MAX_VALUE, config.require(long_));
assertEquals(Double.MAX_VALUE, config.require(double_));
assertEquals(true, config.require(boolean_));
assertEquals(List.of("foo", "bar"), config.require(array));
}
@Test
void merge() {
var a = BotConfig.builder()
.value("a", "a")
.value("c", "a")
.build();
var b = BotConfig.builder()
.value("b", "b")
.value("c", "b")
.build();
var c = a.merge(b);
// a remains unchanged
assertEquals(Optional.of("a"), a.getString("a"));
assertEquals(Optional.of("a"), a.getString("c"));
assertFalse(a.has("b"));
// b remains unchanged
assertEquals(Optional.of("b"), b.getString("b"));
assertEquals(Optional.of("b"), b.getString("c"));
assertFalse(b.has("a"));
// c contains a's and b's values
assertEquals(Optional.of("a"), c.getString("a"));
assertEquals(Optional.of("b"), c.getString("b"));
assertEquals(Optional.of("b"), c.getString("c"));
}
}

@ -0,0 +1,14 @@
package eu.jonahbauer.chat.bot.impl;
import eu.jonahbauer.chat.bot.api.ChatBot;
import lombok.experimental.UtilityClass;
import org.jetbrains.annotations.NotNull;
import java.util.function.Supplier;
@UtilityClass
public class ChatBotFactoryAccess {
public static <T extends ChatBot> @NotNull ChatBotFactory<T> newInstance(@NotNull Class<T> type, @NotNull Supplier<T> supplier) {
return new ChatBotFactory<>(type, supplier);
}
}

@ -0,0 +1,29 @@
package eu.jonahbauer.chat.bot.impl;
import eu.jonahbauer.chat.bot.test.TestChatBot;
import eu.jonahbauer.chat.bot.config.BotConfig;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class ChatBotFactoryTest {
@Test
void botsCanOnlyBeInstantiatedUsingChatBotFactory() {
assertThrows(IllegalCallerException.class, TestChatBot::new);
assertNotNull(new ChatBotFactory<>(TestChatBot.class, TestChatBot::new).create(BotConfig.EMPTY));
}
@Test
void suppliedConfigOverridesDefaultConfig() {
var factory = new ChatBotFactory<>(TestChatBot.class, TestChatBot::new);
var bot = factory.create(BotConfig.EMPTY);
assertEquals(TestChatBot.DEFAULT_CONFIG, bot.getConfig());
var overridden = factory.create(BotConfig.builder().name("Overridden").value(TestChatBot.TEST, "overridden").build());
assertEquals("Overridden", overridden.getConfig().require(BotConfig.NAME));
assertEquals("overridden", overridden.getConfig().require(TestChatBot.TEST));
}
}

@ -0,0 +1,21 @@
package eu.jonahbauer.chat.bot.test;
import eu.jonahbauer.chat.bot.api.Chat;
import lombok.Getter;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
@Getter
public class MockChat implements Chat {
private final List<Message> messages = new ArrayList<>();
@Override
public boolean send(@NotNull String channel, @NotNull String name, @NotNull String message, boolean bottag, boolean publicId) {
messages.add(new Message(channel, name, message, bottag, publicId));
return true;
}
public record Message(String channel, String name, String message, boolean bottag, boolean publicId) {}
}

@ -0,0 +1,26 @@
package eu.jonahbauer.chat.bot.test;
import eu.jonahbauer.chat.bot.api.ChatBot;
import eu.jonahbauer.chat.bot.api.Message;
import eu.jonahbauer.chat.bot.config.BotConfig;
import lombok.Setter;
import org.jetbrains.annotations.NotNull;
@Setter
public class TestChatBot extends ChatBot {
public static final BotConfig.Key.OfString TEST = BotConfig.Key.ofString("test");
public static final BotConfig DEFAULT_CONFIG = BotConfig.builder()
.name("TestBot").value(TEST, "test")
.build();
private String message;
public TestChatBot() {
super(DEFAULT_CONFIG);
}
@Override
protected void onMessage(Message.@NotNull Post message) {
post(this.message);
}
}

@ -0,0 +1,6 @@
plugins {
id("chat-bot.bot-conventions")
}
group = "eu.jonahbauer.chat.bots"
version = "0.1.0-SNAPSHOT"

@ -0,0 +1,19 @@
package eu.jonahbauer.chat.bot.ping;
import eu.jonahbauer.chat.bot.api.ChatBot;
import eu.jonahbauer.chat.bot.api.Message;
import org.jetbrains.annotations.NotNull;
public class PingBot extends ChatBot {
public PingBot() {
super("Ping");
}
@Override
protected void onMessage(Message.@NotNull Post message) {
if (message.message().equals("!ping")) {
post("pong");
}
}
}

@ -0,0 +1,10 @@
import eu.jonahbauer.chat.bot.api.ChatBot;
import eu.jonahbauer.chat.bot.ping.PingBot;
module eu.jonahbauer.chat.bot.ping {
requires eu.jonahbauer.chat.bot.api;
requires org.apache.logging.log4j;
requires static lombok;
provides ChatBot with PingBot;
}

@ -0,0 +1,7 @@
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}

@ -0,0 +1,17 @@
import org.gradle.api.tasks.compile.JavaCompile
plugins {
java
id("chat-bot.java-conventions")
}
val libs = versionCatalogs.named("libs")
dependencies {
implementation(project(":bot-api"))
implementation(libs.findLibrary("log4j2-api").get())
}
tasks.withType<JavaCompile> {
options.compilerArgs.add("-parameters")
}

@ -0,0 +1,41 @@
plugins {
java
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
repositories {
mavenLocal()
mavenCentral()
}
val libs = versionCatalogs.named("libs")
dependencies {
compileOnly(libs.findLibrary("lombok").get())
annotationProcessor(libs.findLibrary("lombok").get())
testImplementation(platform(libs.findLibrary("junit-bom").get()))
testImplementation(libs.findLibrary("junit-jupiter").get())
testCompileOnly(libs.findLibrary("lombok").get())
testAnnotationProcessor(libs.findLibrary("lombok").get())
}
tasks.withType<JavaCompile> {
options.compilerArgs.add("--enable-preview")
}
tasks.withType<Test> {
useJUnitPlatform()
jvmArgs("--enable-preview")
}
val application = extensions.findByType<JavaApplication>()
application?.apply {
applicationDefaultJvmArgs = listOf("--enable-preview")
}

@ -0,0 +1,22 @@
[versions]
annotations = "24.1.0"
jackson = "2.16.1"
junit = "5.10.2"
log4j2 = "2.22.1"
lombok = "1.18.30"
[libraries]
annotations = { module = "org.jetbrains:annotations", version.ref = "annotations" }
jackson-annotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jackson"}
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson"}
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson"}
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" }
log4j2-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j2"}
log4j2-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j2"}
log4j2-slf4j = { module = "org.apache.logging.log4j:log4j-slf4j2-impl", version.ref = "log4j2"}
lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" }
[bundles]
jackson = ["jackson-databind", "jackson-annotations", "jackson-datatype-jsr310"]
log4j2 = ["log4j2-api", "log4j2-core", "log4j2-slf4j"]

Binary file not shown.

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-rc-4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

249
gradlew vendored

@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

92
gradlew.bat vendored

@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

@ -0,0 +1,15 @@
plugins {
id("chat-bot.java-conventions")
id("java-library")
}
group = "eu.jonahbauer.chat"
version = "0.1.0-SNAPSHOT"
dependencies {
api(project(":bot-api")) {
capabilities {
requireCapability("eu.jonahbauer.chat:bot-api-config")
}
}
}

@ -0,0 +1,133 @@
package eu.jonahbauer.chat.server.management;
import eu.jonahbauer.chat.bot.config.BotConfig;
import lombok.*;
import org.jetbrains.annotations.NotNull;
import javax.management.openmbean.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Makes {@link BotConfig} accessible as JMX open data.
*/
@SuppressWarnings("unused")
@EqualsAndHashCode
public class BotConfigSupport implements CompositeDataView {
private static final OpenType<String[]> STRING_ARRAY_TYPE;
static {
try {
STRING_ARRAY_TYPE = ArrayType.getArrayType(SimpleType.STRING);
} catch (OpenDataException e) {
throw Lombok.sneakyThrow(e);
}
}
private final @NotNull BotConfig config;
public BotConfigSupport(@NotNull BotConfig config) {
this.config = Objects.requireNonNull(config);
}
public @NotNull BotConfig unwrap() {
return config;
}
// at least one getter is required for this class to be a valid open data type
public String getType() {
return config.getType();
}
// detected by java.management
@SneakyThrows
public static @NotNull BotConfigSupport from(@NotNull CompositeData composite) {
var type = composite.getCompositeType();
var keys = type.keySet();
var out = BotConfig.builder();
for (var key : keys) {
var fieldType = type.getType(key);
var value = composite.get(key);
if (SimpleType.LONG.equals(fieldType)) {
out.value(key, (Long) value);
} else if (SimpleType.INTEGER.equals(fieldType)) {
out.value(key, (Integer) value);
} else if (SimpleType.SHORT.equals(fieldType)) {
out.value(key, (Short) value);
} else if (SimpleType.CHARACTER.equals(fieldType)) {
out.value(key, (Character) value);
} else if (SimpleType.BYTE.equals(fieldType)) {
out.value(key, (Byte) value);
} else if (SimpleType.DOUBLE.equals(fieldType)) {
out.value(key, (Double) value);
} else if (SimpleType.FLOAT.equals(fieldType)) {
out.value(key, (Float) value);
} else if (SimpleType.BOOLEAN.equals(fieldType)) {
out.value(key, (Boolean) value);
} else if (SimpleType.STRING.equals(fieldType)) {
out.value(key, (String) value);
} else if (STRING_ARRAY_TYPE.equals(fieldType)) {
out.value(key, List.of((String[]) composite.get(key)));
} else {
throw new IllegalArgumentException("Unsupported field type: " + fieldType.getDescription());
}
}
return new BotConfigSupport(out.build());
}
@Override
public @NotNull CompositeData toCompositeData(@NotNull CompositeType ct) {
try {
var itemNames = new ArrayList<String>();
var itemTypes = new ArrayList<OpenType<?>>();
var values = new ArrayList<>();
var keys = config.keySet();
for (var key : keys) {
var value = getOpenData(config.get(key).orElseThrow());
var type = getOpenType(value);
itemNames.add(key);
itemTypes.add(type);
values.add(value);
}
var xct = new CompositeType(
ct.getTypeName(),
ct.getDescription(),
itemNames.toArray(new String[0]),
itemNames.toArray(new String[0]),
itemTypes.toArray(new OpenType<?>[0])
);
return new CompositeDataSupport(xct, itemNames.toArray(new String[0]), values.toArray());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static @NotNull OpenType<?> getOpenType(@NotNull Object value) {
return switch (value) {
case Long _ -> SimpleType.LONG;
case Double _ -> SimpleType.DOUBLE;
case Boolean _ -> SimpleType.BOOLEAN;
case String _ -> SimpleType.STRING;
case String[] _ -> STRING_ARRAY_TYPE;
default -> throw new IllegalArgumentException();
};
}
@SuppressWarnings("SuspiciousToArrayCall")
private static Object getOpenData(Object value) {
return value instanceof List<?> list ? list.toArray(new String[0]) : value;
}
@Override
public String toString() {
return config.toString();
}
}

@ -0,0 +1,58 @@
package eu.jonahbauer.chat.server.management;
import eu.jonahbauer.chat.server.management.annotations.ManagedAttribute;
import eu.jonahbauer.chat.server.management.annotations.ManagedOperation;
import eu.jonahbauer.chat.server.management.annotations.ManagedParameter;
import lombok.SneakyThrows;
import org.jetbrains.annotations.NotNull;
import javax.management.MXBean;
import javax.management.ObjectName;
import java.util.List;
import java.util.Map;
import java.util.SortedSet;
@SuppressWarnings("unused")
@MXBean
public interface ChatBotManagerMXBean {
@NotNull ObjectName NAME = getObjectName();
@SneakyThrows
private static @NotNull ObjectName getObjectName() {
return ObjectName.getInstance("eu.jonahbauer.chat.bot.server", "component", "ChatBots");
}
@ManagedOperation(description = "starts a bot with the given name and type", impact = ManagedOperation.Impact.ACTION)
default void start(
@ManagedParameter(name = "name", description = "the name of the bot (must be unique)") @NotNull String name,
@ManagedParameter(name = "type", description = "the fully qualified class name of the bot") @NotNull String type,
@ManagedParameter(name = "channel", description = "the channel the bot will be active in") @NotNull String channel
) {
start(name, type, List.of(channel));
}
@ManagedOperation(description = "starts a bot with the given name and type", impact = ManagedOperation.Impact.ACTION)
void start(
@ManagedParameter(name = "name", description = "the name of the bot (must be unique)") @NotNull String name,
@ManagedParameter(name = "type", description = "the fully qualified class name of the bot") @NotNull String type,
@ManagedParameter(name = "channels", description = "the channels the bot will be active in") @NotNull List<@NotNull String> channels
);
@ManagedOperation(description = "starts a bot with the given config under the specified name", impact = ManagedOperation.Impact.ACTION)
void start(
@ManagedParameter(name = "name", description = "the name of the bot (must be unique)") @NotNull String name,
@ManagedParameter(name = "config", description = "the bot configuration") @NotNull BotConfigSupport config
);
@ManagedOperation(description = "stops the bot with the given name and waits for it to finish", impact = ManagedOperation.Impact.ACTION)
void stop(@ManagedParameter(name = "name", description = "the name of the bot") @NotNull String name) throws InterruptedException;
@ManagedOperation(description = "stops all currently running bots and waits for them to finish", impact = ManagedOperation.Impact.ACTION)
void stop() throws InterruptedException;
@ManagedAttribute(description = "the list of all currently running bots.")
@NotNull Map<@NotNull String, @NotNull BotConfigSupport> getBots();
@ManagedAttribute(description = "the list of all known bot implementations")
@NotNull SortedSet<@NotNull String> getBotImplementations();
}

@ -0,0 +1,59 @@
package eu.jonahbauer.chat.server.management;
import eu.jonahbauer.chat.server.management.annotations.ManagedAttribute;
import eu.jonahbauer.chat.server.management.annotations.ManagedOperation;
import lombok.SneakyThrows;
import org.jetbrains.annotations.NotNull;
import javax.management.MXBean;
import javax.management.ObjectName;
import java.util.List;
@SuppressWarnings("unused")
@MXBean
public interface ChatBotSupportMXBean {
@SneakyThrows
static @NotNull ObjectName getObjectName(String name) {
return ObjectName.getInstance(STR."eu.jonahbauer.chat.bot.server:component=ChatBots,name=\{quote(name)}");
}
private static @NotNull String quote(@NotNull String name) {
var needsQuoting = name.chars().anyMatch(chr -> chr == ',' || chr == '=' || chr == ':' || chr == '"' || chr == '*' || chr == '?');
return needsQuoting ? ObjectName.quote(name) : name;
}
@ManagedAttribute(description = "the bot's name")
@NotNull String getName();
@ManagedAttribute(description = "the bot's type")
default @NotNull String getType() {
return getConfig().getType();
}
@ManagedAttribute(description = "the channels the bot is active in")
default @NotNull List<@NotNull String> getChannels() {
return getConfig().unwrap().getChannels();
}
@ManagedAttribute(description = "whether the bot sets the bottag for its messages")
default boolean isBotTag() {
return getConfig().unwrap().isBotTag();
}
@ManagedAttribute(description = "whether the bot reacts to messages from other bots")
default boolean isIgnoreBots() {
return getConfig().unwrap().isIgnoreBots();
}
@ManagedAttribute(description = "whether the bot sends messages with public id")
default boolean isPublicId() {
return getConfig().unwrap().isPublicId();
}
@ManagedAttribute(description = "the bot's config")
@NotNull BotConfigSupport getConfig();
@ManagedOperation(description = "stops this bot", impact = ManagedOperation.Impact.ACTION)
void stop() throws InterruptedException;
}

@ -0,0 +1,41 @@
package eu.jonahbauer.chat.server.management;
import eu.jonahbauer.chat.server.management.annotations.ManagedAttribute;
import eu.jonahbauer.chat.server.management.annotations.ManagedOperation;
import eu.jonahbauer.chat.server.management.annotations.ManagedOperation.Impact;
import eu.jonahbauer.chat.server.management.annotations.ManagedParameter;
import lombok.SneakyThrows;
import org.jetbrains.annotations.NotNull;
import javax.management.MXBean;
import javax.management.ObjectName;
import java.util.SortedSet;
@SuppressWarnings("unused")
@MXBean
public interface SocketManagerMXBean {
@NotNull ObjectName NAME = getObjectName();
@SneakyThrows
private static @NotNull ObjectName getObjectName() {
return ObjectName.getInstance("eu.jonahbauer.chat.bot.server", "component", "Sockets");
}
@ManagedOperation(description = "updates the credentials used to authenticate to the chat", impact = Impact.ACTION)
void setCredentials(
@ManagedParameter(name = "username") @NotNull String username,
@ManagedParameter(name = "password") @NotNull String password
);
@ManagedOperation(description = "starts a socket for the given channel", impact = Impact.ACTION)
void start(@ManagedParameter(name = "channel") @NotNull String channel);
@ManagedOperation(description = "stops the socket for the given channel and waits for it to finish", impact = Impact.ACTION)
void stop(@ManagedParameter(name = "channel") @NotNull String channel) throws InterruptedException;
@ManagedOperation(description = "stops all sockets and waits for them to finish", impact = Impact.ACTION)
void stop() throws InterruptedException;
@ManagedAttribute(description = "the list of currently connected channels")
@NotNull SortedSet<@NotNull String> getChannels();
}

@ -0,0 +1,5 @@
package eu.jonahbauer.chat.server.management;
public enum SocketState {
CREATED, CONNECTING, CONNECTED, COOLDOWN, STOPPING, STOPPED
}

@ -0,0 +1,59 @@
package eu.jonahbauer.chat.server.management;
import eu.jonahbauer.chat.server.management.annotations.ManagedAttribute;
import eu.jonahbauer.chat.server.management.annotations.ManagedOperation;
import eu.jonahbauer.chat.server.management.annotations.ManagedParameter;
import lombok.SneakyThrows;
import org.jetbrains.annotations.NotNull;
import javax.management.MXBean;
import javax.management.ObjectName;
import java.util.Date;
@SuppressWarnings("unused")
@MXBean
public interface SocketSupportMXBean {
@SneakyThrows
static @NotNull ObjectName getObjectName(@NotNull String channel) {
return ObjectName.getInstance(STR."eu.jonahbauer.chat.bot.server:component=Sockets,name=\{quote(channel)}");
}
private static @NotNull String quote(@NotNull String channel) {
var needsQuoting = channel.chars().anyMatch(chr -> chr == ',' || chr == '=' || chr == ':' || chr == '"' || chr == '*' || chr == '?');
return needsQuoting ? ObjectName.quote(channel) : channel;
}
@ManagedAttribute(description = "the socket's state")
SocketState getState();
@ManagedAttribute(description = "the channel this socket is connected to")
String getChannel();
@ManagedAttribute(description = "the time when the current cooldown will end")
Date getCooldownUntil();
@ManagedOperation(description = "stops this socket", impact = ManagedOperation.Impact.ACTION)
void stop() throws InterruptedException;
@ManagedOperation(description = "forcefully restarts the socket when its currently on cooldown", impact = ManagedOperation.Impact.ACTION)
void restart();
@ManagedOperation(description = "sends a message via this socket", impact = ManagedOperation.Impact.ACTION)
default void send(
@ManagedParameter(name = "name") String name,
@ManagedParameter(name = "message") String message
) {
send(name, message, true, true);
}
@ManagedOperation(description = "sends a message via this socket", impact = ManagedOperation.Impact.ACTION)
void send(
@ManagedParameter(name = "name") String name,
@ManagedParameter(name = "message") String message,
@ManagedParameter(name = "bottag") boolean bottag,
@ManagedParameter(name = "publicic") boolean publicid
);
}

@ -0,0 +1,12 @@
package eu.jonahbauer.chat.server.management.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ManagedAttribute {
String description() default "";
}

@ -0,0 +1,12 @@
package eu.jonahbauer.chat.server.management.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface ManagedBean {
String description();
}

@ -0,0 +1,28 @@
package eu.jonahbauer.chat.server.management.annotations;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import javax.management.MBeanOperationInfo;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ManagedOperation {
String description() default "";
Impact impact() default Impact.UNKNOWN;
@Getter
@RequiredArgsConstructor
enum Impact {
INFO(MBeanOperationInfo.INFO),
ACTION(MBeanOperationInfo.ACTION),
ACTION_INFO(MBeanOperationInfo.ACTION_INFO),
UNKNOWN(MBeanOperationInfo.UNKNOWN);
private final int code;
}
}

@ -0,0 +1,13 @@
package eu.jonahbauer.chat.server.management.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface ManagedParameter {
String name() default "";
String description() default "";
}

@ -0,0 +1,10 @@
module eu.jonahbauer.chat.server.management {
exports eu.jonahbauer.chat.server.management;
exports eu.jonahbauer.chat.server.management.annotations;
requires transitive java.management;
requires transitive eu.jonahbauer.chat.bot.config;
requires static transitive org.jetbrains.annotations;
requires static lombok;
}

@ -0,0 +1,26 @@
plugins {
id("chat-bot.java-conventions")
application
}
group = "eu.jonahbauer.chat"
version = "0.1.0-SNAPSHOT"
val bots = project(":bots").subprojects
dependencies {
implementation(project(":bot-api"))
implementation(project(":management"))
implementation(libs.bundles.log4j2)
implementation(libs.bundles.jackson)
bots.forEach {
implementation(project(it.path))
}
}
application {
mainClass.set("eu.jonahbauer.chat.server.Main")
mainModule.set("eu.jonahbauer.chat.server")
applicationDefaultJvmArgs = listOf("--enable-preview")
}

@ -0,0 +1,60 @@
package eu.jonahbauer.chat.server;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import eu.jonahbauer.chat.bot.config.BotConfig;
import eu.jonahbauer.chat.server.bot.BotConfigDeserializer;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
public record Config(
@NotNull Account account,
@NotNull Set<@NotNull String> channels,
@JsonDeserialize(contentUsing = BotConfigDeserializer.class)
@NotNull Map<@NotNull String, @NotNull BotConfig> bots
) {
private static final String CONFIGURATION_FILE_ENV = "CHAT_BOT_CONFIG";
private static final String CONFIGURATION_FILE_PROPERTY = "chatbot.configurationFile";
private static final String DEFAULT_CONFIGURATION_FILE = "file:./config.json";
public static @NotNull Config load() throws IOException {
var env = System.getenv(CONFIGURATION_FILE_ENV);
if (env != null) return read(env);
var prop = System.getProperty(CONFIGURATION_FILE_PROPERTY);
if (prop != null) return read(prop);
return read(DEFAULT_CONFIGURATION_FILE);
}
public static @NotNull Config read(@NotNull String url) throws IOException {
return read(URI.create(url).toURL());
}
public static @NotNull Config read(@NotNull URL url) throws IOException {
var mapper = new ObjectMapper();
return mapper.readValue(url, Config.class);
}
public Config {
Objects.requireNonNull(account, "account");
channels = Set.copyOf(channels); // implicit null check
bots = Map.copyOf(bots); // implicit null check
}
public record Account(
@NotNull String username,
@NotNull String password
) {
public Account {
Objects.requireNonNull(username, "username");
Objects.requireNonNull(password, "password");
}
}
}

@ -0,0 +1,30 @@
package eu.jonahbauer.chat.server;
import eu.jonahbauer.chat.server.bot.ChatBotSupervisor;
import eu.jonahbauer.chat.server.management.impl.ChatBotManager;
import eu.jonahbauer.chat.server.management.impl.SocketManager;
import eu.jonahbauer.chat.server.socket.SocketSupervisor;
import java.io.IOException;
import java.util.concurrent.CountDownLatch;
public class Main {
public static void main(String[] args) throws IOException, InterruptedException {
// initialize ChatBotManager and SocketManager
var _ = ChatBotManager.INSTANCE;
var _ = SocketManager.INSTANCE;
var config = Config.load();
ChatBotSupervisor.INSTANCE.start(config);
SocketSupervisor.INSTANCE.start(config);
try {
// keep main thread running
new CountDownLatch(1).await();
} catch (InterruptedException e) {
SocketSupervisor.INSTANCE.stop();
ChatBotSupervisor.INSTANCE.stop();
}
}
}

@ -0,0 +1,61 @@
package eu.jonahbauer.chat.server.bot;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import eu.jonahbauer.chat.bot.config.BotConfig;
import eu.jonahbauer.chat.bot.config.BotConfigurationException;
import java.io.IOException;
import java.util.ArrayList;
public class BotConfigDeserializer extends StdDeserializer<BotConfig> {
protected BotConfigDeserializer() {
super(BotConfig.class);
}
@Override
public BotConfig deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
if (p.currentToken() == JsonToken.START_OBJECT) {
p.nextToken();
}
var builder = BotConfig.builder();
while (p.currentToken() == JsonToken.FIELD_NAME) {
var key = p.currentName();
switch (p.nextToken()) {
case VALUE_STRING -> builder.value(key, p.getValueAsString());
case VALUE_FALSE -> builder.value(key, false);
case VALUE_TRUE -> builder.value(key, true);
case VALUE_NULL -> {}
case VALUE_NUMBER_FLOAT -> builder.value(key, p.getDoubleValue());
case VALUE_NUMBER_INT -> builder.value(key, p.getLongValue());
case START_ARRAY -> {
var list = new ArrayList<String>();
while (p.nextToken() != JsonToken.END_ARRAY) {
if (p.currentToken() == JsonToken.VALUE_STRING) {
list.add(p.getValueAsString());
} else {
throw new BotConfigurationException("Unsupported property type in array.");
}
}
builder.value(key, list);
}
case START_OBJECT -> throw new BotConfigurationException("Unsupported property type: object");
default -> throw new BotConfigurationException("Unsupported property property type.");
}
p.nextToken();
}
if (p.currentToken() == JsonToken.END_OBJECT) {
return builder.build();
}
throw new JsonParseException(p, "Unexpected token: " + p.currentToken(), p.currentLocation());
}
}

@ -0,0 +1,242 @@
package eu.jonahbauer.chat.server.bot;
import eu.jonahbauer.chat.bot.api.ChatBot;
import eu.jonahbauer.chat.bot.api.Message;
import eu.jonahbauer.chat.bot.config.BotConfig;
import eu.jonahbauer.chat.bot.impl.BotCreationException;
import eu.jonahbauer.chat.bot.impl.ChatBotFactory;
import eu.jonahbauer.chat.server.Config;
import eu.jonahbauer.chat.server.management.impl.ChatBotSupport;
import eu.jonahbauer.chat.server.socket.SocketSupervisor;
import lombok.extern.log4j.Log4j2;
import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NonBlocking;
import org.jetbrains.annotations.NotNull;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
@Log4j2
public enum ChatBotSupervisor {
INSTANCE;
private static final Duration POST_EXPIRATION = Duration.ofSeconds(2);
private static final int MESSAGE_QUEUE_SIZE = 10;
/**
* Map of running bots indexed by their name.
*/
private final @NotNull Map<@NotNull String, @NotNull RunningBot> bots = new HashMap<>();
/**
* Dispatches the message to all running bots. If a bot has too many messages queued up, the message may not be
* delivered to that bot.
* @param message the message
*/
public void onMessage(@NotNull Message.Post message) {
log.debug("Dispatching message: {}", message);
var expiration = Instant.now().plus(POST_EXPIRATION);
for (var entry : bots.entrySet()) {
var result = entry.getValue().offer(message, expiration);
if (!result) log.warn("A message was dropped because bot {} is busy.", entry.getKey());
}
}
/**
* Starts all bots defined in the given config.
* @param config a configuration
* @throws IllegalStateException if any bots are running already
*/
public synchronized void start(@NotNull Config config) {
if (!bots.isEmpty()) throw new IllegalStateException("start(Config) may not be used when any bots are running already");
config.bots().forEach(this::start);
}
/**
* Starts a bot with the given name and type.
* @param name the name of the bot (must be unique)
* @param type the fully qualified name of a class extending {@link ChatBot}
* @param channels the channels the bot will be active in
* @throws BotCreationException if the bot could not be created
*/
public synchronized void start(@NotNull String name, @NotNull String type, @NotNull List<String> channels) {
start(name, BotConfig.builder().type(type).channels(channels).build());
}
/**
* Starts a bot with the given config under the specified name.
* @param name the name of the bot (must be unique)
* @param config the bot configuration
* @throws BotCreationException if the bot could not be created
*/
public synchronized void start(@NotNull String name, @NotNull BotConfig config) {
if (bots.containsKey(name)) throw new BotCreationException("Duplicate bot name: " + name);
bots.put(name, new RunningBot(name, config));
}
/**
* Stops the bot with the given name and waits for it to finish. Does nothing if no bot with that name exists.
* @param name the name of the bot
* @throws InterruptedException if any thread has interrupted the current thread.
*/
public void stop(@NotNull String name) throws InterruptedException {
RunningBot bot;
synchronized (this) {
bot = bots.remove(name);
if (bot == null) return;
bot.stop();
}
bot.join();
}
/**
* Stops all currently running bots and waits for them to finish.
* @throws InterruptedException if any thread has interrupted the current thread.
*/
public void stop() throws InterruptedException {
var stopped = new ArrayList<RunningBot>();
synchronized (this) {
for (RunningBot bot : bots.values()) {
stopped.add(bot);
bot.stop();
}
bots.clear();
}
for (var bot : stopped) {
bot.join();
}
}
/**
* {@return a map of all currently running bots and their effective config}
*/
public @NotNull Map<@NotNull String, @NotNull BotConfig> getBots() {
var out = new TreeMap<String, BotConfig>();
for (var entry : bots.entrySet()) {
out.put(entry.getKey(), entry.getValue().getConfig());
}
return Collections.unmodifiableMap(out);
}
@Contract("_ -> new")
@SuppressWarnings("unchecked")
private static @NotNull ChatBotFactory<?> getChatBotFactory(@NotNull String type) {
try {
var clazz = Class.forName(type);
if (!ChatBot.class.isAssignableFrom(clazz)) {
throw new BotCreationException("Not a chat bot type: " + type);
}
return new ChatBotFactory<>((Class<? extends ChatBot>) clazz);
} catch (ClassNotFoundException ex) {
throw new BotCreationException("Unknown chat bot of type: " + type, ex);
}
}
/**
* Manages a {@link ChatBot} instance running on its own thread. Takes care of recreating the bot after it threw
* an exception. {@linkplain ChatBotSupport#register(String, BotConfig) Registers} the bot during construction
* and {@linkplain ChatBotSupport#unregister(String) unregisters} it during {@link #stop()}.
*/
private static class RunningBot implements Runnable {
private final @NotNull String name;
private final @NotNull BotConfig config;
private final @NotNull ChatBotFactory<?> factory;
private final @NotNull Thread thread;
private final @NotNull BlockingQueue<@NotNull PendingPost> queue = new ArrayBlockingQueue<>(MESSAGE_QUEUE_SIZE);
private final AtomicBoolean stopped = new AtomicBoolean();
private volatile ChatBot bot;
public RunningBot(@NotNull String name, @NotNull BotConfig config) {
this.name = name;
this.config = config;
this.factory = getChatBotFactory(config.getType());
log.info("starting bot {}...", name);
ChatBotSupport.register(name, config);
this.thread = Thread.ofVirtual().name("ChatBot[" + name + "]").start(this);
}
@Override
public void run() {
log.info("started bot {}", name);
while (!stopped.get()) {
var bot = factory.create(config);
this.bot = bot;
try {
loop(bot);
} catch (Exception ex) {
log.warn("bot {} threw an exception and will be recreated", name, ex);
continue;
}
try {
bot.onStop();
} catch (Exception ex) {
log.warn("bot {} threw an exception during shutdown", name, ex);
}
}
log.info("stopped bot {}", name);
}
private void loop(@NotNull ChatBot bot) {
while (!stopped.get()) {
try {
var post = queue.take();
if (Instant.now().isAfter(post.expiration())) {
log.warn("A message was dropped because it expired: {}", post);
} else if (bot.getConfig().isIgnoreBots() && post.post().bot()) {
log.debug("Ignoring message {} because bottag is set.", post.post());
} else if (!bot.getConfig().getChannels().contains(post.post().channel())) {
log.debug("Ignoring message {} because channel is not listened to.", post.post());
} else {
bot.onMessage(SocketSupervisor.INSTANCE, post.post());
}
} catch (InterruptedException _) {
}
}
}
@NonBlocking
public boolean offer(@NotNull Message.Post post, @NotNull Instant expiration) {
if (stopped.get()) return false;
return queue.offer(new PendingPost(post, expiration));
}
/**
* Stops the bot, unregistering it from the MBean server and preventing it from receiving any new messages.
*/
@NonBlocking
public void stop() {
if (stopped.getAndSet(true)) return;
log.info("stopping bot {}", name);
ChatBotSupport.unregister(name);
thread.interrupt();
}
/**
* Waits for the bot to finish execution.
*/
@Blocking
public void join() throws InterruptedException {
thread.join();
}
public @NotNull BotConfig getConfig() {
var bot = this.bot;
return bot != null ? bot.getConfig() : config;
}
private record PendingPost(@NotNull Message.Post post, @NotNull Instant expiration) {}
}
}

@ -0,0 +1,157 @@
package eu.jonahbauer.chat.server.management.impl;
import eu.jonahbauer.chat.server.management.annotations.ManagedAttribute;
import eu.jonahbauer.chat.server.management.annotations.ManagedBean;
import eu.jonahbauer.chat.server.management.annotations.ManagedOperation;
import eu.jonahbauer.chat.server.management.annotations.ManagedParameter;
import lombok.extern.log4j.Log4j2;
import javax.management.*;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.function.Function;
@Log4j2
public class AdvancedMBean extends StandardMBean {
public <T> AdvancedMBean(T implementation) {
super(implementation, null, true);
}
@Override
protected String getDescription(MBeanInfo info) {
var clazz = getMBeanInterface();
var annotation = clazz.getAnnotation(ManagedBean.class);
if (annotation != null && !"".equals(annotation.description())) {
return annotation.description();
} else {
return super.getDescription(info);
}
}
@Override
protected int getImpact(MBeanOperationInfo info) {
if (info == null) return MBeanOperationInfo.UNKNOWN;
var method = findOperation(info);
var annotation = method != null ? method.getAnnotation(ManagedOperation.class) : null;
if (annotation != null) {
return annotation.impact().getCode();
} else {
return super.getImpact(info);
}
}
@Override
protected String getDescription(MBeanOperationInfo info) {
if (info == null) return null;
var method = findOperation(info);
var annotation = method != null ? method.getAnnotation(ManagedOperation.class) : null;
if (annotation != null && !"".equals(annotation.description())) {
return annotation.description();
} else {
return super.getDescription(info);
}
}
@Override
protected String getParameterName(MBeanOperationInfo op, MBeanParameterInfo param, int sequence) {
if (op == null) return super.getParameterName(op, param, sequence);
var method = findOperation(op);
if (method == null) return super.getParameterName(op, param, sequence);
var annotation = method.getParameters()[sequence].getAnnotation(ManagedParameter.class);
if (annotation != null && !"".equals(annotation.name())) {
return annotation.name();
} else {
return super.getParameterName(op, param, sequence);
}
}
@Override
protected String getDescription(MBeanOperationInfo op, MBeanParameterInfo param, int sequence) {
if (op == null) return super.getParameterName(op, param, sequence);
var method = findOperation(op);
if (method == null) return super.getParameterName(op, param, sequence);
var annotation = method.getParameters()[sequence].getAnnotation(ManagedParameter.class);
if (annotation != null && !"".equals(annotation.description())) {
return annotation.description();
} else {
return super.getParameterName(op, param, sequence);
}
}
@Override
protected String getDescription(MBeanAttributeInfo info) {
var name = info.getName();
var type = getType(info, MBeanAttributeInfo::getType);
var getter = findMethod(("boolean".equals(type) ? "is" : "get") + capitalize(name));
if (getter != null) {
var annotation = getter.getAnnotation(ManagedAttribute.class);
if (annotation != null && !"".equals(annotation.description())) return annotation.description();
}
var setter = findMethod("set" + capitalize(name), type);
if (setter != null) {
var annotation = setter.getAnnotation(ManagedAttribute.class);
if (annotation != null && !"".equals(annotation.description())) return annotation.description();
}
return super.getDescription(info);
}
private <T extends MBeanFeatureInfo> String getType(T info, Function<T, String> getType) {
var descriptor = info.getDescriptor();
if (descriptor == null) return getType.apply(info);
try {
var originalType = (String) descriptor.getFieldValue(JMX.ORIGINAL_TYPE_FIELD);
return originalType.contains("<") ? originalType.substring(0, originalType.indexOf("<")) : originalType;
} catch (ClassCastException | RuntimeOperationsException ignored) {
return getType.apply(info);
}
}
private Method findOperation(MBeanOperationInfo info) {
var name = info.getName();
var params = new ArrayList<String>();
for (var parameter : info.getSignature()) {
params.add(getType(parameter, MBeanParameterInfo::getType));
}
return findMethod(name, params);
}
private Method findMethod(String name, String...parameters) {
return findMethod(name, Arrays.asList(parameters));
}
private Method findMethod(String name, List<String> parameters) {
var clazz = getMBeanInterface();
methods: for (var method : clazz.getMethods()) {
if (!method.getName().equals(name)) continue;
if (method.getParameterCount() != parameters.size()) continue;
var parameterTypes = method.getParameterTypes();
for (int i = 0; i < parameters.size(); i++) {
if (!parameters.get(i).equals(parameterTypes[i].getName())) continue methods;
}
return method;
}
return null;
}
private static String capitalize(String name) {
if (name.isEmpty()) return name;
return name.substring(0, 1).toUpperCase(Locale.ROOT) + name.substring(1);
}
}

@ -0,0 +1,67 @@
package eu.jonahbauer.chat.server.management.impl;
import eu.jonahbauer.chat.bot.impl.BotCreationException;
import eu.jonahbauer.chat.bot.impl.ChatBotFactory;
import eu.jonahbauer.chat.server.bot.ChatBotSupervisor;
import eu.jonahbauer.chat.server.management.BotConfigSupport;
import eu.jonahbauer.chat.server.management.ChatBotManagerMXBean;
import lombok.SneakyThrows;
import org.jetbrains.annotations.NotNull;
import java.lang.management.ManagementFactory;
import java.util.*;
import java.util.stream.Collectors;
public enum ChatBotManager implements ChatBotManagerMXBean {
INSTANCE;
@SneakyThrows
ChatBotManager() {
var server = ManagementFactory.getPlatformMBeanServer();
server.registerMBean(new AdvancedMBean(this), ChatBotManagerMXBean.NAME);
}
@Override
public void start(@NotNull String name, @NotNull String type, @NotNull List<@NotNull String> channels) {
try {
ChatBotSupervisor.INSTANCE.start(name, type, channels);
} catch (BotCreationException ex) {
throw new IllegalArgumentException(ex.getMessage(), ex.getCause());
}
}
@Override
public void start(@NotNull String name, @NotNull BotConfigSupport config) {
try {
ChatBotSupervisor.INSTANCE.start(name, config.unwrap());
} catch (BotCreationException ex) {
throw new IllegalArgumentException(ex.getMessage(), ex.getCause());
}
}
@Override
public void stop(@NotNull String name) throws InterruptedException {
ChatBotSupervisor.INSTANCE.stop(name);
}
@Override
public void stop() throws InterruptedException {
ChatBotSupervisor.INSTANCE.stop();
}
@Override
public @NotNull Map<@NotNull String, @NotNull BotConfigSupport> getBots() {
var out = new TreeMap<String, BotConfigSupport>();
ChatBotSupervisor.INSTANCE.getBots().forEach((key, value) -> out.put(key, new BotConfigSupport(value)));
return Collections.unmodifiableMap(out);
}
@Override
public @NotNull SortedSet<@NotNull String> getBotImplementations() {
return Collections.unmodifiableSortedSet(
ChatBotFactory.implementations().stream()
.map(Class::getCanonicalName)
.collect(Collectors.toCollection(TreeSet::new))
);
}
}

@ -0,0 +1,44 @@
package eu.jonahbauer.chat.server.management.impl;
import eu.jonahbauer.chat.bot.config.BotConfig;
import eu.jonahbauer.chat.server.bot.ChatBotSupervisor;
import eu.jonahbauer.chat.server.management.BotConfigSupport;
import eu.jonahbauer.chat.server.management.ChatBotSupportMXBean;
import lombok.Getter;
import lombok.extern.log4j.Log4j2;
import java.lang.management.ManagementFactory;
@Getter
@Log4j2
public class ChatBotSupport implements ChatBotSupportMXBean {
private final String name;
private final BotConfigSupport config;
public static void register(String name, BotConfig config) {
try {
var server = ManagementFactory.getPlatformMBeanServer();
server.registerMBean(new AdvancedMBean(new ChatBotSupport(name, config)), ChatBotSupportMXBean.getObjectName(name));
} catch (Exception ex) {
log.error("Could not register bot as an MBean.", ex);
}
}
public static void unregister(String name) {
try {
var server = ManagementFactory.getPlatformMBeanServer();
server.unregisterMBean(ChatBotSupportMXBean.getObjectName(name));
} catch (Exception ex) {
log.error("Could not unregister bot as an MBean.", ex);
}
}
private ChatBotSupport(String name, BotConfig config) {
this.name = name;
this.config = new BotConfigSupport(config);
}
public void stop() throws InterruptedException {
ChatBotSupervisor.INSTANCE.stop(name);
}
}

@ -0,0 +1,44 @@
package eu.jonahbauer.chat.server.management.impl;
import eu.jonahbauer.chat.server.management.SocketManagerMXBean;
import eu.jonahbauer.chat.server.socket.SocketSupervisor;
import lombok.SneakyThrows;
import org.jetbrains.annotations.NotNull;
import java.lang.management.ManagementFactory;
import java.util.SortedSet;
public enum SocketManager implements SocketManagerMXBean {
INSTANCE;
@SneakyThrows
SocketManager() {
var server = ManagementFactory.getPlatformMBeanServer();
server.registerMBean(new AdvancedMBean(this), SocketManagerMXBean.NAME);
}
@Override
public void setCredentials(@NotNull String username, @NotNull String password) {
SocketSupervisor.INSTANCE.setAccount(username, password);
}
@Override
public void start(@NotNull String channel) {
SocketSupervisor.INSTANCE.start(channel);
}
@Override
public void stop(@NotNull String channel) throws InterruptedException {
SocketSupervisor.INSTANCE.stop(channel);
}
@Override
public void stop() throws InterruptedException {
SocketSupervisor.INSTANCE.stop();
}
@Override
public @NotNull SortedSet<@NotNull String> getChannels() {
return SocketSupervisor.INSTANCE.getChannels();
}
}

@ -0,0 +1,64 @@
package eu.jonahbauer.chat.server.management.impl;
import eu.jonahbauer.chat.server.management.SocketState;
import eu.jonahbauer.chat.server.management.SocketSupportMXBean;
import eu.jonahbauer.chat.server.socket.SocketSupervisor;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import java.lang.management.ManagementFactory;
import java.util.Date;
@Log4j2
@Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class SocketSupport implements SocketSupportMXBean {
private final String channel;
public static void register(String channel) {
try {
var server = ManagementFactory.getPlatformMBeanServer();
server.registerMBean(new AdvancedMBean(new SocketSupport(channel)), SocketSupportMXBean.getObjectName(channel));
} catch (Exception ex) {
log.error("Could not register socket as an MBean.", ex);
}
}
public static void unregister(String name) {
try {
var server = ManagementFactory.getPlatformMBeanServer();
server.unregisterMBean(SocketSupportMXBean.getObjectName(name));
} catch (Exception ex) {
log.error("Could not unregister socket as an MBean.", ex);
}
}
@Override
public Date getCooldownUntil() {
var cooldown = SocketSupervisor.INSTANCE.getCooldownUntil(channel);
return cooldown != null ? Date.from(cooldown) : null;
}
@Override
public SocketState getState() {
return SocketSupervisor.INSTANCE.getState(channel);
}
@Override
public void stop() throws InterruptedException {
SocketSupervisor.INSTANCE.stop(channel);
}
@Override
public void restart() {
SocketSupervisor.INSTANCE.restart(channel);
}
@Override
public void send(String name, String message, boolean bottag, boolean publicid) {
SocketSupervisor.INSTANCE.send(channel, name, message, bottag, publicid);
}
}

@ -0,0 +1,21 @@
package eu.jonahbauer.chat.server.socket;
import com.fasterxml.jackson.annotation.JsonFormat;
import org.jetbrains.annotations.NotNull;
import java.util.Objects;
public record OutgoingMessage(
@NotNull String name,
@NotNull String message,
@NotNull String channel,
long delay,
@JsonFormat(shape = JsonFormat.Shape.NUMBER) boolean publicid,
@JsonFormat(shape = JsonFormat.Shape.NUMBER) boolean bottag
) {
public OutgoingMessage {
Objects.requireNonNull(channel, "channel");
Objects.requireNonNull(name, "name");
Objects.requireNonNull(message, "message");
}
}

@ -0,0 +1,7 @@
package eu.jonahbauer.chat.server.socket;
import lombok.experimental.StandardException;
@StandardException
public class SocketCreationException extends RuntimeException {
}

@ -0,0 +1,576 @@
package eu.jonahbauer.chat.server.socket;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import eu.jonahbauer.chat.bot.api.Chat;
import eu.jonahbauer.chat.bot.api.Message;
import eu.jonahbauer.chat.server.Config;
import eu.jonahbauer.chat.server.bot.ChatBotSupervisor;
import eu.jonahbauer.chat.server.management.SocketState;
import eu.jonahbauer.chat.server.management.impl.SocketSupport;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
import org.apache.logging.log4j.Level;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.net.CookieManager;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.net.http.HttpResponse;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;
import static eu.jonahbauer.chat.server.util.UrlTemplateProcessor.URL;
@Log4j2
public enum SocketSupervisor implements Chat {
INSTANCE;
private static final URI AUTH_SERVER = URI.create("https://chat.qed-verein.de/rubychat/account");
private static final String ORIGIN = "https://chat.qed-verein.de";
private static final String SERVER = "wss://chat.qed-verein.de/websocket?position=0&version=2&channel=";
private static final int PING_INTERVAL = 30;
private static final ScheduledExecutorService SCHEDULER = Executors.newScheduledThreadPool(1, Thread.ofVirtual().name("SocketSupervisor").factory());
private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new JavaTimeModule());
private volatile @Nullable Config.Account account;
private volatile @Nullable Credentials credentials;
private final @NotNull CookieManager cookie = new CookieManager();
private final @NotNull HttpClient client = HttpClient.newBuilder().cookieHandler(cookie).build();
private final @NotNull ConcurrentMap<@NotNull String, @NotNull ChatClient> sockets = new ConcurrentHashMap<>();
public void setAccount(@NotNull Config.Account account) {
this.account = Objects.requireNonNull(account, "account");
}
public void setAccount(@NotNull String username, @NotNull String password) {
this.account = new Config.Account(username, password);
}
@Override
public boolean send(@NotNull String channel, @NotNull String name, @NotNull String message, boolean bottag, boolean publicId) {
var socket = sockets.get(channel);
if (socket == null || !socket.isOpen()) {
log.error("Cannot deliver message to {}: not connected", channel);
return false;
} else {
var out = new OutgoingMessage(name, message, channel, socket.delay, publicId, bottag);
try {
socket.send(MAPPER.writeValueAsString(out));
log.info("Sending message: {}", out);
return true;
} catch (JsonProcessingException e) {
log.error("Could not serialize message: {}", out, e);
return false;
}
}
}
/**
* Starts all sockets defined in the given config.
* @param config a configuration
* @throws IllegalStateException if any sockets are already running
*/
public synchronized void start(@NotNull Config config) {
if (!this.sockets.isEmpty()) throw new IllegalStateException("start(Config) may not be used when any sockets are running already");
setAccount(config.account());
config.channels().forEach(this::start);
}
/**
* Starts a socket for the given channel.
* @param channel the channel
* @throws IllegalStateException if a socket is already connected to that channel
*/
public synchronized void start(@NotNull String channel) {
if (sockets.containsKey(channel)) throw new SocketCreationException("Duplicate channel: " + channel);
var socket = new ChatClient(channel);
this.sockets.put(channel, socket);
}
/**
* Forcefully restarts the socket for the given channel when it is on cooldown.
* @param channel the channel
* @throws IllegalStateException if the socket is not on cooldown
*/
public void restart(@NotNull String channel) {
var socket = sockets.get(channel);
if (socket != null) socket.restart();
}
public @Nullable SocketState getState(@NotNull String channel) {
var socket = sockets.get(channel);
return socket == null ? null : socket.getState();
}
public @Nullable Instant getCooldownUntil(@NotNull String channel) {
var socket = sockets.get(channel);
return socket == null ? null : socket.getCooldownUntil();
}
/**
* Stops the socket for the given channel and waits for it to finish.
* @param channel the channel
* @throws InterruptedException if any thread has interrupted the current thread.
*/
public void stop(@NotNull String channel) throws InterruptedException {
var socket = sockets.get(channel);
if (socket == null) return;
socket.stop();
socket.join();
}
/**
* Stops all currently running sockets and waits for them to finish.
* @throws InterruptedException if any thread has interrupted the current thread.
*/
public void stop() throws InterruptedException {
var sockets = new ArrayList<>(this.sockets.values());
for (var socket : sockets) {
socket.stop();
}
for (var socket : sockets) {
socket.join();
}
}
public @NotNull SortedSet<@NotNull String> getChannels() {
return Collections.unmodifiableSortedSet(new TreeSet<>(sockets.keySet()));
}
@SneakyThrows
private @NotNull Credentials login() {
var account = this.account;
if (account == null) throw new IllegalStateException("Account not initialized.");
var credentials = this.credentials;
if (credentials != null) return credentials;
var request = HttpRequest.newBuilder().uri(AUTH_SERVER)
.POST(BodyPublishers.ofString(
URL."username=\{account.username()}&password=\{account.password()}&version=20171030131648"
))
.build();
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalArgumentException(STR."invalid credentials (status code: \{response.statusCode()})");
}
var body = response.body();
if (!body.contains("success")) {
throw new IllegalArgumentException(STR."invalid credentials (\{body})");
}
var cookies = cookie.getCookieStore().get(AUTH_SERVER);
var userid = cookies.stream().filter(cookie -> cookie.getName().equals("userid")).findFirst();
var pwhash = cookies.stream().filter(cookie -> cookie.getName().equals("pwhash")).findFirst();
if (userid.isEmpty() || pwhash.isEmpty()) {
throw new IllegalArgumentException(STR."invalid credentials (status code: \{response.statusCode()})");
}
this.credentials = credentials = new Credentials(userid.get().getValue(), pwhash.get().getValue());
return credentials;
}
private record Credentials(String userid, String pwhash) {}
private sealed interface ChatClientState {
default void onEnter() {}
default void send(@NotNull String message) {
throw new IllegalStateException();
}
default void restart() {
throw new IllegalStateException();
}
default void stop() {
abort();
}
default void abort() {
throw new IllegalStateException();
}
@NotNull SocketState getState();
}
private final class ChatClient implements WebSocket.Listener, ChatClientState {
private final String channel;
private final @NotNull ReentrantLock lock = new ReentrantLock();
private final @NotNull CountDownLatch stopped = new CountDownLatch(1);
private @NotNull ChatClientState state = new Created();
private volatile long delay = -1;
public ChatClient(@NotNull String channel) {
this.channel = channel;
SocketSupport.register(channel);
this.state.onEnter();
}
private void transition(@NotNull ChatClientState from, @NotNull ChatClientState to) {
lock.lock();
try {
if (state == from) {
state = to;
state.onEnter();
}
} finally {
lock.unlock();
}
}
public boolean isOpen() {
return getState() == SocketState.CONNECTED;
}
public @Nullable Instant getCooldownUntil() {
return state instanceof Cooldown cooldown ? cooldown.until : null;
}
@Override
public void send(@NotNull String message) {
lock.lock();
try {
state.send(message);
} finally {
lock.unlock();
}
}
@Override
public void restart() {
lock.lock();
try {
state.restart();
} finally {
lock.unlock();
}
}
@Override
public void stop() {
lock.lock();
try {
state.stop();
} finally {
lock.unlock();
}
}
@Override
public void abort() {
lock.lock();
try {
state.abort();
} finally {
lock.unlock();
}
}
@Override
public @NotNull SocketState getState() {
return state.getState();
}
public void join() throws InterruptedException {
stopped.await();
}
private <T extends Throwable> void withNamedThread(@NotNull ThrowingRunnable<T> runnable) throws T {
var name = Thread.currentThread().getName();
try {
Thread.currentThread().setName(STR."Socket[\{channel}]");
runnable.run();
} finally {
Thread.currentThread().setName(name);
}
}
private <R, T extends Throwable> R withNamedThread(@NotNull ThrowingSupplier<R, T> supplier) throws T {
var name = Thread.currentThread().getName();
try {
Thread.currentThread().setName(STR."Socket[\{channel}]");
return supplier.get();
} finally {
Thread.currentThread().setName(name);
}
}
private interface ThrowingRunnable<T extends Throwable> {
void run() throws T;
}
private interface ThrowingSupplier<R, T extends Throwable> {
R get() throws T;
}
public final class Created implements ChatClientState {
@Override
public void onEnter() {
transition(this, new Connecting());
}
@Override
public @NotNull SocketState getState() {
return SocketState.CREATED;
}
}
public final class Connecting implements ChatClientState {
private final int cooldown;
public Connecting() {
this(1);
}
public Connecting(int cooldown) {
this.cooldown = cooldown;
}
@Override
public void onEnter() {
log.info("starting socket {}", channel);
try {
var connected = new Connected();
transition(this, connected);
} catch (Exception ex) {
log.warn("socket {} failed with an exception.", channel, ex);
transition(this, new Cooldown(cooldown));
}
}
@Override
public @NotNull SocketState getState() {
return SocketState.CONNECTING;
}
}
public final class Connected implements ChatClientState, WebSocket.Listener {
private final @NotNull WebSocket socket;
private @Nullable ScheduledFuture<?> ping;
private volatile boolean stopped;
private final @NotNull List<@NotNull CharSequence> parts = new ArrayList<>();
private volatile CompletableFuture<?> message = new CompletableFuture<>();
public Connected() throws ExecutionException, InterruptedException {
var credentials = SocketSupervisor.this.login();
this.socket = SocketSupervisor.this.client.newWebSocketBuilder()
.header("Origin", ORIGIN)
.header("Cookie", URL."userid=\{credentials.userid()}; pwhash=\{credentials.pwhash()}")
.buildAsync(URI.create(SERVER + URL."\{channel}"), this)
.get();
}
@Override
public void onOpen(@NotNull WebSocket webSocket) {
withNamedThread(() -> {
log.info("started socket {}", channel);
ping = SCHEDULER.scheduleAtFixedRate(this::ping, PING_INTERVAL, PING_INTERVAL, TimeUnit.SECONDS);
WebSocket.Listener.super.onOpen(webSocket);
});
}
@Override
public @Nullable CompletionStage<?> onText(@NotNull WebSocket webSocket, @NotNull CharSequence message, boolean last) {
return withNamedThread(() -> {
this.parts.add(message);
webSocket.request(1);
if (last) {
onMessage(this.parts);
this.parts.clear();
this.message.complete(null);
CompletionStage<?> out = this.message;
this.message = new CompletableFuture<>();
return out;
}
return this.message;
});
}
private void onMessage(@NotNull List<@NotNull CharSequence> parts) {
var text = String.join("", parts);
try {
var message = MAPPER.readValue(text, Message.class);
var level = message instanceof Message.Post || message instanceof Message.Ack ? Level.INFO : Level.DEBUG;
log.log(level, "Received message: {}", message);
if (message instanceof Message.Post post) {
delay = post.id() + 1;
ChatBotSupervisor.INSTANCE.onMessage(post);
}
} catch (JsonProcessingException e) {
log.warn("Could not parse as message: {}", text, e);
}
}
@Override
public @Nullable CompletionStage<?> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
return withNamedThread(() -> {
log.warn("Socket {} received binary data.", channel);
return WebSocket.Listener.super.onBinary(webSocket, data, last);
});
}
@Override
public @Nullable CompletionStage<?> onClose(@NotNull WebSocket webSocket, int statusCode, @NotNull String reason) {
return withNamedThread(() -> {
log.info("socket {} closed (code={}, reason={})", channel, statusCode, reason);
lock.lock();
try {
if (stopped) {
transition(this, new Stopped());
} else {
transition(this, new Cooldown(1));
}
} finally {
lock.unlock();
}
return null;
});
}
@Override
public void onError(WebSocket webSocket, Throwable error) {
withNamedThread(() -> {
log.warn("Socket {} failed with an exception.", channel, error);
lock.lock();
try {
if (stopped) {
transition(this, new Stopped());
} else {
transition(this, new Cooldown(1));
}
} finally {
lock.unlock();
}
});
}
@Override
public void send(@NotNull String message) {
if (stopped) throw new IllegalStateException();
socket.sendText(message, true);
}
@Override
public void abort() {
if (ping != null) ping.cancel(true);
socket.abort();
transition(this, new Stopped());
}
@Override
public void stop() {
if (stopped) throw new IllegalStateException();
log.info("stopping socket {}", channel);
if (ping != null) ping.cancel(true);
stopped = true;
socket.sendClose(WebSocket.NORMAL_CLOSURE, "ok");
}
private void ping() {
try {
socket.sendText(MAPPER.writeValueAsString(Message.PING), true);
log.debug("Sending message: {}", Message.PING);
} catch (IOException ex) {
log.error("Failed to send ping", ex);
}
}
@Override
public @NotNull SocketState getState() {
return stopped ? SocketState.STOPPING : SocketState.CONNECTED;
}
}
public final class Cooldown implements ChatClientState {
private final int cooldown;
private Instant until;
private ScheduledFuture<?> future;
public Cooldown(int cooldown) {
this.cooldown = cooldown;
}
@Override
public void onEnter() {
log.info("restarting socket {} in {} seconds", channel, cooldown);
var nextCooldown = Math.min(3600, cooldown * 2);
this.until = Instant.now().plusSeconds(cooldown);
this.future = SCHEDULER.schedule(() -> transition(this, new Connecting(nextCooldown)), cooldown, TimeUnit.SECONDS);
}
@Override
public void abort() {
log.info("stopping socket {}", channel);
future.cancel(true);
transition(this, new Stopped());
}
@Override
public void restart() {
future.cancel(true);
transition(this, new Connecting());
}
@Override
public @NotNull SocketState getState() {
return SocketState.COOLDOWN;
}
}
public final class Stopped implements ChatClientState {
@Override
public void onEnter() {
log.info("stopped socket {}", channel);
stopped.countDown();
sockets.remove(channel, ChatClient.this);
SocketSupport.unregister(channel);
}
@Override
public void abort() {}
@Override
public @NotNull SocketState getState() {
return SocketState.STOPPED;
}
}
}
}

@ -0,0 +1,28 @@
package eu.jonahbauer.chat.server.util;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
public enum UrlTemplateProcessor implements StringTemplate.Processor<String, RuntimeException> {
URL;
@Override
public String process(StringTemplate template) throws RuntimeException {
var out = new StringBuilder();
var fragments = template.fragments();
var values = template.values();
for (int i = 0, length = values.size(); i < length; i++) {
out.append(fragments.get(i));
var value = values.get(i);
if (value != null) {
out.append(URLEncoder.encode(value.toString(), StandardCharsets.UTF_8));
}
}
out.append(fragments.getLast());
return out.toString();
}
}

@ -0,0 +1,17 @@
module eu.jonahbauer.chat.server {
exports eu.jonahbauer.chat.server;
opens eu.jonahbauer.chat.server.bot to com.fasterxml.jackson.databind;
opens eu.jonahbauer.chat.server.socket to com.fasterxml.jackson.databind;
requires com.fasterxml.jackson.core;
requires com.fasterxml.jackson.databind;
requires com.fasterxml.jackson.datatype.jsr310;
requires eu.jonahbauer.chat.bot.api;
requires eu.jonahbauer.chat.server.management;
requires java.management;
requires java.net.http;
requires org.apache.logging.log4j;
requires org.jetbrains.annotations;
requires static lombok;
}

@ -0,0 +1,12 @@
<Configuration>
<Appenders>
<Console name="console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight{%-5level} [%15.15t] %style{%c{1}}{cyan} : %msg%n" />
</Console>
</Appenders>
<Loggers>
<Root level="info" additivity="false">
<AppenderRef ref="console" />
</Root>
</Loggers>
</Configuration>

@ -0,0 +1,18 @@
@file:Suppress("UnstableApiUsage")
rootProject.name = "ChatBot"
include("server")
include("bot-api")
include("bots")
include("management")
// Bots
include("bots:ping-bot")
project(":bots:ping-bot").name = "ping-bot"
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
Loading…
Cancel
Save