initial commit
This commit is contained in:
43
bot-api/build.gradle.kts
Normal file
43
bot-api/build.gradle.kts
Normal 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")
|
||||
}
|
@@ -0,0 +1,464 @@
|
||||
package eu.jonahbauer.chat.bot.config;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
|
||||
public final class BotConfig {
|
||||
public static final Key.OfString NAME = Key.ofString("name");
|
||||
public static final Key.OfBoolean BOT_TAG = Key.ofBoolean("bottag");
|
||||
public static final Key.OfBoolean IGNORE_BOTS = Key.ofBoolean("ignore_bots");
|
||||
public static final Key.OfBoolean PUBLIC_ID = Key.ofBoolean("public_id");
|
||||
public static final Key.OfStringArray CHANNELS = Key.ofStringArray("channels");
|
||||
public static final Key.OfString TYPE = Key.ofString("type");
|
||||
public static final BotConfig EMPTY = new BotConfig(Collections.emptyMap());
|
||||
|
||||
@EqualsAndHashCode.Include
|
||||
private final @Unmodifiable @NotNull Map<@NotNull String, @NotNull Value> data;
|
||||
|
||||
public static @NotNull Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return a new builder initialized with this config's data}
|
||||
*/
|
||||
public @NotNull Builder mutate() {
|
||||
return new Builder(this.data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull String toString() {
|
||||
return "BotConfig" + data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the other config into this one, returning a new config container properties of both this and the provided
|
||||
* config. Properties present in both this and the provided config will contain the value of the provided config.
|
||||
* @param config another config
|
||||
* @return a merged config
|
||||
*/
|
||||
public @NotNull BotConfig merge(@NotNull BotConfig config) {
|
||||
var data = new HashMap<>(this.data);
|
||||
data.putAll(config.data);
|
||||
return new BotConfig(Map.copyOf(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return the set of all keys to this config}
|
||||
*/
|
||||
public @NotNull Set<@NotNull String> keySet() {
|
||||
return data.keySet();
|
||||
}
|
||||
|
||||
private transient @Nullable Set<@NotNull Entry<?>> entrySet;
|
||||
public @NotNull Set<@NotNull Entry<?>> entrySet() {
|
||||
if (entrySet == null) {
|
||||
var entries = new HashSet<Entry<?>>();
|
||||
data.forEach((key, v) -> entries.add(switch (v) {
|
||||
case Value.OfLong(long value) -> Entry.ofLong(key, value);
|
||||
case Value.OfDouble(double value) -> Entry.ofDouble(key, value);
|
||||
case Value.OfString(String value) -> Entry.ofString(key, value);
|
||||
case Value.OfBoolean(boolean value) -> Entry.ofBoolean(key, value);
|
||||
case Value.OfStringArray(List<String> value) -> Entry.ofStringArray(key, value);
|
||||
}));
|
||||
entrySet = Collections.unmodifiableSet(entries);
|
||||
}
|
||||
return entrySet;
|
||||
}
|
||||
|
||||
/*
|
||||
* getters for default keys
|
||||
*/
|
||||
|
||||
public @NotNull String getType() {
|
||||
return get(TYPE).orElseThrow(() -> new BotConfigurationException("missing property: " + TYPE));
|
||||
}
|
||||
|
||||
public @NotNull String getName() {
|
||||
return get(NAME).orElseThrow(() -> new BotConfigurationException("missing property: " + NAME));
|
||||
}
|
||||
|
||||
public @NotNull List<@NotNull String> getChannels() {
|
||||
return get(CHANNELS).orElseThrow(() -> new BotConfigurationException("missing property: " + CHANNELS));
|
||||
}
|
||||
|
||||
public boolean isBotTag() {
|
||||
return get(BOT_TAG).orElse(true);
|
||||
}
|
||||
|
||||
public boolean isIgnoreBots() {
|
||||
return get(IGNORE_BOTS).orElse(true);
|
||||
}
|
||||
|
||||
public boolean isPublicId() {
|
||||
return get(PUBLIC_ID).orElse(true);
|
||||
}
|
||||
|
||||
/*
|
||||
* generic getters
|
||||
*/
|
||||
|
||||
/**
|
||||
* {@return whether this config contains the given key}
|
||||
*/
|
||||
public boolean has(@NotNull String key) {
|
||||
return data.containsKey(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return whether this config contains the given key and the value associated with the key matches the key type}
|
||||
*/
|
||||
public boolean has(@NotNull Key<?, ?> key) {
|
||||
return switch (key) {
|
||||
case Key.OfBoolean(var k) -> getBoolean(k).isPresent();
|
||||
case Key.OfLong(var k) -> getLong(k).isPresent();
|
||||
case Key.OfDouble(var k) -> getDouble(k).isPresent();
|
||||
case Key.OfStringArray(var k) -> getStringArray(k).isPresent();
|
||||
case Key.OfString(var k) -> getString(k).isPresent();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return the value associated with the given key}
|
||||
* @throws BotConfigurationException if no value is present for the given key or the value does not match the key
|
||||
* type
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> @NotNull T require(@NotNull Key<T, ?> key) {
|
||||
try {
|
||||
return (T) switch (key) {
|
||||
case Key.OfBoolean(var k) -> getBoolean(k).orElseThrow();
|
||||
case Key.OfLong(var k) -> getLong(k).orElseThrow();
|
||||
case Key.OfDouble(var k) -> getDouble(k).orElseThrow();
|
||||
case Key.OfStringArray(var k) -> getStringArray(k).orElseThrow();
|
||||
case Key.OfString(var k) -> getString(k).orElseThrow();
|
||||
};
|
||||
} catch (NoSuchElementException ex) {
|
||||
throw new BotConfigurationException("Missing property value:" + key.name(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return the value associated with the given key} Returns an empty optional if no value is present for the given
|
||||
* key or the value does not match the key type.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> @NotNull T get(@NotNull Key<?, T> key) {
|
||||
return (T) switch (key) {
|
||||
case Key.OfBoolean(var k) -> getBoolean(k);
|
||||
case Key.OfLong(var k) -> getLong(k);
|
||||
case Key.OfDouble(var k) -> getDouble(k);
|
||||
case Key.OfStringArray(var k) -> getStringArray(k);
|
||||
case Key.OfString(var k) -> getString(k);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return the value associated with the given key} If not empty, then the returned {@link Optional} will
|
||||
* contain an object of type {@link String}, {@link Long}, {@link Double}, {@link Boolean} or {@link List List<String>}.
|
||||
*/
|
||||
public @NotNull Optional<?> get(@NotNull String key) {
|
||||
var value = data.get(key);
|
||||
return value == null ? Optional.empty() : Optional.of(value.asObject());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return the value associated with the given key} Returns an {@linkplain Optional#empty() empty optional} if no
|
||||
* value is present for the given key or the value is not of type {@link String}.
|
||||
*/
|
||||
public @NotNull Optional<String> getString(@NotNull String key) {
|
||||
var value = data.get(key);
|
||||
return value == null ? Optional.empty() : Optional.of(value.asString(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return the value associated with the given key} Returns an {@linkplain OptionalLong#empty() empty optional} if
|
||||
* no value is present for the given key or the value is not of type {@link Long}.
|
||||
*/
|
||||
public @NotNull OptionalLong getLong(@NotNull String key) {
|
||||
var value = data.get(key);
|
||||
return value == null ? OptionalLong.empty() : OptionalLong.of(value.asLong(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return the value associated with the given key} Returns an {@linkplain OptionalDouble#empty() empty optional}
|
||||
* if no value is present for the given key or the value is not of type {@link Double}.
|
||||
*/
|
||||
public @NotNull OptionalDouble getDouble(@NotNull String key) {
|
||||
var value = data.get(key);
|
||||
return value == null ? OptionalDouble.empty() : OptionalDouble.of(value.asDouble(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return the value associated with the given key} Returns an {@linkplain Optional#empty() empty optional} if no
|
||||
* value is present for the given key or the value is not of type {@link Boolean}.
|
||||
*/
|
||||
public @NotNull Optional<Boolean> getBoolean(@NotNull String key) {
|
||||
var value = data.get(key);
|
||||
return value == null ? Optional.empty() : Optional.of(value.asBoolean(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return the value associated with the given key} Returns an {@linkplain Optional#empty() empty optional} if no
|
||||
* value is present for the given key or the value is not of type {@link List List<String>}.
|
||||
*/
|
||||
public @NotNull Optional<List<String>> getStringArray(@NotNull String key) {
|
||||
var value = data.get(key);
|
||||
return value == null ? Optional.empty() : Optional.of(value.asArray(key));
|
||||
}
|
||||
|
||||
private static BotConfigurationException wrongType(@NotNull String key, @NotNull String expected, @NotNull String actual) {
|
||||
return new BotConfigurationException("Wrong property type for " + key + " (expected " + expected + ", got " + actual + ")");
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private final Map<String, Value> data;
|
||||
|
||||
private Builder() {
|
||||
this(Collections.emptyMap());
|
||||
}
|
||||
|
||||
private Builder(Map<String, Value> data) {
|
||||
this.data = new HashMap<>(data);
|
||||
}
|
||||
|
||||
public Builder type(@NotNull String type) {
|
||||
return value(TYPE, type);
|
||||
}
|
||||
|
||||
public Builder name(@NotNull String name) {
|
||||
return value(NAME, name);
|
||||
}
|
||||
|
||||
public Builder channels(@NotNull String @NotNull... channels) {
|
||||
return value(CHANNELS, List.of(channels));
|
||||
}
|
||||
|
||||
public Builder channels(@NotNull List<@NotNull String> channels) {
|
||||
return value(CHANNELS, List.copyOf(channels));
|
||||
}
|
||||
|
||||
public Builder noPublicId() {
|
||||
return value(PUBLIC_ID, false);
|
||||
}
|
||||
|
||||
public Builder noBotTag() {
|
||||
return value(BOT_TAG, false);
|
||||
}
|
||||
|
||||
public Builder noIgnoreBots() {
|
||||
return value(IGNORE_BOTS, false);
|
||||
}
|
||||
|
||||
public Builder value(@NotNull String key, long value) {
|
||||
data.put(key, new Value.OfLong(value));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder value(@NotNull String key, double value) {
|
||||
data.put(key, new Value.OfDouble(value));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder value(@NotNull String key, @NotNull String value) {
|
||||
data.put(key, new Value.OfString(value));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder value(@NotNull String key, boolean value) {
|
||||
data.put(key, new Value.OfBoolean(value));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder value(@NotNull String key, @NotNull List<@NotNull String> value) {
|
||||
data.put(key, new Value.OfStringArray(value));
|
||||
return this;
|
||||
}
|
||||
|
||||
public <S> Builder value(@NotNull Key<S, ?> key, S value) {
|
||||
data.put(key.name(), Value.forKey(key, value));
|
||||
return this;
|
||||
}
|
||||
|
||||
public BotConfig build() {
|
||||
return new BotConfig(Map.copyOf(data));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed key to a {@link BotConfig}.
|
||||
* @param <S> the raw value type
|
||||
* @param <T> the optional value type
|
||||
*/
|
||||
public sealed interface Key<S, T> {
|
||||
@NotNull String name();
|
||||
|
||||
static @NotNull Key.OfLong ofLong(@NotNull String key) {
|
||||
return new OfLong(key);
|
||||
}
|
||||
|
||||
static @NotNull Key.OfDouble ofDouble(@NotNull String key) {
|
||||
return new OfDouble(key);
|
||||
}
|
||||
|
||||
static @NotNull Key.OfString ofString(@NotNull String key) {
|
||||
return new OfString(key);
|
||||
}
|
||||
|
||||
static @NotNull Key.OfBoolean ofBoolean(@NotNull String key) {
|
||||
return new OfBoolean(key);
|
||||
}
|
||||
|
||||
static @NotNull Key.OfStringArray ofStringArray(@NotNull String key) {
|
||||
return new OfStringArray(key);
|
||||
}
|
||||
|
||||
record OfLong(@NotNull String name) implements Key<Long, OptionalLong> {}
|
||||
record OfDouble(@NotNull String name) implements Key<Double, OptionalDouble> {}
|
||||
record OfString(@NotNull String name) implements Key<String, Optional<String>> {}
|
||||
record OfBoolean(@NotNull String name) implements Key<Boolean, Optional<Boolean>> {}
|
||||
record OfStringArray(@NotNull String name) implements Key<List<String>, Optional<List<String>>> {}
|
||||
}
|
||||
|
||||
public sealed interface Entry<T> extends Map.Entry<String, T> {
|
||||
@NotNull String key();
|
||||
@NotNull T value();
|
||||
|
||||
@Override
|
||||
default @NotNull String getKey() {
|
||||
return key();
|
||||
}
|
||||
|
||||
@Override
|
||||
default @NotNull T getValue() {
|
||||
return value();
|
||||
}
|
||||
|
||||
@Override
|
||||
default T setValue(T value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
static @NotNull Entry.OfLong ofLong(@NotNull String key, long value) {
|
||||
return new Entry.OfLong(key, value);
|
||||
}
|
||||
|
||||
static @NotNull Entry.OfDouble ofDouble(@NotNull String key, double value) {
|
||||
return new Entry.OfDouble(key, value);
|
||||
}
|
||||
|
||||
static @NotNull Entry.OfString ofString(@NotNull String key, @NotNull String value) {
|
||||
return new Entry.OfString(key, value);
|
||||
}
|
||||
|
||||
static @NotNull Entry.OfBoolean ofBoolean(@NotNull String key, boolean value) {
|
||||
return new Entry.OfBoolean(key, value);
|
||||
}
|
||||
|
||||
static @NotNull Entry.OfStringArray ofStringArray(@NotNull String key, @NotNull List<@NotNull String> value) {
|
||||
return new Entry.OfStringArray(key, value);
|
||||
}
|
||||
|
||||
|
||||
record OfLong(@NotNull String key, long valueAsLong) implements Entry<Long> {
|
||||
@Override
|
||||
public @NotNull Long value() {
|
||||
return valueAsLong;
|
||||
}
|
||||
}
|
||||
record OfDouble(@NotNull String key, double valueAsDouble) implements Entry<Double> {
|
||||
@Override
|
||||
public @NotNull Double value() {
|
||||
return valueAsDouble;
|
||||
}
|
||||
}
|
||||
record OfString(@NotNull String key, @NotNull String value) implements Entry<String> {}
|
||||
record OfBoolean(@NotNull String key, boolean valueAsBoolean) implements Entry<Boolean> {
|
||||
@Override
|
||||
public @NotNull Boolean value() {
|
||||
return valueAsBoolean;
|
||||
}
|
||||
}
|
||||
record OfStringArray(@NotNull String key, @NotNull List<@NotNull String> value) implements Entry<List<String>> {}
|
||||
}
|
||||
|
||||
private sealed interface Value {
|
||||
String name();
|
||||
|
||||
default long asLong(@NotNull String key) { throw wrongType(key, "integer", name()); }
|
||||
default double asDouble(@NotNull String key) { throw wrongType(key, "float", name()); }
|
||||
default String asString(@NotNull String key) { throw wrongType(key, "string", name()); }
|
||||
default boolean asBoolean(@NotNull String key) { throw wrongType(key, "boolean", name()); }
|
||||
default List<String> asArray(@NotNull String key) { throw wrongType(key, "array", name()); }
|
||||
Object asObject();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static <S> Value forKey(Key<S,?> key, S value) {
|
||||
return switch (key) {
|
||||
case Key.OfString _ -> new Value.OfString((String) value);
|
||||
case Key.OfDouble _ -> new Value.OfDouble((double) value);
|
||||
case Key.OfLong _ -> new Value.OfLong((long) value);
|
||||
case Key.OfBoolean _ -> new Value.OfBoolean((boolean) value);
|
||||
case Key.OfStringArray _ -> new Value.OfStringArray((List<String>) value);
|
||||
};
|
||||
}
|
||||
|
||||
record OfLong(long value) implements Value {
|
||||
public String name() { return "integer"; }
|
||||
|
||||
@Override
|
||||
public long asLong(@NotNull String key) { return value; }
|
||||
|
||||
@Override
|
||||
public Object asObject() { return value; }
|
||||
}
|
||||
record OfDouble(double value) implements Value {
|
||||
public String name() { return "float"; }
|
||||
|
||||
@Override
|
||||
public double asDouble(@NotNull String key) { return value; }
|
||||
|
||||
@Override
|
||||
public Object asObject() { return value; }
|
||||
}
|
||||
record OfString(@NotNull String value) implements Value {
|
||||
public OfString { Objects.requireNonNull(value); }
|
||||
|
||||
public String name() { return "string"; }
|
||||
|
||||
@Override
|
||||
public String asString(@NotNull String key) { return value; }
|
||||
|
||||
@Override
|
||||
public Object asObject() { return value; }
|
||||
}
|
||||
record OfBoolean(boolean value) implements Value {
|
||||
public String name() { return "boolean"; }
|
||||
|
||||
@Override
|
||||
public boolean asBoolean(@NotNull String key) { return value; }
|
||||
|
||||
@Override
|
||||
public Object asObject() { return value; }
|
||||
}
|
||||
record OfStringArray(@NotNull List<@NotNull String> value) implements Value {
|
||||
public OfStringArray { value = List.copyOf(value); }
|
||||
public String name() { return "array"; }
|
||||
|
||||
@Override
|
||||
public List<String> asArray(@NotNull String key) { return value; }
|
||||
|
||||
@Override
|
||||
public Object asObject() { return value; }
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
package eu.jonahbauer.chat.bot.config;
|
||||
|
||||
import lombok.experimental.StandardException;
|
||||
|
||||
@StandardException
|
||||
public class BotConfigurationException extends RuntimeException {
|
||||
}
|
6
bot-api/src/config/java/module-info.java
Normal file
6
bot-api/src/config/java/module-info.java
Normal 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;
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
package eu.jonahbauer.chat.bot.api;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public interface Chat {
|
||||
boolean send(@NotNull String channel, @NotNull String name, @NotNull String message, boolean bottag, boolean publicId);
|
||||
}
|
@@ -0,0 +1,61 @@
|
||||
package eu.jonahbauer.chat.bot.api;
|
||||
|
||||
import eu.jonahbauer.chat.bot.config.BotConfig;
|
||||
import eu.jonahbauer.chat.bot.impl.ChatBotFactory;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.jetbrains.annotations.ApiStatus;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@Getter
|
||||
@Accessors(makeFinal = true)
|
||||
public abstract class ChatBot {
|
||||
private static final ScopedValue<String> CHANNEL = ScopedValue.newInstance();
|
||||
private static final ScopedValue<Chat> CHAT = ScopedValue.newInstance();
|
||||
|
||||
protected final @NotNull BotConfig config;
|
||||
|
||||
protected ChatBot(@NotNull String name) {
|
||||
this(BotConfig.builder().name(name).build());
|
||||
}
|
||||
|
||||
protected ChatBot(@NotNull BotConfig defaultConfig) {
|
||||
var config = ChatBotFactory.BOT_CONFIG
|
||||
.orElseThrow(() -> new IllegalCallerException("ChatBot may only be instantiated via ChatBotFactory."));
|
||||
this.config = defaultConfig.merge(config);
|
||||
}
|
||||
|
||||
public final void onMessage(@NotNull Chat chat, @NotNull Message.Post message) {
|
||||
ScopedValue
|
||||
.where(CHANNEL, message.channel())
|
||||
.where(CHAT, chat)
|
||||
.run(() -> onMessage(message));
|
||||
}
|
||||
|
||||
@ApiStatus.OverrideOnly
|
||||
protected abstract void onMessage(@NotNull Message.Post message);
|
||||
|
||||
/**
|
||||
* Called during the normal shutdown process for a bot.
|
||||
*/
|
||||
@ApiStatus.OverrideOnly
|
||||
public void onStop() {}
|
||||
|
||||
protected void post(@NotNull String message) {
|
||||
post(message, null, null, null);
|
||||
}
|
||||
|
||||
protected void post(@NotNull String message, @Nullable String name, @Nullable Boolean bottag, @Nullable Boolean publicId) {
|
||||
Objects.requireNonNull(message, "message");
|
||||
name = Objects.requireNonNullElse(name, this.config.getName());
|
||||
bottag = Objects.requireNonNullElse(bottag, this.config.isBotTag());
|
||||
publicId = Objects.requireNonNullElse(publicId, this.config.isPublicId());
|
||||
|
||||
var chat = CHAT.orElseThrow(() -> new IllegalStateException("post() may only be called from inside onMessage(Post)"));
|
||||
var channel = CHANNEL.orElseThrow(() -> new IllegalStateException("post() may only be called from inside onMessage(Post)"));
|
||||
chat.send(channel, name, message, bottag, publicId);
|
||||
}
|
||||
}
|
@@ -0,0 +1,51 @@
|
||||
package eu.jonahbauer.chat.bot.api;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
|
||||
@JsonTypeInfo(
|
||||
use = JsonTypeInfo.Id.NAME,
|
||||
property = "type"
|
||||
)
|
||||
@JsonSubTypes({
|
||||
@JsonSubTypes.Type(value = Message.Ping.class, name = "ping"),
|
||||
@JsonSubTypes.Type(value = Message.Pong.class, name = "pong"),
|
||||
@JsonSubTypes.Type(value = Message.Ack.class, name = "ack"),
|
||||
@JsonSubTypes.Type(value = Message.Post.class, name = "post")
|
||||
})
|
||||
public sealed interface Message {
|
||||
Ping PING = new Ping();
|
||||
|
||||
record Ping() implements Message { }
|
||||
|
||||
record Pong() implements Message { }
|
||||
|
||||
record Ack() implements Message { }
|
||||
|
||||
record Post(
|
||||
long id,
|
||||
@NotNull String name,
|
||||
@NotNull String message,
|
||||
@NotNull String channel,
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@NotNull LocalDateTime date,
|
||||
@Nullable Long delay,
|
||||
@JsonProperty("user_id")
|
||||
@Nullable Long userId,
|
||||
@JsonProperty("username")
|
||||
@Nullable String userName,
|
||||
@NotNull String color,
|
||||
int bottag
|
||||
) implements Message {
|
||||
public boolean bot() {
|
||||
return bottag != 0;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
package eu.jonahbauer.chat.bot.impl;
|
||||
|
||||
import lombok.experimental.StandardException;
|
||||
|
||||
@StandardException
|
||||
public class BotCreationException extends RuntimeException {
|
||||
}
|
@@ -0,0 +1,57 @@
|
||||
package eu.jonahbauer.chat.bot.impl;
|
||||
|
||||
import eu.jonahbauer.chat.bot.api.ChatBot;
|
||||
import eu.jonahbauer.chat.bot.config.BotConfig;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.TestOnly;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.ServiceLoader;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public final class ChatBotFactory<T extends ChatBot> {
|
||||
public static final ScopedValue<BotConfig> BOT_CONFIG = ScopedValue.newInstance();
|
||||
private static final ServiceLoader<ChatBot> SERVICE_LOADER = ServiceLoader.load(ChatBot.class);
|
||||
|
||||
private final @NotNull Class<T> type;
|
||||
private final @NotNull Supplier<T> delegate;
|
||||
|
||||
public static @NotNull Set<@NotNull Class<? extends ChatBot>> implementations() {
|
||||
return SERVICE_LOADER.stream().map(ServiceLoader.Provider::type).collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public ChatBotFactory(@NotNull Class<T> type) {
|
||||
if (!ChatBot.class.isAssignableFrom(type)) throw new IllegalArgumentException();
|
||||
|
||||
this.type = type;
|
||||
this.delegate = (Supplier<T>) SERVICE_LOADER.stream()
|
||||
.filter(p -> p.type().equals(type)).findFirst()
|
||||
.orElseThrow(() -> new BotCreationException("No suitable provider found: " + type.getName()));
|
||||
}
|
||||
|
||||
@TestOnly
|
||||
ChatBotFactory(@NotNull Class<T> type, @NotNull Supplier<T> supplier) {
|
||||
if (!ChatBot.class.isAssignableFrom(type)) throw new IllegalArgumentException();
|
||||
this.type = type;
|
||||
this.delegate = Objects.requireNonNull(supplier, "supplier");
|
||||
}
|
||||
|
||||
public @NotNull Class<T> type() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public @NotNull T create() {
|
||||
return create(BotConfig.EMPTY);
|
||||
}
|
||||
|
||||
public @NotNull T create(@NotNull BotConfig config) {
|
||||
try {
|
||||
return ScopedValue.where(BOT_CONFIG, config).get(delegate);
|
||||
} catch (Throwable t) {
|
||||
throw new BotCreationException("Exception in constructor: " + type.getName(), t);
|
||||
}
|
||||
}
|
||||
}
|
14
bot-api/src/main/java/module-info.java
Normal file
14
bot-api/src/main/java/module-info.java
Normal 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;
|
||||
}
|
@@ -0,0 +1,53 @@
|
||||
package eu.jonahbauer.chat.bot.api;
|
||||
|
||||
import eu.jonahbauer.chat.bot.config.BotConfig;
|
||||
import eu.jonahbauer.chat.bot.impl.ChatBotFactory;
|
||||
import eu.jonahbauer.chat.bot.impl.ChatBotFactoryAccess;
|
||||
import eu.jonahbauer.chat.bot.test.MockChat;
|
||||
import eu.jonahbauer.chat.bot.test.TestChatBot;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
public class ChatBotTest {
|
||||
private static ChatBotFactory<TestChatBot> factory;
|
||||
|
||||
@BeforeAll
|
||||
static void init() {
|
||||
factory = ChatBotFactoryAccess.newInstance(TestChatBot.class, TestChatBot::new);
|
||||
}
|
||||
|
||||
@Test
|
||||
void postUsesDefaultValuesFromBotConfig() {
|
||||
var chat = new MockChat();
|
||||
var post = new Message.Post(0, "Test", "Hello World!", "", LocalDateTime.MIN, 0L, null, null, "ffffff", 0);
|
||||
var post2 = new Message.Post(0, "Test", "Hello World!", "test", LocalDateTime.MIN, 0L, null, null, "ffffff", 0);
|
||||
|
||||
var bot = factory.create(BotConfig.EMPTY);
|
||||
bot.setMessage("Hello, TestChatBot#1");
|
||||
bot.onMessage(chat, post);
|
||||
bot.onMessage(chat, post2);
|
||||
|
||||
var bot2 = factory.create(BotConfig.builder().name("TestChatBot#2").noBotTag().noPublicId().build());
|
||||
bot2.setMessage("Hello, TestChatBot#2");
|
||||
bot2.onMessage(chat, post);
|
||||
bot2.onMessage(chat, post2);
|
||||
|
||||
var messages = chat.getMessages();
|
||||
assertEquals(4, messages.size());
|
||||
assertEquals(new MockChat.Message("", "TestBot", "Hello, TestChatBot#1", true, true), messages.get(0));
|
||||
assertEquals(new MockChat.Message("test", "TestBot", "Hello, TestChatBot#1", true, true), messages.get(1));
|
||||
assertEquals(new MockChat.Message("", "TestChatBot#2", "Hello, TestChatBot#2", false, false), messages.get(2));
|
||||
assertEquals(new MockChat.Message("test", "TestChatBot#2", "Hello, TestChatBot#2", false, false), messages.get(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void postMayNotBeInvokedOutsideOfOnMessage() {
|
||||
var bot = factory.create(BotConfig.EMPTY);
|
||||
assertThrows(IllegalStateException.class, () -> bot.post("Hello, World!"));
|
||||
}
|
||||
}
|
@@ -0,0 +1,116 @@
|
||||
package eu.jonahbauer.chat.bot.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalDouble;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
public class BotConfigTest {
|
||||
|
||||
@Test
|
||||
void buildAndAccessWithStringKeys() {
|
||||
BotConfig config = BotConfig.builder()
|
||||
.value("string", "value")
|
||||
.value("long", Long.MAX_VALUE)
|
||||
.value("double", Double.MAX_VALUE)
|
||||
.value("boolean", true)
|
||||
.value("array", List.of("foo", "bar"))
|
||||
.build();
|
||||
|
||||
assertEquals(Optional.of("value"), config.getString("string"));
|
||||
assertEquals(OptionalLong.of(Long.MAX_VALUE), config.getLong("long"));
|
||||
assertEquals(OptionalDouble.of(Double.MAX_VALUE), config.getDouble("double"));
|
||||
assertEquals(Optional.of(true), config.getBoolean("boolean"));
|
||||
assertEquals(Optional.of(List.of("foo", "bar")), config.getStringArray("array"));
|
||||
|
||||
assertThrows(BotConfigurationException.class, () -> config.getLong("string"));
|
||||
assertThrows(BotConfigurationException.class, () -> config.getDouble("string"));
|
||||
assertThrows(BotConfigurationException.class, () -> config.getBoolean("string"));
|
||||
assertThrows(BotConfigurationException.class, () -> config.getStringArray("string"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void accessUninitializedProperty() {
|
||||
var unset = BotConfig.Key.ofString("unset");
|
||||
var config = BotConfig.EMPTY;
|
||||
|
||||
assertEquals(Optional.empty(), config.get(unset));
|
||||
assertEquals(Optional.empty(), config.getString("unset"));
|
||||
assertEquals(OptionalLong.empty(), config.getLong("unset"));
|
||||
assertEquals(OptionalDouble.empty(), config.getDouble("unset"));
|
||||
assertEquals(Optional.empty(), config.getBoolean("unset"));
|
||||
assertEquals(Optional.empty(), config.getStringArray("unset"));
|
||||
|
||||
assertThrows(BotConfigurationException.class, () -> config.require(unset));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildAndAccessWithTypedKeys() {
|
||||
var string = BotConfig.Key.ofString("string");
|
||||
var long_ = BotConfig.Key.ofLong("long");
|
||||
var double_ = BotConfig.Key.ofDouble("double");
|
||||
var boolean_ = BotConfig.Key.ofBoolean("boolean");
|
||||
var array = BotConfig.Key.ofStringArray("array");
|
||||
|
||||
var config = BotConfig.builder()
|
||||
.value(string, "value")
|
||||
.value(long_, Long.MAX_VALUE)
|
||||
.value(double_, Double.MAX_VALUE)
|
||||
.value(boolean_, true)
|
||||
.value(array, List.of("foo", "bar"))
|
||||
.build();
|
||||
|
||||
assertEquals(Optional.of("value"), config.getString("string"));
|
||||
assertEquals(OptionalLong.of(Long.MAX_VALUE), config.getLong("long"));
|
||||
assertEquals(OptionalDouble.of(Double.MAX_VALUE), config.getDouble("double"));
|
||||
assertEquals(Optional.of(true), config.getBoolean("boolean"));
|
||||
assertEquals(Optional.of(List.of("foo", "bar")), config.getStringArray("array"));
|
||||
|
||||
assertEquals(Optional.of("value"), config.get(string));
|
||||
assertEquals(OptionalLong.of(Long.MAX_VALUE), config.get(long_));
|
||||
assertEquals(OptionalDouble.of(Double.MAX_VALUE), config.get(double_));
|
||||
assertEquals(Optional.of(true), config.get(boolean_));
|
||||
assertEquals(Optional.of(List.of("foo", "bar")), config.get(array));
|
||||
|
||||
assertEquals("value", config.require(string));
|
||||
assertEquals(Long.MAX_VALUE, config.require(long_));
|
||||
assertEquals(Double.MAX_VALUE, config.require(double_));
|
||||
assertEquals(true, config.require(boolean_));
|
||||
assertEquals(List.of("foo", "bar"), config.require(array));
|
||||
}
|
||||
|
||||
@Test
|
||||
void merge() {
|
||||
var a = BotConfig.builder()
|
||||
.value("a", "a")
|
||||
.value("c", "a")
|
||||
.build();
|
||||
|
||||
var b = BotConfig.builder()
|
||||
.value("b", "b")
|
||||
.value("c", "b")
|
||||
.build();
|
||||
|
||||
var c = a.merge(b);
|
||||
|
||||
// a remains unchanged
|
||||
assertEquals(Optional.of("a"), a.getString("a"));
|
||||
assertEquals(Optional.of("a"), a.getString("c"));
|
||||
assertFalse(a.has("b"));
|
||||
|
||||
// b remains unchanged
|
||||
assertEquals(Optional.of("b"), b.getString("b"));
|
||||
assertEquals(Optional.of("b"), b.getString("c"));
|
||||
assertFalse(b.has("a"));
|
||||
|
||||
// c contains a's and b's values
|
||||
assertEquals(Optional.of("a"), c.getString("a"));
|
||||
assertEquals(Optional.of("b"), c.getString("b"));
|
||||
assertEquals(Optional.of("b"), c.getString("c"));
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,14 @@
|
||||
package eu.jonahbauer.chat.bot.impl;
|
||||
|
||||
import eu.jonahbauer.chat.bot.api.ChatBot;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@UtilityClass
|
||||
public class ChatBotFactoryAccess {
|
||||
public static <T extends ChatBot> @NotNull ChatBotFactory<T> newInstance(@NotNull Class<T> type, @NotNull Supplier<T> supplier) {
|
||||
return new ChatBotFactory<>(type, supplier);
|
||||
}
|
||||
}
|
@@ -0,0 +1,29 @@
|
||||
package eu.jonahbauer.chat.bot.impl;
|
||||
|
||||
import eu.jonahbauer.chat.bot.test.TestChatBot;
|
||||
import eu.jonahbauer.chat.bot.config.BotConfig;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
public class ChatBotFactoryTest {
|
||||
|
||||
@Test
|
||||
void botsCanOnlyBeInstantiatedUsingChatBotFactory() {
|
||||
assertThrows(IllegalCallerException.class, TestChatBot::new);
|
||||
assertNotNull(new ChatBotFactory<>(TestChatBot.class, TestChatBot::new).create(BotConfig.EMPTY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void suppliedConfigOverridesDefaultConfig() {
|
||||
var factory = new ChatBotFactory<>(TestChatBot.class, TestChatBot::new);
|
||||
|
||||
var bot = factory.create(BotConfig.EMPTY);
|
||||
assertEquals(TestChatBot.DEFAULT_CONFIG, bot.getConfig());
|
||||
|
||||
var overridden = factory.create(BotConfig.builder().name("Overridden").value(TestChatBot.TEST, "overridden").build());
|
||||
assertEquals("Overridden", overridden.getConfig().require(BotConfig.NAME));
|
||||
assertEquals("overridden", overridden.getConfig().require(TestChatBot.TEST));
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
package eu.jonahbauer.chat.bot.test;
|
||||
|
||||
import eu.jonahbauer.chat.bot.api.Chat;
|
||||
import lombok.Getter;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
public class MockChat implements Chat {
|
||||
private final List<Message> messages = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public boolean send(@NotNull String channel, @NotNull String name, @NotNull String message, boolean bottag, boolean publicId) {
|
||||
messages.add(new Message(channel, name, message, bottag, publicId));
|
||||
return true;
|
||||
}
|
||||
|
||||
public record Message(String channel, String name, String message, boolean bottag, boolean publicId) {}
|
||||
}
|
@@ -0,0 +1,26 @@
|
||||
package eu.jonahbauer.chat.bot.test;
|
||||
|
||||
import eu.jonahbauer.chat.bot.api.ChatBot;
|
||||
import eu.jonahbauer.chat.bot.api.Message;
|
||||
import eu.jonahbauer.chat.bot.config.BotConfig;
|
||||
import lombok.Setter;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
@Setter
|
||||
public class TestChatBot extends ChatBot {
|
||||
public static final BotConfig.Key.OfString TEST = BotConfig.Key.ofString("test");
|
||||
public static final BotConfig DEFAULT_CONFIG = BotConfig.builder()
|
||||
.name("TestBot").value(TEST, "test")
|
||||
.build();
|
||||
|
||||
private String message;
|
||||
|
||||
public TestChatBot() {
|
||||
super(DEFAULT_CONFIG);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onMessage(Message.@NotNull Post message) {
|
||||
post(this.message);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user