commit eadf1eaf5b8110f94a294ad1b5a7e48b5f5baf00 Author: jbb01 Date: Tue Sep 12 19:47:07 2023 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b63da45 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/bot-api/build.gradle.kts b/bot-api/build.gradle.kts new file mode 100644 index 0000000..1a2995a --- /dev/null +++ b/bot-api/build.gradle.kts @@ -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 { + options.compilerArgs.add("-Xlint:-module") +} \ No newline at end of file diff --git a/bot-api/src/config/java/eu/jonahbauer/chat/bot/config/BotConfig.java b/bot-api/src/config/java/eu/jonahbauer/chat/bot/config/BotConfig.java new file mode 100644 index 0000000..a4a51a8 --- /dev/null +++ b/bot-api/src/config/java/eu/jonahbauer/chat/bot/config/BotConfig.java @@ -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>(); + 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 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 @NotNull T require(@NotNull Key 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 @NotNull T get(@NotNull Key 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 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 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> 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 data; + + private Builder() { + this(Collections.emptyMap()); + } + + private Builder(Map 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 Builder value(@NotNull Key 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 the raw value type + * @param the optional value type + */ + public sealed interface Key { + @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 {} + record OfDouble(@NotNull String name) implements Key {} + record OfString(@NotNull String name) implements Key> {} + record OfBoolean(@NotNull String name) implements Key> {} + record OfStringArray(@NotNull String name) implements Key, Optional>> {} + } + + public sealed interface Entry extends Map.Entry { + @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 { + @Override + public @NotNull Long value() { + return valueAsLong; + } + } + record OfDouble(@NotNull String key, double valueAsDouble) implements Entry { + @Override + public @NotNull Double value() { + return valueAsDouble; + } + } + record OfString(@NotNull String key, @NotNull String value) implements Entry {} + record OfBoolean(@NotNull String key, boolean valueAsBoolean) implements Entry { + @Override + public @NotNull Boolean value() { + return valueAsBoolean; + } + } + record OfStringArray(@NotNull String key, @NotNull List<@NotNull String> value) implements Entry> {} + } + + 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 asArray(@NotNull String key) { throw wrongType(key, "array", name()); } + Object asObject(); + + @SuppressWarnings("unchecked") + static Value forKey(Key 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) 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 asArray(@NotNull String key) { return value; } + + @Override + public Object asObject() { return value; } + } + } +} diff --git a/bot-api/src/config/java/eu/jonahbauer/chat/bot/config/BotConfigurationException.java b/bot-api/src/config/java/eu/jonahbauer/chat/bot/config/BotConfigurationException.java new file mode 100644 index 0000000..71df0cf --- /dev/null +++ b/bot-api/src/config/java/eu/jonahbauer/chat/bot/config/BotConfigurationException.java @@ -0,0 +1,7 @@ +package eu.jonahbauer.chat.bot.config; + +import lombok.experimental.StandardException; + +@StandardException +public class BotConfigurationException extends RuntimeException { +} diff --git a/bot-api/src/config/java/module-info.java b/bot-api/src/config/java/module-info.java new file mode 100644 index 0000000..80b0c33 --- /dev/null +++ b/bot-api/src/config/java/module-info.java @@ -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; +} \ No newline at end of file diff --git a/bot-api/src/main/java/eu/jonahbauer/chat/bot/api/Chat.java b/bot-api/src/main/java/eu/jonahbauer/chat/bot/api/Chat.java new file mode 100644 index 0000000..e2fcfbe --- /dev/null +++ b/bot-api/src/main/java/eu/jonahbauer/chat/bot/api/Chat.java @@ -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); +} diff --git a/bot-api/src/main/java/eu/jonahbauer/chat/bot/api/ChatBot.java b/bot-api/src/main/java/eu/jonahbauer/chat/bot/api/ChatBot.java new file mode 100644 index 0000000..694f66d --- /dev/null +++ b/bot-api/src/main/java/eu/jonahbauer/chat/bot/api/ChatBot.java @@ -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 CHANNEL = ScopedValue.newInstance(); + private static final ScopedValue 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); + } +} diff --git a/bot-api/src/main/java/eu/jonahbauer/chat/bot/api/Message.java b/bot-api/src/main/java/eu/jonahbauer/chat/bot/api/Message.java new file mode 100644 index 0000000..fc0e284 --- /dev/null +++ b/bot-api/src/main/java/eu/jonahbauer/chat/bot/api/Message.java @@ -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; + } + } +} diff --git a/bot-api/src/main/java/eu/jonahbauer/chat/bot/impl/BotCreationException.java b/bot-api/src/main/java/eu/jonahbauer/chat/bot/impl/BotCreationException.java new file mode 100644 index 0000000..860e2b0 --- /dev/null +++ b/bot-api/src/main/java/eu/jonahbauer/chat/bot/impl/BotCreationException.java @@ -0,0 +1,7 @@ +package eu.jonahbauer.chat.bot.impl; + +import lombok.experimental.StandardException; + +@StandardException +public class BotCreationException extends RuntimeException { +} diff --git a/bot-api/src/main/java/eu/jonahbauer/chat/bot/impl/ChatBotFactory.java b/bot-api/src/main/java/eu/jonahbauer/chat/bot/impl/ChatBotFactory.java new file mode 100644 index 0000000..1d6c0db --- /dev/null +++ b/bot-api/src/main/java/eu/jonahbauer/chat/bot/impl/ChatBotFactory.java @@ -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 { + public static final ScopedValue BOT_CONFIG = ScopedValue.newInstance(); + private static final ServiceLoader SERVICE_LOADER = ServiceLoader.load(ChatBot.class); + + private final @NotNull Class type; + private final @NotNull Supplier delegate; + + public static @NotNull Set<@NotNull Class> implementations() { + return SERVICE_LOADER.stream().map(ServiceLoader.Provider::type).collect(Collectors.toUnmodifiableSet()); + } + + @SuppressWarnings("unchecked") + public ChatBotFactory(@NotNull Class type) { + if (!ChatBot.class.isAssignableFrom(type)) throw new IllegalArgumentException(); + + this.type = type; + this.delegate = (Supplier) SERVICE_LOADER.stream() + .filter(p -> p.type().equals(type)).findFirst() + .orElseThrow(() -> new BotCreationException("No suitable provider found: " + type.getName())); + } + + @TestOnly + ChatBotFactory(@NotNull Class type, @NotNull Supplier supplier) { + if (!ChatBot.class.isAssignableFrom(type)) throw new IllegalArgumentException(); + this.type = type; + this.delegate = Objects.requireNonNull(supplier, "supplier"); + } + + public @NotNull Class 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); + } + } +} diff --git a/bot-api/src/main/java/module-info.java b/bot-api/src/main/java/module-info.java new file mode 100644 index 0000000..c04c146 --- /dev/null +++ b/bot-api/src/main/java/module-info.java @@ -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; +} \ No newline at end of file diff --git a/bot-api/src/test/java/eu/jonahbauer/chat/bot/api/ChatBotTest.java b/bot-api/src/test/java/eu/jonahbauer/chat/bot/api/ChatBotTest.java new file mode 100644 index 0000000..2f02774 --- /dev/null +++ b/bot-api/src/test/java/eu/jonahbauer/chat/bot/api/ChatBotTest.java @@ -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 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!")); + } +} diff --git a/bot-api/src/test/java/eu/jonahbauer/chat/bot/config/BotConfigTest.java b/bot-api/src/test/java/eu/jonahbauer/chat/bot/config/BotConfigTest.java new file mode 100644 index 0000000..8a55431 --- /dev/null +++ b/bot-api/src/test/java/eu/jonahbauer/chat/bot/config/BotConfigTest.java @@ -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")); + } + +} diff --git a/bot-api/src/test/java/eu/jonahbauer/chat/bot/impl/ChatBotFactoryAccess.java b/bot-api/src/test/java/eu/jonahbauer/chat/bot/impl/ChatBotFactoryAccess.java new file mode 100644 index 0000000..0887f04 --- /dev/null +++ b/bot-api/src/test/java/eu/jonahbauer/chat/bot/impl/ChatBotFactoryAccess.java @@ -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 @NotNull ChatBotFactory newInstance(@NotNull Class type, @NotNull Supplier supplier) { + return new ChatBotFactory<>(type, supplier); + } +} diff --git a/bot-api/src/test/java/eu/jonahbauer/chat/bot/impl/ChatBotFactoryTest.java b/bot-api/src/test/java/eu/jonahbauer/chat/bot/impl/ChatBotFactoryTest.java new file mode 100644 index 0000000..9f1325e --- /dev/null +++ b/bot-api/src/test/java/eu/jonahbauer/chat/bot/impl/ChatBotFactoryTest.java @@ -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)); + } + +} diff --git a/bot-api/src/test/java/eu/jonahbauer/chat/bot/test/MockChat.java b/bot-api/src/test/java/eu/jonahbauer/chat/bot/test/MockChat.java new file mode 100644 index 0000000..1ac5982 --- /dev/null +++ b/bot-api/src/test/java/eu/jonahbauer/chat/bot/test/MockChat.java @@ -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 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) {} +} diff --git a/bot-api/src/test/java/eu/jonahbauer/chat/bot/test/TestChatBot.java b/bot-api/src/test/java/eu/jonahbauer/chat/bot/test/TestChatBot.java new file mode 100644 index 0000000..0ea5595 --- /dev/null +++ b/bot-api/src/test/java/eu/jonahbauer/chat/bot/test/TestChatBot.java @@ -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); + } +} \ No newline at end of file diff --git a/bots/ping-bot/build.gradle.kts b/bots/ping-bot/build.gradle.kts new file mode 100644 index 0000000..89e7fbd --- /dev/null +++ b/bots/ping-bot/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + id("chat-bot.bot-conventions") +} + +group = "eu.jonahbauer.chat.bots" +version = "0.1.0-SNAPSHOT" \ No newline at end of file diff --git a/bots/ping-bot/src/main/java/eu/jonahbauer/chat/bot/ping/PingBot.java b/bots/ping-bot/src/main/java/eu/jonahbauer/chat/bot/ping/PingBot.java new file mode 100644 index 0000000..72d1041 --- /dev/null +++ b/bots/ping-bot/src/main/java/eu/jonahbauer/chat/bot/ping/PingBot.java @@ -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"); + } + } +} diff --git a/bots/ping-bot/src/main/java/module-info.java b/bots/ping-bot/src/main/java/module-info.java new file mode 100644 index 0000000..59fc4dc --- /dev/null +++ b/bots/ping-bot/src/main/java/module-info.java @@ -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; +} \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..b22ed73 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/chat-bot.bot-conventions.gradle.kts b/buildSrc/src/main/kotlin/chat-bot.bot-conventions.gradle.kts new file mode 100644 index 0000000..55cfc6a --- /dev/null +++ b/buildSrc/src/main/kotlin/chat-bot.bot-conventions.gradle.kts @@ -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 { + options.compilerArgs.add("-parameters") +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/chat-bot.java-conventions.gradle.kts b/buildSrc/src/main/kotlin/chat-bot.java-conventions.gradle.kts new file mode 100644 index 0000000..22ead73 --- /dev/null +++ b/buildSrc/src/main/kotlin/chat-bot.java-conventions.gradle.kts @@ -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 { + options.compilerArgs.add("--enable-preview") +} + +tasks.withType { + useJUnitPlatform() + jvmArgs("--enable-preview") +} + +val application = extensions.findByType() +application?.apply { + applicationDefaultJvmArgs = listOf("--enable-preview") +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..0390bda --- /dev/null +++ b/gradle/libs.versions.toml @@ -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"] diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f20ff05 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..6689b85 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/management/build.gradle.kts b/management/build.gradle.kts new file mode 100644 index 0000000..56afbc0 --- /dev/null +++ b/management/build.gradle.kts @@ -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") + } + } +} \ No newline at end of file diff --git a/management/src/main/java/eu/jonahbauer/chat/server/management/BotConfigSupport.java b/management/src/main/java/eu/jonahbauer/chat/server/management/BotConfigSupport.java new file mode 100644 index 0000000..92ea0b2 --- /dev/null +++ b/management/src/main/java/eu/jonahbauer/chat/server/management/BotConfigSupport.java @@ -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_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(); + var itemTypes = new ArrayList>(); + 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(); + } +} diff --git a/management/src/main/java/eu/jonahbauer/chat/server/management/ChatBotManagerMXBean.java b/management/src/main/java/eu/jonahbauer/chat/server/management/ChatBotManagerMXBean.java new file mode 100644 index 0000000..66c22c7 --- /dev/null +++ b/management/src/main/java/eu/jonahbauer/chat/server/management/ChatBotManagerMXBean.java @@ -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(); +} diff --git a/management/src/main/java/eu/jonahbauer/chat/server/management/ChatBotSupportMXBean.java b/management/src/main/java/eu/jonahbauer/chat/server/management/ChatBotSupportMXBean.java new file mode 100644 index 0000000..96067b5 --- /dev/null +++ b/management/src/main/java/eu/jonahbauer/chat/server/management/ChatBotSupportMXBean.java @@ -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; +} diff --git a/management/src/main/java/eu/jonahbauer/chat/server/management/SocketManagerMXBean.java b/management/src/main/java/eu/jonahbauer/chat/server/management/SocketManagerMXBean.java new file mode 100644 index 0000000..d927870 --- /dev/null +++ b/management/src/main/java/eu/jonahbauer/chat/server/management/SocketManagerMXBean.java @@ -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(); +} diff --git a/management/src/main/java/eu/jonahbauer/chat/server/management/SocketState.java b/management/src/main/java/eu/jonahbauer/chat/server/management/SocketState.java new file mode 100644 index 0000000..187ff3c --- /dev/null +++ b/management/src/main/java/eu/jonahbauer/chat/server/management/SocketState.java @@ -0,0 +1,5 @@ +package eu.jonahbauer.chat.server.management; + +public enum SocketState { + CREATED, CONNECTING, CONNECTED, COOLDOWN, STOPPING, STOPPED +} \ No newline at end of file diff --git a/management/src/main/java/eu/jonahbauer/chat/server/management/SocketSupportMXBean.java b/management/src/main/java/eu/jonahbauer/chat/server/management/SocketSupportMXBean.java new file mode 100644 index 0000000..e097449 --- /dev/null +++ b/management/src/main/java/eu/jonahbauer/chat/server/management/SocketSupportMXBean.java @@ -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 + ); + +} diff --git a/management/src/main/java/eu/jonahbauer/chat/server/management/annotations/ManagedAttribute.java b/management/src/main/java/eu/jonahbauer/chat/server/management/annotations/ManagedAttribute.java new file mode 100644 index 0000000..309e3c1 --- /dev/null +++ b/management/src/main/java/eu/jonahbauer/chat/server/management/annotations/ManagedAttribute.java @@ -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 ""; +} diff --git a/management/src/main/java/eu/jonahbauer/chat/server/management/annotations/ManagedBean.java b/management/src/main/java/eu/jonahbauer/chat/server/management/annotations/ManagedBean.java new file mode 100644 index 0000000..689b7da --- /dev/null +++ b/management/src/main/java/eu/jonahbauer/chat/server/management/annotations/ManagedBean.java @@ -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(); +} diff --git a/management/src/main/java/eu/jonahbauer/chat/server/management/annotations/ManagedOperation.java b/management/src/main/java/eu/jonahbauer/chat/server/management/annotations/ManagedOperation.java new file mode 100644 index 0000000..714cb47 --- /dev/null +++ b/management/src/main/java/eu/jonahbauer/chat/server/management/annotations/ManagedOperation.java @@ -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; + } +} diff --git a/management/src/main/java/eu/jonahbauer/chat/server/management/annotations/ManagedParameter.java b/management/src/main/java/eu/jonahbauer/chat/server/management/annotations/ManagedParameter.java new file mode 100644 index 0000000..ac0db47 --- /dev/null +++ b/management/src/main/java/eu/jonahbauer/chat/server/management/annotations/ManagedParameter.java @@ -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 ""; +} diff --git a/management/src/main/java/module-info.java b/management/src/main/java/module-info.java new file mode 100644 index 0000000..f360574 --- /dev/null +++ b/management/src/main/java/module-info.java @@ -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; +} \ No newline at end of file diff --git a/server/build.gradle.kts b/server/build.gradle.kts new file mode 100644 index 0000000..2e878ca --- /dev/null +++ b/server/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/server/src/main/java/eu/jonahbauer/chat/server/Config.java b/server/src/main/java/eu/jonahbauer/chat/server/Config.java new file mode 100644 index 0000000..8ebfa4c --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/Config.java @@ -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"); + } + } +} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/Main.java b/server/src/main/java/eu/jonahbauer/chat/server/Main.java new file mode 100644 index 0000000..5f54c76 --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/Main.java @@ -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(); + } + } +} \ No newline at end of file diff --git a/server/src/main/java/eu/jonahbauer/chat/server/bot/BotConfigDeserializer.java b/server/src/main/java/eu/jonahbauer/chat/server/bot/BotConfigDeserializer.java new file mode 100644 index 0000000..9b6bda7 --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/bot/BotConfigDeserializer.java @@ -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 { + + 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(); + 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()); + } +} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/bot/ChatBotSupervisor.java b/server/src/main/java/eu/jonahbauer/chat/server/bot/ChatBotSupervisor.java new file mode 100644 index 0000000..0bae7e6 --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/bot/ChatBotSupervisor.java @@ -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 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(); + + 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(); + 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) 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) {} + } +} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/management/impl/AdvancedMBean.java b/server/src/main/java/eu/jonahbauer/chat/server/management/impl/AdvancedMBean.java new file mode 100644 index 0000000..c23790d --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/management/impl/AdvancedMBean.java @@ -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 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 String getType(T info, Function 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(); + + 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 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); + } +} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/management/impl/ChatBotManager.java b/server/src/main/java/eu/jonahbauer/chat/server/management/impl/ChatBotManager.java new file mode 100644 index 0000000..7cd5421 --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/management/impl/ChatBotManager.java @@ -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(); + 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)) + ); + } +} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/management/impl/ChatBotSupport.java b/server/src/main/java/eu/jonahbauer/chat/server/management/impl/ChatBotSupport.java new file mode 100644 index 0000000..19b67ed --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/management/impl/ChatBotSupport.java @@ -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); + } +} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/management/impl/SocketManager.java b/server/src/main/java/eu/jonahbauer/chat/server/management/impl/SocketManager.java new file mode 100644 index 0000000..38ed05f --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/management/impl/SocketManager.java @@ -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(); + } +} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/management/impl/SocketSupport.java b/server/src/main/java/eu/jonahbauer/chat/server/management/impl/SocketSupport.java new file mode 100644 index 0000000..1103b0c --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/management/impl/SocketSupport.java @@ -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); + } +} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/socket/OutgoingMessage.java b/server/src/main/java/eu/jonahbauer/chat/server/socket/OutgoingMessage.java new file mode 100644 index 0000000..04c134f --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/socket/OutgoingMessage.java @@ -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"); + } +} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/socket/SocketCreationException.java b/server/src/main/java/eu/jonahbauer/chat/server/socket/SocketCreationException.java new file mode 100644 index 0000000..544dca6 --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/socket/SocketCreationException.java @@ -0,0 +1,7 @@ +package eu.jonahbauer.chat.server.socket; + +import lombok.experimental.StandardException; + +@StandardException +public class SocketCreationException extends RuntimeException { +} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/socket/SocketSupervisor.java b/server/src/main/java/eu/jonahbauer/chat/server/socket/SocketSupervisor.java new file mode 100644 index 0000000..aa6c489 --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/socket/SocketSupervisor.java @@ -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 void withNamedThread(@NotNull ThrowingRunnable runnable) throws T { + var name = Thread.currentThread().getName(); + try { + Thread.currentThread().setName(STR."Socket[\{channel}]"); + runnable.run(); + } finally { + Thread.currentThread().setName(name); + } + } + + private R withNamedThread(@NotNull ThrowingSupplier 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 { + void run() throws T; + } + + private interface ThrowingSupplier { + 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; + } + } + } +} diff --git a/server/src/main/java/eu/jonahbauer/chat/server/util/UrlTemplateProcessor.java b/server/src/main/java/eu/jonahbauer/chat/server/util/UrlTemplateProcessor.java new file mode 100644 index 0000000..b2a7170 --- /dev/null +++ b/server/src/main/java/eu/jonahbauer/chat/server/util/UrlTemplateProcessor.java @@ -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 { + 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(); + } +} diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java new file mode 100644 index 0000000..b2f120d --- /dev/null +++ b/server/src/main/java/module-info.java @@ -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; +} \ No newline at end of file diff --git a/server/src/main/resources/log4j2.xml b/server/src/main/resources/log4j2.xml new file mode 100644 index 0000000..8bb1efe --- /dev/null +++ b/server/src/main/resources/log4j2.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..61319e6 --- /dev/null +++ b/settings.gradle.kts @@ -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() + } +}