initial commit

This commit is contained in:
jbb01
2023-09-12 19:47:07 +02:00
commit eadf1eaf5b
57 changed files with 3407 additions and 0 deletions

43
bot-api/build.gradle.kts Normal file
View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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);
}

View File

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

View File

@@ -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;
}
}
}

View File

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

View File

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

View File

@@ -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;
}

View File

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

View File

@@ -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"));
}
}

View File

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

View File

@@ -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));
}
}

View File

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

View File

@@ -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);
}
}