initial commit
commit
eadf1eaf5b
@ -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<String>}.
|
||||||
|
*/
|
||||||
|
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<String>}.
|
||||||
|
*/
|
||||||
|
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
|
@ -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" "$@"
|
@ -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…
Reference in New Issue