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