Compare commits

...

10 Commits

@ -0,0 +1,50 @@
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### Git ###
.git
.gitignore
### Docker ###
.dockerignore
Dockerfile
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store

@ -0,0 +1,33 @@
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /root/app
# Download Gradle
COPY ./gradle ./gradle
COPY ./gradlew ./
RUN ./gradlew --no-daemon --info --stacktrace
# Download Dependencies
COPY ./buildSrc ./buildSrc
COPY ./settings.gradle.kts ./
COPY ./bot-api/build.gradle.kts ./bot-api/
COPY ./bot-database/build.gradle.kts ./bot-database/
COPY ./management/build.gradle.kts ./management/
COPY ./server/build.gradle.kts ./server/
COPY ./bots/ping-bot/build.gradle.kts ./bots/ping-bot/
COPY ./bots/pizza-bot/build.gradle.kts ./bots/pizza-bot/
RUN ./gradlew deps --no-daemon --info --stacktrace
# Build Application
COPY . .
RUN ./gradlew build :server:jlink --no-daemon --info --stacktrace
FROM alpine:3.19.1
WORKDIR /opt/chat/
COPY --from=build /root/app/server/build/dist/jre /opt/chat/
ENV JAVA_HOME="/opt/chat"
ENV JAVA_OPTS="--enable-preview"
ENV CHAT_BOT_CONFIG="file:/etc/chat/config.json"
ENTRYPOINT $JAVA_HOME/bin/java $JAVA_OPTS --module eu.jonahbauer.chat.server

@ -1,10 +1,10 @@
plugins { plugins {
id("java-library") id("java-library")
id("java-test-fixtures")
id("chat-bot.java-conventions") id("chat-bot.java-conventions")
} }
group = "eu.jonahbauer.chat" group = "eu.jonahbauer.chat"
version = "0.1.0-SNAPSHOT"
sourceSets { sourceSets {
create("config") { create("config") {
@ -20,21 +20,21 @@ java {
} }
} }
val configApi by configurations
val configCompileOnly by configurations val configCompileOnly by configurations
val configCompileOnlyApi by configurations
val configAnnotationProcessor by configurations val configAnnotationProcessor by configurations
dependencies { dependencies {
api(libs.annotations) compileOnlyApi(libs.annotations)
api(project(path)) { api(project(path)) {
capabilities { capabilities {
requireCapability("${project.group}:${project.name}-config") requireCapability("${project.group}:${project.name}-config")
} }
} }
implementation(libs.jackson.annotations) implementation(libs.gson)
configApi(libs.annotations)
configCompileOnly(libs.lombok) configCompileOnly(libs.lombok)
configCompileOnlyApi(libs.annotations)
configAnnotationProcessor(libs.lombok) configAnnotationProcessor(libs.lombok)
} }

@ -1,25 +1,12 @@
package eu.jonahbauer.chat.bot.api; package eu.jonahbauer.chat.bot.api;
import com.fasterxml.jackson.annotation.JsonFormat; import com.google.gson.annotations.SerializedName;
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.NotNull;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import java.time.LocalDateTime; 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 { public sealed interface Message {
Ping PING = new Ping(); Ping PING = new Ping();
@ -34,12 +21,11 @@ public sealed interface Message {
@NotNull String name, @NotNull String name,
@NotNull String message, @NotNull String message,
@NotNull String channel, @NotNull String channel,
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
@NotNull LocalDateTime date, @NotNull LocalDateTime date,
@Nullable Long delay, @Nullable Long delay,
@JsonProperty("user_id") @SerializedName("user_id")
@Nullable Long userId, @Nullable Long userId,
@JsonProperty("username") @SerializedName("username")
@Nullable String userName, @Nullable String userName,
@NotNull String color, @NotNull String color,
int bottag int bottag

@ -2,12 +2,12 @@ import eu.jonahbauer.chat.bot.api.ChatBot;
module eu.jonahbauer.chat.bot.api { module eu.jonahbauer.chat.bot.api {
exports eu.jonahbauer.chat.bot.api; exports eu.jonahbauer.chat.bot.api;
exports eu.jonahbauer.chat.bot.impl to eu.jonahbauer.chat.server; exports eu.jonahbauer.chat.bot.impl to eu.jonahbauer.chat.server, eu.jonahbauer.chat.bot.api.fixtures;
requires transitive eu.jonahbauer.chat.bot.config; requires transitive eu.jonahbauer.chat.bot.config;
requires static transitive org.jetbrains.annotations; requires static transitive org.jetbrains.annotations;
requires static com.fasterxml.jackson.annotation; requires static com.google.gson;
requires static lombok; requires static lombok;
uses ChatBot; uses ChatBot;

@ -0,0 +1,21 @@
package eu.jonahbauer.chat.bot.test;
import eu.jonahbauer.chat.bot.api.ChatBot;
import eu.jonahbauer.chat.bot.config.BotConfig;
import eu.jonahbauer.chat.bot.impl.ChatBotFactory;
import org.jetbrains.annotations.NotNull;
import java.util.function.Supplier;
public final class ChatBotFactoryAccess {
private ChatBotFactoryAccess() {}
public static <T extends ChatBot> T create(@NotNull BotConfig config, @NotNull Supplier<T> supplier) {
return ScopedValue.where(ChatBotFactory.BOT_CONFIG, config).get(supplier);
}
public static <T extends ChatBot> T create(@NotNull Supplier<T> supplier) {
return create(BotConfig.EMPTY, supplier);
}
}

@ -0,0 +1,27 @@
package eu.jonahbauer.chat.bot.test;
import eu.jonahbauer.chat.bot.api.Chat;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
public class MockChat implements Chat {
private final @NotNull List<@NotNull 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 @NotNull List<@NotNull Message> getMessages() {
return messages;
}
public record Message(@NotNull String channel, @NotNull String name, @NotNull String message, boolean bottag, boolean publicId) {
public Message(@NotNull String channel, @NotNull String name, @NotNull String message) {
this(channel, name, message, true, true);
}
}
}

@ -0,0 +1,7 @@
module eu.jonahbauer.chat.bot.api.fixtures {
exports eu.jonahbauer.chat.bot.test;
requires eu.jonahbauer.chat.bot.api;
requires eu.jonahbauer.chat.bot.config;
requires static transitive org.jetbrains.annotations;
}

@ -0,0 +1,13 @@
plugins {
id("java-library")
id("chat-bot.java-conventions")
}
group = "eu.jonahbauer.chat"
dependencies {
compileOnlyApi(libs.annotations)
implementation(libs.sqlite)
implementation(libs.hikari)
implementation(libs.slf4j)
}

@ -0,0 +1,9 @@
package eu.jonahbauer.chat.bot.database;
import lombok.experimental.StandardException;
import java.sql.SQLException;
@StandardException
public class BindException extends SQLException {
}

@ -0,0 +1,310 @@
package eu.jonahbauer.chat.bot.database;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import eu.jonahbauer.chat.bot.database.impl.Binder;
import eu.jonahbauer.chat.bot.database.impl.Helper;
import eu.jonahbauer.chat.bot.database.impl.Inserter;
import eu.jonahbauer.chat.bot.database.impl.Updater;
import eu.jonahbauer.chat.bot.database.impl.types.TypeAdapter;
import lombok.Lombok;
import lombok.SneakyThrows;
import org.intellij.lang.annotations.Language;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
@SuppressWarnings({"SqlSourceToSinkFlow", "unused"})
public class Database implements AutoCloseable {
private static final ScopedValue<Connection> CONNECTION = ScopedValue.newInstance();
private final @NotNull HikariDataSource ds;
public Database(@NotNull String url) {
this(url, null, null);
}
public Database(@NotNull String url, @Nullable String user, @Nullable String password) {
var hikariConfig = new HikariConfig();
hikariConfig.setJdbcUrl(url);
hikariConfig.setUsername(user);
hikariConfig.setPassword(password);
hikariConfig.addDataSourceProperty("enforceForeignKeys", true);
this.ds = new HikariDataSource(hikariConfig);
}
@SuppressWarnings("RedundantThrows")
public <T extends Throwable> void transaction(@NotNull ThrowingRunnable<T> runnable) throws SQLException, T {
if (CONNECTION.isBound()) throw new IllegalStateException("nested transactions are not supported");
try (var connection = getConnection0()) {
connection.setAutoCommit(false);
try {
ScopedValue.runWhere(CONNECTION, connection, () -> {
try {
runnable.run();
} catch (Throwable t) {
throw Lombok.sneakyThrow(t);
}
});
connection.commit();
} catch (Throwable t) {
connection.rollback();
throw t;
}
}
}
@SuppressWarnings("RedundantThrows")
public <R, T extends Throwable> R transaction(@NotNull ThrowingCallable<R, T> callable) throws SQLException, T {
if (CONNECTION.isBound()) throw new IllegalStateException("nested transactions are not supported");
try (var connection = getConnection0()) {
connection.setAutoCommit(false);
try {
var result = ScopedValue.callWhere(CONNECTION, connection, () -> {
try {
return callable.call();
} catch (Throwable t) {
throw Lombok.sneakyThrow(t);
}
});
connection.commit();
return result;
} catch (Throwable t) {
connection.rollback();
throw Lombok.sneakyThrow(t);
}
}
}
public <T extends Record> @NotNull List<T> findAll(@NotNull Class<T> type) throws SQLException {
return executeQuery(type, "SELECT * FROM " + Helper.getQuotedTableName(type));
}
public <T extends Record & Entity<T>> @NotNull Optional<T> findById(@NotNull Class<T> type, long id) throws SQLException {
return findWhere(type, "id", id);
}
@SuppressWarnings("Convert2MethodRef")
public <T extends Record> @NotNull List<T> findAllWhere(@NotNull Class<T> type, @NotNull String component, Object value) throws SQLException {
return findWhere0(type, component, value, (t, query, customizer) -> executeQuery(t, query, customizer));
}
@SuppressWarnings("Convert2MethodRef")
public <T extends Record> @NotNull Optional<T> findWhere(@NotNull Class<T> type, @NotNull String component, Object value) throws SQLException {
return findWhere0(type, component, value, (t, query, customizer) -> executeUniqueQuery(t, query, customizer));
}
@SneakyThrows(NoSuchMethodException.class)
private <T extends Record, R> R findWhere0(@NotNull Class<T> type, @NotNull String component, Object value, @NotNull FindWhere<T, R> finder) throws SQLException {
var table = Helper.getQuotedTableName(type);
var column = Helper.getColumnName(type, component);
var componentType = type.getMethod(component).getReturnType();
var adapter = TypeAdapter.get(componentType);
return finder.find(
type,
"SELECT * FROM " + table + " WHERE " + column + " " + (value == null ? "IS NULL" : "= ?"),
stmt -> {
if (value != null) adapter.prepareUnsafe(stmt, 1, value);
}
);
}
private interface FindWhere<T extends Record, R> {
R find(@NotNull Class<T> type, @Language("SQL") @NotNull String query, @NotNull ThrowingConsumer<PreparedStatement, SQLException> customizer) throws SQLException;
}
public <T extends Record> @NotNull List<T> bindAll(@NotNull Class<T> type, @NotNull ResultSet result) throws SQLException {
return bindAll(type, result, "");
}
public <T extends Record> @NotNull List<T> bindAll(@NotNull Class<T> type, @NotNull ResultSet result, @NotNull String prefix) throws SQLException {
var out = new ArrayList<T>();
var binder = Binder.get(type);
while (result.next()) {
out.add(binder.bind(result, prefix));
}
return Collections.unmodifiableList(out);
}
public <T extends Record> @NotNull Optional<T> bind(@NotNull Class<T> type, @NotNull ResultSet result) throws SQLException {
return bind(type, result, "");
}
public <T extends Record> @NotNull Optional<T> bind(@NotNull Class<T> type, @NotNull ResultSet result, @NotNull String prefix) throws SQLException {
if (result.next()) {
var binder = Binder.get(type);
var out = Optional.of(binder.bind(result, prefix));
if (result.next()) throw new NonUniqueResultException();
return out;
} else {
return Optional.empty();
}
}
@SuppressWarnings("unchecked")
public <T extends Record & Entity<T>> boolean delete(@NotNull T entity) throws SQLException {
return delete((Class<T>) entity.getClass(), entity.id());
}
public <T extends Record & Entity<T>> boolean delete(@NotNull Class<T> type, long id) throws SQLException {
return 1 == executeUpdate("DELETE FROM " + Helper.getQuotedTableName(type) + " WHERE id = ?", stmt -> stmt.setLong(1, id));
}
public long insert(@NotNull Record record) throws SQLException {
return insert0(record);
}
private <T extends Record> long insert0(@NotNull T record) throws SQLException {
@SuppressWarnings("unchecked")
var type = (Class<T>) record.getClass();
var connection = getConnection0();
try {
return Inserter.get(type).insert(connection, record);
} finally {
close(connection);
}
}
public <T extends Record> T save(@NotNull T record) throws SQLException {
@SuppressWarnings("unchecked")
var type = (Class<T>) record.getClass();
var connection = getConnection0();
try {
var id = Inserter.get(type).insert(connection, record);
if (record instanceof Entity<?> entity) {
return (T) entity.withId(id);
} else {
return record;
}
} finally {
close(connection);
}
}
public <T extends Record & Entity<T>> boolean update(@NotNull T entity) throws SQLException {
@SuppressWarnings("unchecked")
var type = (Class<T>) entity.getClass();
var connection = getConnection0();
try {
return Updater.get(type).update(connection, entity);
} finally {
close(connection);
}
}
@SuppressWarnings("SqlWithoutWhere")
public int truncate(@NotNull Class<? extends Record> type) throws SQLException {
return executeUpdate("DELETE FROM " + Helper.getQuotedTableName(type));
}
public <T extends Record> @NotNull List<T> executeQuery(@NotNull Class<T> type, @NotNull @Language("SQL") String query) throws SQLException {
var connection = getConnection0();
try (var stmt = connection.createStatement()) {
Helper.logSqlStatement(query);
try (var result = stmt.executeQuery(query)) {
return bindAll(type, result);
}
} finally {
close(connection);
}
}
public <R extends Record, T extends Throwable> @NotNull List<R> executeQuery(@NotNull Class<R> type, @NotNull @Language("SQL") String query, @NotNull ThrowingConsumer<@NotNull PreparedStatement, T> customizer) throws SQLException, T {
var connection = getConnection0();
Helper.logSqlStatement(query);
try (var stmt = connection.prepareStatement(query)) {
customizer.accept(stmt);
try (var result = stmt.executeQuery()) {
return bindAll(type, result);
}
} finally {
close(connection);
}
}
public <T extends Record> @NotNull Optional<T> executeUniqueQuery(@NotNull Class<T> type, @NotNull @Language("SQL") String query) throws SQLException {
var connection = getConnection0();
try (var stmt = connection.createStatement()) {
Helper.logSqlStatement(query);
try (var result = stmt.executeQuery(query)) {
return bind(type, result);
}
} finally {
close(connection);
}
}
public <R extends Record, T extends Throwable> @NotNull Optional<R> executeUniqueQuery(@NotNull Class<R> type, @NotNull @Language("SQL") String query, @NotNull ThrowingConsumer<@NotNull PreparedStatement, T> customizer) throws SQLException, T {
var connection = getConnection0();
Helper.logSqlStatement(query);
try (var stmt = connection.prepareStatement(query)) {
customizer.accept(stmt);
try (var result = stmt.executeQuery()) {
return bind(type, result);
}
} finally {
close(connection);
}
}
public int executeUpdate(@NotNull @Language("SQL") String query) throws SQLException {
var connection = getConnection0();
try (var stmt = connection.createStatement()) {
Helper.logSqlStatement(query);
return stmt.executeUpdate(query);
} finally {
close(connection);
}
}
public <T extends Throwable> int executeUpdate(@NotNull @Language("SQL") String query, @NotNull ThrowingConsumer<@NotNull PreparedStatement, T> customizer) throws SQLException, T {
var connection = getConnection0();
Helper.logSqlStatement(query);
try (var stmt = connection.prepareStatement(query)) {
customizer.accept(stmt);
return stmt.executeUpdate();
} finally {
close(connection);
}
}
public @NotNull Connection getConnection() throws SQLException {
return ds.getConnection();
}
private @NotNull Connection getConnection0() throws SQLException {
return CONNECTION.isBound() ? CONNECTION.get() : ds.getConnection();
}
void close(@NotNull Connection connection) throws SQLException {
if (!CONNECTION.isBound() || CONNECTION.get() != connection) connection.close();
}
@Override
public void close() {
ds.close();
}
public interface ThrowingCallable<R, T extends Throwable> {
R call() throws T;
}
public interface ThrowingRunnable<T extends Throwable> {
void run() throws T;
}
public interface ThrowingConsumer<S, T extends Throwable> {
void accept(S object) throws T;
}
}

@ -0,0 +1,12 @@
package eu.jonahbauer.chat.bot.database;
import eu.jonahbauer.chat.bot.database.impl.Helper;
public interface Entity<T extends Record & Entity<T>> {
long id();
@SuppressWarnings("unchecked")
default T withId(long id) {
return Helper.getWithId((Class<T>) getClass()).withId((T) this, id);
}
}

@ -0,0 +1,9 @@
package eu.jonahbauer.chat.bot.database;
import lombok.experimental.StandardException;
import java.sql.SQLException;
@StandardException
public class NonUniqueResultException extends SQLException {
}

@ -0,0 +1,10 @@
package eu.jonahbauer.chat.bot.database.annotations;
import java.lang.annotation.*;
@Documented
@Target(ElementType.RECORD_COMPONENT)
@Retention(RetentionPolicy.RUNTIME)
public @interface Column {
String value();
}

@ -0,0 +1,10 @@
package eu.jonahbauer.chat.bot.database.annotations;
import java.lang.annotation.*;
@Documented
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Table {
String value();
}

@ -0,0 +1,19 @@
package eu.jonahbauer.chat.bot.database.impl;
import org.jetbrains.annotations.NotNull;
abstract class AbstractFactory<F> extends ClassValue<F> {
@Override
protected final @NotNull F computeValue(Class<?> type) {
if (!type.isRecord()) throw new IllegalArgumentException();
return bridge(type);
}
@SuppressWarnings("unchecked")
private <T extends Record> @NotNull F bridge(@NotNull Class<?> type) {
return computeValue0((Class<T>) type);
}
protected abstract <T extends Record> @NotNull F computeValue0(@NotNull Class<T> type);
}

@ -0,0 +1,19 @@
package eu.jonahbauer.chat.bot.database.impl;
import org.jetbrains.annotations.NotNull;
import java.sql.ResultSet;
import java.sql.SQLException;
public interface Binder<T extends Record> {
@SuppressWarnings("unchecked")
static <T extends Record> @NotNull Binder<T> get(@NotNull Class<T> type) {
return (Binder<T>) BinderFactory.INSTANCE.get(type);
}
default @NotNull T bind(@NotNull ResultSet set) throws SQLException {
return bind(set, "");
}
@NotNull T bind(@NotNull ResultSet set, @NotNull String prefix) throws SQLException;
}

@ -0,0 +1,67 @@
package eu.jonahbauer.chat.bot.database.impl;
import eu.jonahbauer.chat.bot.database.BindException;
import eu.jonahbauer.chat.bot.database.impl.types.TypeAdapter;
import lombok.SneakyThrows;
import org.jetbrains.annotations.NotNull;
import java.lang.reflect.Constructor;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
final class BinderFactory extends AbstractFactory<Binder<?>> {
public static final @NotNull BinderFactory INSTANCE = new BinderFactory();
private BinderFactory() {}
@Override
@SneakyThrows
protected @NotNull <T extends Record> Binder<T> computeValue0(@NotNull Class<T> type) {
var components = type.getRecordComponents();
var columns = new ArrayList<String>();
var adapters = new ArrayList<TypeAdapter<?>>();
var types = new ArrayList<Class<?>>();
for (var component : components) {
var componentType = component.getType();
types.add(componentType);
columns.add(Helper.getColumnName(component));
adapters.add(TypeAdapter.get(componentType));
}
var ctor = type.getDeclaredConstructor(types.toArray(Class[]::new));
return new BinderImpl<>(ctor, columns, adapters);
}
private static final class BinderImpl<T extends Record> implements Binder<T> {
private final @NotNull Constructor<T> constructor;
private final @NotNull List<@NotNull String> columns;
private final @NotNull List<@NotNull TypeAdapter<?>> adapters;
private BinderImpl(@NotNull Constructor<T> constructor, @NotNull List<@NotNull String> columns, @NotNull List<@NotNull TypeAdapter<?>> adapters) {
this.constructor = Objects.requireNonNull(constructor);
this.columns = List.copyOf(columns);
this.adapters = List.copyOf(adapters);
if (this.columns.size() != this.adapters.size()) throw new IllegalArgumentException();
}
@Override
public @NotNull T bind(@NotNull ResultSet result, @NotNull String prefix) throws SQLException {
try {
var count = columns.size();
var arguments = new Object[count];
for (int i = 0; i < count; i++) {
var index = result.findColumn(prefix + columns.get(i));
arguments[i] = adapters.get(i).extract(result, index);
}
return constructor.newInstance(arguments);
} catch (Exception ex) {
throw new BindException(ex);
}
}
}
}

@ -0,0 +1,173 @@
package eu.jonahbauer.chat.bot.database.impl;
import eu.jonahbauer.chat.bot.database.Entity;
import eu.jonahbauer.chat.bot.database.annotations.Column;
import eu.jonahbauer.chat.bot.database.annotations.Table;
import lombok.Lombok;
import lombok.SneakyThrows;
import lombok.experimental.UtilityClass;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.reflect.RecordComponent;
import java.util.HashMap;
import java.util.Map;
import static java.lang.invoke.MethodHandles.*;
import static java.lang.invoke.MethodType.methodType;
@UtilityClass
public class Helper {
public static @NotNull String getQuotedColumnName(@NotNull RecordComponent component) {
return quote(getColumnName(component));
}
public static @NotNull String getColumnName(@NotNull RecordComponent component) {
return getColumnName(component.getDeclaringRecord(), component.getName());
}
public static @NotNull String getColumnName(@NotNull Class<?> type, @NotNull String component) {
var out = columnNameCache.get(type).get(component);
if (out == null) throw new IllegalArgumentException("invalid component name " + component);
return out;
}
private static final @NotNull ClassValue<@NotNull Map<@NotNull String, @NotNull String>> columnNameCache = new ClassValue<>() {
@Override
protected @NotNull Map<@NotNull String, @NotNull String> computeValue(@NotNull Class<?> type) {
var out = new HashMap<String, String>();
for (var component : type.getRecordComponents()) {
var column = component.getAnnotation(Column.class);
if (column != null) {
out.put(component.getName(), column.value());
} else {
out.put(component.getName(), getDatabaseName(component.getName()));
}
}
return Map.copyOf(out);
}
};
public static @NotNull String getQuotedTableName(@NotNull Class<? extends Record> type) {
return tableNameCache.get(type);
}
private static final @NotNull ClassValue<@NotNull String> tableNameCache = new ClassValue<>() {
@Override
protected @NotNull String computeValue(@NotNull Class<?> type) {
var table = type.getAnnotation(Table.class);
if (table != null) {
return quote(table.value());
} else {
return quote(getDatabaseName(type.getSimpleName()));
}
}
};
public static boolean isIdColumn(@NotNull RecordComponent component) {
return Entity.class.isAssignableFrom(component.getDeclaringRecord())
&& component.getType() == long.class
&& "id".equals(component.getName());
}
@SuppressWarnings("unchecked")
public static <T extends Record & Entity<T>> WithId<T> getWithId(@NotNull Class<T> type) {
return (WithId<T>) withIdCache.get(type);
}
private static final @NotNull ClassValue<@NotNull WithId<?>> withIdCache = new ClassValue<>() {
@Override
@SneakyThrows
protected WithId<?> computeValue(Class<?> type) {
if (!type.isRecord()) throw new IllegalArgumentException();
if (!Entity.class.isAssignableFrom(type)) throw new IllegalArgumentException();
var lookup = MethodHandles.lookup();
var components = type.getRecordComponents();
var types = new Class<?>[components.length];
var accessors = new MethodHandle[components.length];
var reorder = new int[components.length];
for (int i = 0; i < components.length; i++) {
var component = components[i];
types[i] = component.getType();
if (isIdColumn(component)) {
accessors[i] = identity(long.class);
reorder[i] = 1;
} else {
accessors[i] = lookup.unreflect(component.getAccessor());
reorder[i] = 0;
}
}
var ctor = lookup.findConstructor(type, methodType(void.class, types));
var cloner = filterArguments(ctor, 0, accessors);
var wither = permuteArguments(cloner, methodType(type, type, long.class), reorder);
return create(wither.asType(methodType(Record.class, Record.class, long.class)));
}
@SuppressWarnings("unchecked")
private <T extends Record & Entity<T>> WithId<T> create(@NotNull MethodHandle handle) {
return (entity, id) -> {
try {
return (T) handle.invokeExact(entity, id);
} catch (Throwable t) {
throw Lombok.sneakyThrow(t);
}
};
}
};
private static final @NotNull Logger log = LoggerFactory.getLogger("eu.jonahbauer.chat.bot.database.SQL");
public static void logSqlStatement(@NotNull String sql) {
log.debug(sql);
}
private static @NotNull String getDatabaseName(@NotNull String name) {
if (name.length() <= 1) return name;
StringBuilder out = null;
for (int i = 1, length = name.length(); i < length; i++) {
var prev = name.charAt(i - 1);
var curr = name.charAt(i);
if (Character.isLowerCase(prev) && Character.isUpperCase(curr)) {
if (out == null) {
out = new StringBuilder(name.length() + 1);
out.append(name, 0, i);
out.setCharAt(0, Character.toLowerCase(out.charAt(0)));
}
out.append('_');
out.append(Character.toLowerCase(curr));
} else if (out != null) {
out.append(curr);
}
}
if (out == null) {
if (Character.isUpperCase(name.charAt(0))) {
return Character.toLowerCase(name.charAt(0)) + name.substring(1);
} else {
return name;
}
} else {
return out.toString();
}
}
public static @NotNull String quote(@NotNull String name) {
var out = new StringBuilder(name.length() + 2);
out.append('"');
for (int i = 0, length = name.length(); i < length; i++) {
var chr = name.charAt(i);
if (chr == '"') out.append('"');
if (chr == '\n') out.append('\\');
out.append(chr);
}
out.append('"');
return out.toString();
}
}

@ -0,0 +1,15 @@
package eu.jonahbauer.chat.bot.database.impl;
import org.jetbrains.annotations.NotNull;
import java.sql.Connection;
import java.sql.SQLException;
public interface Inserter<T extends Record> {
@SuppressWarnings("unchecked")
static <T extends Record> @NotNull Inserter<T> get(@NotNull Class<T> type) {
return (Inserter<T>) InserterFactory.INSTANCE.get(type);
}
long insert(@NotNull Connection connection, @NotNull T entity) throws SQLException;
}

@ -0,0 +1,39 @@
package eu.jonahbauer.chat.bot.database.impl;
import eu.jonahbauer.chat.bot.database.Entity;
import org.jetbrains.annotations.NotNull;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Collections;
final class InserterFactory extends AbstractFactory<@NotNull Inserter<?>> {
public static final @NotNull InserterFactory INSTANCE = new InserterFactory();
private InserterFactory() {}
@Override
protected <T extends Record> @NotNull Inserter<?> computeValue0(@NotNull Class<T> type) {
var isEntity = Entity.class.isAssignableFrom(type);
var table = Helper.getQuotedTableName(type);
var preparer = Preparer.get(type);
var columns = preparer.columns();
var query = STR."""
INSERT INTO \{table} (\{String.join(", ", columns)})
VALUES (\{String.join(", ", Collections.nCopies(columns.size(), "?"))})
\{isEntity ? "RETURNING id" : "RETURNING -1"}
""";
return (Connection connection, T entity) -> {
Helper.logSqlStatement(query);
try (var stmt = connection.prepareStatement(query)) {
preparer.prepare(stmt, entity);
try (var result = stmt.executeQuery()) {
if (!result.next()) throw new SQLException();
return result.getLong(1);
}
}
};
}
}

@ -0,0 +1,23 @@
package eu.jonahbauer.chat.bot.database.impl;
import org.jetbrains.annotations.NotNull;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;
public interface Preparer<T extends Record> {
@SuppressWarnings("unchecked")
static <T extends Record> @NotNull Preparer<T> get(@NotNull Class<T> type) {
return (Preparer<T>) PreparerFactory.INSTANCE.get(type);
}
default void prepare(@NotNull PreparedStatement statement, @NotNull T entity) throws SQLException {
prepare(statement, entity, 1);
}
void prepare(@NotNull PreparedStatement statement, @NotNull T entity, int index) throws SQLException;
@NotNull List<@NotNull String> columns();
}

@ -0,0 +1,74 @@
package eu.jonahbauer.chat.bot.database.impl;
import eu.jonahbauer.chat.bot.database.impl.types.TypeAdapter;
import lombok.Lombok;
import org.jetbrains.annotations.NotNull;
import java.lang.reflect.Method;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
final class PreparerFactory extends AbstractFactory<Preparer<?>> {
public static final @NotNull PreparerFactory INSTANCE = new PreparerFactory();
private PreparerFactory() {}
@Override
protected <T extends Record> @NotNull Preparer<T> computeValue0(@NotNull Class<T> type) {
var components = type.getRecordComponents();
var columns = new ArrayList<String>(components.length - 1);
var accessors = new ArrayList<Method>(components.length - 1);
var adapters = new ArrayList<TypeAdapter<?>>(components.length - 1);
for (var component : components) {
if (Helper.isIdColumn(component)) continue;
columns.add(Helper.getQuotedColumnName(component));
accessors.add(component.getAccessor());
adapters.add(TypeAdapter.get(component.getType()));
}
return new PreparerImpl<>(columns, adapters, accessors);
}
private static final class PreparerImpl<T extends Record> implements Preparer<T> {
private final @NotNull List<@NotNull String> columns;
private final @NotNull List<@NotNull TypeAdapter<?>> adapters;
private final @NotNull List<@NotNull Method> accessors;
public PreparerImpl(
@NotNull List<@NotNull String> columns,
@NotNull List<@NotNull TypeAdapter<?>> adapters,
@NotNull List<@NotNull Method> accessors
) {
this.columns = List.copyOf(columns);
this.adapters = List.copyOf(adapters);
this.accessors = List.copyOf(accessors);
if (this.columns.size() != this.adapters.size() || this.adapters.size() != this.accessors.size()) {
throw new IllegalArgumentException();
}
}
@Override
public void prepare(@NotNull PreparedStatement stmt, @NotNull T entity, int offset) throws SQLException {
try {
for (int i = 0, length = columns.size(); i < length; i++) {
var value = accessors.get(i).invoke(entity);
adapters.get(i).prepareUnsafe(stmt, i + offset, value);
}
} catch (SQLException ex) {
throw ex;
} catch (Exception ex) {
throw Lombok.sneakyThrow(ex);
}
}
@Override
public @NotNull List<@NotNull String> columns() {
return columns;
}
}
}

@ -0,0 +1,16 @@
package eu.jonahbauer.chat.bot.database.impl;
import eu.jonahbauer.chat.bot.database.Entity;
import org.jetbrains.annotations.NotNull;
import java.sql.Connection;
import java.sql.SQLException;
public interface Updater<T extends Record & Entity> {
@SuppressWarnings("unchecked")
static <T extends Record & Entity> @NotNull Updater<T> get(@NotNull Class<T> type) {
return (Updater<T>) UpdaterFactory.INSTANCE.get(type);
}
boolean update(@NotNull Connection connection, @NotNull T entity) throws SQLException;
}

@ -0,0 +1,45 @@
package eu.jonahbauer.chat.bot.database.impl;
import eu.jonahbauer.chat.bot.database.Entity;
import org.jetbrains.annotations.NotNull;
import java.sql.Connection;
import java.util.stream.Collectors;
final class UpdaterFactory extends AbstractFactory<@NotNull Updater<?>> {
public static final @NotNull UpdaterFactory INSTANCE = new UpdaterFactory();
private UpdaterFactory() {}
@Override
protected <T extends Record> @NotNull Updater<?> computeValue0(@NotNull Class<T> type) {
if (!Entity.class.isAssignableFrom(type)) throw new IllegalArgumentException();
return bridge(type);
}
@SuppressWarnings("unchecked")
private <T extends Record & Entity> @NotNull Updater<T> bridge(@NotNull Class<?> type) {
return computeValue1((Class<T>) type);
}
private <T extends Record & Entity> @NotNull Updater<T> computeValue1(@NotNull Class<T> type) {
var table = Helper.getQuotedTableName(type);
var preparer = Preparer.get(type);
var columns = preparer.columns();
var query = STR."""
UPDATE \{table}
SET \{columns.stream().map(col -> col + " = ?").collect(Collectors.joining(", "))}
WHERE id = ?
""";
return (Connection connection, T entity) -> {
Helper.logSqlStatement(query);
try (var stmt = connection.prepareStatement(query)) {
preparer.prepare(stmt, entity);
stmt.setLong(columns.size() + 1, entity.id());
return stmt.executeUpdate() == 1;
}
};
}
}

@ -0,0 +1,8 @@
package eu.jonahbauer.chat.bot.database.impl;
import eu.jonahbauer.chat.bot.database.Entity;
@FunctionalInterface
public interface WithId<T extends Record & Entity<T>> {
T withId(T entity, long id);
}

@ -0,0 +1,28 @@
package eu.jonahbauer.chat.bot.database.impl.types;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
@RequiredArgsConstructor
public class EnumTypeAdapter<E extends Enum<E>> implements TypeAdapter<E> {
private final @NotNull Class<E> type;
@Override
public void prepare(@NotNull PreparedStatement stmt, int index, E value) throws SQLException {
if (value == null) {
stmt.setNull(index, Types.VARCHAR);
} else {
stmt.setString(index, value.name());
}
}
@Override
public E extract(@NotNull ResultSet result, int index) throws SQLException {
return Enum.valueOf(type, result.getString(index));
}
}

@ -0,0 +1,31 @@
package eu.jonahbauer.chat.bot.database.impl.types;
import org.jetbrains.annotations.NotNull;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.time.Instant;
public class InstantTypeAdapter implements TypeAdapter<Instant> {
@Override
public void prepare(@NotNull PreparedStatement stmt, int index, Instant value) throws SQLException {
if (value == null) {
stmt.setNull(index, Types.INTEGER);
} else {
stmt.setLong(index, value.toEpochMilli());
}
}
@Override
public Instant extract(@NotNull ResultSet result, int index) throws SQLException {
var millis = result.getLong(index);
if (result.wasNull()) {
return null;
} else {
return Instant.ofEpochMilli(millis);
}
}
}

@ -0,0 +1,79 @@
package eu.jonahbauer.chat.bot.database.impl.types;
import org.jetbrains.annotations.NotNull;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
public class JdbcTypeAdapter<T> implements TypeAdapter<T> {
private final @NotNull Class<T> type;
JdbcTypeAdapter(@NotNull Class<T> type) {
this.type = type;
}
@Override
public void prepare(@NotNull PreparedStatement stmt, int index, T value) throws SQLException {
if (type == byte.class || type == Byte.class) {
if (value == null) stmt.setNull(index, Types.INTEGER);
else stmt.setByte(index, (byte) value);
} else if (type == char.class || type == Character.class) {
if (value == null) stmt.setNull(index, Types.INTEGER);
else stmt.setShort(index, (short) (char) value);
} else if (type == short.class || type == Short.class) {
if (value == null) stmt.setNull(index, Types.INTEGER);
else stmt.setShort(index, (short) value);
} else if (type == int.class || type == Integer.class) {
if (value == null) stmt.setNull(index, Types.INTEGER);
else stmt.setInt(index, (int) value);
} else if (type == long.class || type == Long.class) {
if (value == null) stmt.setNull(index, Types.INTEGER);
else stmt.setLong(index, (long) value);
} else if (type == float.class || type == Float.class) {
if (value == null) stmt.setNull(index, Types.FLOAT);
else stmt.setFloat(index, (float) value);
} else if (type == double.class || type == Double.class) {
if (value == null) stmt.setNull(index, Types.DOUBLE);
else stmt.setDouble(index, (double) value);
} else if (type == boolean.class || type == Boolean.class) {
if (value == null) stmt.setNull(index, Types.BOOLEAN);
else stmt.setBoolean(index, (boolean) value);
} else {
stmt.setObject(index, value);
}
}
@Override
@SuppressWarnings("unchecked")
public T extract(@NotNull ResultSet result, int index) throws SQLException {
if (type == byte.class || type == Byte.class) {
var out = (T) (Byte) result.getByte(index);
return result.wasNull() ? null : out;
} else if (type == char.class || type == Character.class) {
var out = (T) (Character) (char) result.getShort(index);
return result.wasNull() ? null : out;
} else if (type == short.class || type == Short.class) {
var out = (T) (Short) result.getShort(index);
return result.wasNull() ? null : out;
} else if (type == int.class || type == Integer.class) {
var out = (T) (Integer) result.getInt(index);
return result.wasNull() ? null : out;
} else if (type == long.class || type == Long.class) {
var out = (T) (Long) result.getLong(index);
return result.wasNull() ? null : out;
} else if (type == float.class || type == Float.class) {
var out = (T) (Float) result.getFloat(index);
return result.wasNull() ? null : out;
} else if (type == double.class || type == Double.class) {
var out = (T) (Double) result.getDouble(index);
return result.wasNull() ? null : out;
} else if (type == boolean.class || type == Boolean.class) {
var out = (T) (Boolean) result.getBoolean(index);
return result.wasNull() ? null : out;
} else {
return result.getObject(index, type);
}
}
}

@ -0,0 +1,36 @@
package eu.jonahbauer.chat.bot.database.impl.types;
import org.jetbrains.annotations.NotNull;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
public interface TypeAdapter<T> {
@SuppressWarnings("unchecked")
static <T> @NotNull TypeAdapter<T> get(@NotNull Class<T> type) {
if (type == Instant.class) {
return (TypeAdapter<T>) new InstantTypeAdapter();
} else if (Enum.class.isAssignableFrom(type)) {
return (TypeAdapter<T>) newEnumTypeAdapter(type);
} else {
return new JdbcTypeAdapter<>(type);
}
}
@SuppressWarnings("unchecked")
private static <E extends Enum<E>> EnumTypeAdapter<?> newEnumTypeAdapter(Class<?> type) {
return new EnumTypeAdapter<>((Class<E>) type);
}
@SuppressWarnings("unchecked")
default void prepareUnsafe(@NotNull PreparedStatement stmt, int index, Object value) throws SQLException {
prepare(stmt, index, (T) value);
}
void prepare(@NotNull PreparedStatement stmt, int index, T value) throws SQLException;
T extract(@NotNull ResultSet result, int index) throws SQLException;
}

@ -0,0 +1,12 @@
module eu.jonahbauer.chat.bot.database {
exports eu.jonahbauer.chat.bot.database;
exports eu.jonahbauer.chat.bot.database.annotations;
requires transitive java.sql;
requires org.xerial.sqlitejdbc;
requires com.zaxxer.hikari;
requires org.slf4j;
requires static lombok;
requires static transitive org.jetbrains.annotations;
}

@ -0,0 +1,155 @@
package eu.jonahbauer.chat.bot.database;
import eu.jonahbauer.chat.bot.database.annotations.Column;
import eu.jonahbauer.chat.bot.database.annotations.Table;
import org.intellij.lang.annotations.Language;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.SQLException;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
public class DatabaseTest {
@TempDir
static Path temp;
Database database;
@BeforeEach
void init() throws IOException, SQLException {
var file = Files.createTempFile(temp, "database", ".db");
var url = "jdbc:sqlite:" + file.toAbsolutePath();
database = new Database(url);
database.executeUpdate("""
CREATE TABLE "pizza"
(
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"restaurant_id" INTEGER NOT NULL,
"number" INTEGER NOT NULL,
"name" VARCHAR(255) NOT NULL,
"ingredients" VARCHAR(255) NOT NULL,
"price" DOUBLE NOT NULL,
"test" INTEGER
)
""");
}
@Test
void crud() throws SQLException {
crud(Pizza.class, Pizza::new);
}
@Test
void annotations() throws SQLException {
crud(AnnotatedPizza.class, AnnotatedPizza::new);
}
@Test
void transaction() throws SQLException {
assertThrowsExactly(RuntimeException.class, () -> {
database.transaction(() -> {
assertThrows(IllegalStateException.class, () -> database.transaction(() -> {}));
database.insert(new Pizza(-1, 1, 10, "Salami", "Salami, Tomaten, Brot", 12.5));
assertEquals(1, database.findAll(Pizza.class).size());
throw new RuntimeException();
});
});
assertEquals(List.of(), database.findAll(Pizza.class));
}
@Test
void find() throws SQLException {
var salami = new Pizza(1, 1, 10, "Salami", "Salami, Tomaten, Brot", 12.5);
var speziale = new Pizza(2, 1, 15, "Speziale", "Salami, Schinken, Pilze, Tomaten, Brot", 12.5);
assertEquals(1, database.insert(salami));
assertEquals(2, database.insert(speziale));
assertEquals(Optional.empty(), database.findWhere(Pizza.class, "name", "Hawaii"));
assertEquals(Optional.of(salami), database.findWhere(Pizza.class, "name", "Salami"));
assertEquals(Optional.of(speziale), database.findWhere(Pizza.class, "name", "Speziale"));
assertThrows(NonUniqueResultException.class, () -> database.findWhere(Pizza.class, "restaurantId", 1L));
assertEquals(List.of(salami, speziale), database.findAllWhere(Pizza.class, "restaurantId", 1L));
}
@Test
void join() throws SQLException {
var salami = new Pizza(1, 1, 10, "Salami", "Salami, Tomaten, Brot", 12.5);
var speziale = new Pizza(2, 1, 15, "Speziale", "Salami, Schinken, Pilze, Tomaten, Brot", 12.5);
assertEquals(1, database.insert(salami));
assertEquals(2, database.insert(speziale));
@Language("SQL")
var query = """
SELECT "pizza".*
FROM "pizza"
JOIN "pizza" "p2" ON "pizza"."restaurant_id" = "p2"."restaurant_id"
""";
assertEquals(List.of(salami, salami, speziale, speziale), database.executeQuery(Pizza.class, query));
@Language("SQL")
var query2 = """
SELECT "p2".*
FROM "pizza"
JOIN "pizza" "p2" ON "pizza"."restaurant_id" = "p2"."restaurant_id"
""";
assertEquals(List.of(salami, speziale, salami, speziale), database.executeQuery(Pizza.class, query2));
}
private <T extends Record & Entity<T>> void crud(@NotNull Class<T> type, @NotNull PizzaBaker<T> baker) throws SQLException {
var salami = baker.bake(-1, 1, 10, "Salami", "Salami, Tomaten, Brot", 12.5);
var persistent = baker.bake(1, 1, 10, "Salami", "Salami, Tomaten, Brot", 12.5);
var speziale = baker.bake(1, 1, 15, "Speziale", "Salami, Schinken, Pilze, Tomaten, Brot", 12.5);
// create
assertEquals(1, database.insert(salami));
assertEquals(List.of(persistent), database.findAll(type));
assertEquals(Optional.of(persistent), database.findById(type, 1));
assertEquals(Optional.empty(), database.findById(type, 2));
// update
assertFalse(database.update(salami));
assertTrue(database.update(speziale));
assertEquals(List.of(speziale), database.findAll(type));
assertEquals(Optional.of(speziale), database.findById(type, 1));
assertEquals(Optional.empty(), database.findById(type, 2));
// delete
assertFalse(database.delete(type, 2));
assertTrue(database.delete(type, 1));
assertEquals(List.of(), database.findAll(type));
// truncate
database.insert(salami);
database.insert(speziale);
assertEquals(2, database.findAll(type).size());
assertEquals(2, database.truncate(type));
assertEquals(List.of(), database.findAll(type));
}
@AfterEach
void cleanup() {
database.close();
}
private interface PizzaBaker<T extends Record & Entity<T>> {
@NotNull T bake(long id, long restaurantId, int number, @NotNull String name, @NotNull String ingredients, double price);
}
public record Pizza(long id, long restaurantId, int number, @NotNull String name, @NotNull String ingredients, double price) implements Entity<Pizza> {}
@Table("pizza")
public record AnnotatedPizza(long id, @Column("restaurant_id") long restaurant, int number, @NotNull String name, @NotNull String ingredients, double price) implements Entity<AnnotatedPizza> {}
}

@ -0,0 +1,17 @@
package eu.jonahbauer.chat.bot.database;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class EntityTest {
public record TestEntity(long id, String foo, String bar) implements Entity<TestEntity> {}
public record TestEntity2(String foo, String bar, long id) implements Entity<TestEntity2> {}
@Test
void withId() {
assertEquals(new TestEntity(1, "foo", "bar"), new TestEntity(0, "foo", "bar").withId(1));
assertEquals(new TestEntity2("foo", "bar", 1), new TestEntity2("foo", "bar", 0).withId(1));
}
}

@ -3,4 +3,3 @@ plugins {
} }
group = "eu.jonahbauer.chat.bots" group = "eu.jonahbauer.chat.bots"
version = "0.1.0-SNAPSHOT"

@ -3,7 +3,7 @@ import eu.jonahbauer.chat.bot.ping.PingBot;
module eu.jonahbauer.chat.bot.ping { module eu.jonahbauer.chat.bot.ping {
requires eu.jonahbauer.chat.bot.api; requires eu.jonahbauer.chat.bot.api;
requires org.apache.logging.log4j; requires org.slf4j;
requires static lombok; requires static lombok;
provides ChatBot with PingBot; provides ChatBot with PingBot;

@ -0,0 +1,10 @@
plugins {
id("chat-bot.bot-conventions")
}
group = "eu.jonahbauer.chat.bots"
dependencies {
implementation(project(":bot-database"))
testImplementation(testFixtures(project(":bot-api")))
}

@ -0,0 +1,692 @@
package eu.jonahbauer.chat.bot.pizza;
import eu.jonahbauer.chat.bot.api.ChatBot;
import eu.jonahbauer.chat.bot.api.Message.Post;
import eu.jonahbauer.chat.bot.config.BotConfig;
import eu.jonahbauer.chat.bot.database.Database;
import eu.jonahbauer.chat.bot.database.NonUniqueResultException;
import eu.jonahbauer.chat.bot.pizza.model.*;
import eu.jonahbauer.chat.bot.pizza.model.table.Order;
import eu.jonahbauer.chat.bot.pizza.model.table.OrderItem;
import eu.jonahbauer.chat.bot.pizza.model.table.Pizza;
import eu.jonahbauer.chat.bot.pizza.model.table.User;
import eu.jonahbauer.chat.bot.pizza.model.view.OrderDetail;
import eu.jonahbauer.chat.bot.pizza.model.view.OrderItemDetail;
import eu.jonahbauer.chat.bot.pizza.util.ArgumentParser;
import eu.jonahbauer.chat.bot.pizza.util.Pair;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.VisibleForTesting;
import java.sql.SQLException;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.function.BiConsumer;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.stream.Collectors;
import static eu.jonahbauer.chat.bot.pizza.util.ArgumentParser.*;
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.*;
import static java.util.FormatProcessor.FMT;
@Slf4j
public class PizzaBot extends ChatBot {
private static final @NotNull ScopedValue<Double> ANNOYANCE = ScopedValue.newInstance();
private static final @NotNull ScopedValue<User> USER = ScopedValue.newInstance();
private static final @NotNull BotConfig.Key.OfStringArray PRIMARY_CHANNELS = BotConfig.Key.ofStringArray("primary_channels");
private static final @NotNull BotConfig.Key.OfString DATABASE = BotConfig.Key.ofString("database");
private static final @NotNull DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("HH:mm")
.withZone(ZoneId.of("Europe/Berlin"))
.withLocale(Locale.GERMANY);
private static final @NotNull List<@NotNull String> ANNOYED_RESPONSES = List.of(
"Es ist so dunkel, ich kann dich nicht hören.",
"Jetzt schrei doch nicht so!",
"Fresse!"
);
private final @Nullable Set<@NotNull String> primaryChannels;
private final @NotNull PizzaService service;
public PizzaBot() {
super("Marek");
this.primaryChannels = getConfig().get(PRIMARY_CHANNELS).map(Set::copyOf).orElse(null);
this.service = new PizzaService(new Database(getConfig().require(DATABASE)));
}
@VisibleForTesting
PizzaBot(@Nullable Set<@NotNull String> primaryChannels, @NotNull Database database) {
super("Marek");
this.primaryChannels = primaryChannels;
this.service = new PizzaService(database);
}
@Override
protected void onMessage(@NotNull Post post) {
var message = post.message();
if (message.length() < 6) return;
var command = message.substring(0, 6);
var runnable = (Runnable) () -> {
try {
if ("!marek".equals(command) || "!Marek".equals(command)) {
handle(post);
} else if ("!MAREK".equals(command) && !ANNOYED_RESPONSES.isEmpty()) {
handleAnnoyed();
} else if ("!marek".equalsIgnoreCase(command)) {
var uppercase = command.chars().filter(chr -> 'A' <= chr && chr <= 'Z').count();
ScopedValue.runWhere(ANNOYANCE, uppercase / 5d, () -> handle(post));
}
} catch (PizzaBotException ex) {
post(ex.getMessage());
}
};
if (post.userId() != null && post.userName() != null) {
ScopedValue.runWhere(USER, new User(post.userId(), post.userName()), runnable);
} else {
runnable.run();
}
}
private void handle(@NotNull Post post) {
if (!isPrimaryChannel(post.channel())) {
handleNonPrimaryChannel();
} else {
try {
dispatch(ArgumentParser.parse(post.message().substring(6)));
} catch (SQLException ex) {
log.error("Fehler beim Verarbeiten von {}.", post, ex);
}
}
}
private void handleAnnoyed() {
assert !ANNOYED_RESPONSES.isEmpty();
var idx = (int) (Math.random() * ANNOYED_RESPONSES.size());
post(ANNOYED_RESPONSES.get(idx));
}
private void handleNonPrimaryChannel() {
assert primaryChannels != null;
if (primaryChannels.size() == 1) {
post(STR."Bitte benutze den Channel \{primaryChannels.iterator().next()}.");
} else {
var channels = new StringBuilder();
var it = primaryChannels.iterator();
while (it.hasNext()) {
var channel = it.next();
if (!it.hasNext()) {
channels.append(" oder ");
} else if (!channels.isEmpty()) {
channels.append(", ");
}
channels.append(channel);
}
post(STR."Bitte benutze einen der Channel \{channels.toString()}.");
}
}
private void dispatch(@NotNull List<@NotNull String> args) throws SQLException {
if (args.isEmpty()) {
helpUser();
return;
}
var argc = args.size();
var command = args.getFirst();
switch (command) {
case "--user", "-u" -> runAs(args);
case "help" -> {
if (argc > 1 && "--all".equals(args.get(1))) {
helpAdmin();
} else {
helpUser();
}
}
case "info" -> info();
case "order" -> {
if (argc == 1) throw new PizzaBotException("too few arguments");
switch (args.get(1)) {
case "revoke" -> {
if (argc == 2) orderRevoke();
else if (argc != 3) throw new PizzaBotException("too many arguments");
else switch (args.get(2)) {
case "--all" -> orderRevokeAll();
case String s when isLong(s) -> orderRevoke(toLong(s));
case String name -> orderRevoke(name);
}
}
case "list" -> {
if (argc == 2) orderList();
// else if (argc == 3 && "--all".equals(args.get(2))) orderListAll();
else throw new PizzaBotException("invalid command");
}
case "add" -> {
if (argc < 4) throw new PizzaBotException("too few arguments");
else if (argc > 4) throw new PizzaBotException("too many arguments");
orderAdd(args.get(2), args.get(3));
}
case String name when argc == 2 -> order(name, null);
case String name -> order(name, String.join(" ", args.subList(2, args.size())));
}
}
case "summary" -> {
if (argc == 1) summary();
else if (argc == 2 && "--all".equals(args.get(1))) summaryAll();
else if (argc == 2) summary(args.get(1));
else throw new PizzaBotException("invalid command");
}
case "check" -> {
if (argc == 1) check();
else if (argc == 2 && "--all".equals(args.get(1))) checkAll();
else if (argc == 2) check(args.get(1));
else throw new PizzaBotException("invalid command");
}
case "pay" -> {
if (argc == 1) throw new PizzaBotException("missing argument");
else if (argc == 2) pay(toDouble(args.get(1)));
else if (argc == 3) pay(args.get(1), toDouble(args.get(2)));
else throw new PizzaBotException("too many arguments");
}
case "payment" -> {
if (argc == 1) throw new PizzaBotException("too few arguments");
switch (args.get(1)) {
case "confirm" -> {
if (argc == 2) throw new PizzaBotException("too few arguments");
else if (argc == 3 && "--all".equals(args.get(2))) throw new PizzaBotException("missing arguments");
else if (argc == 3) paymentConfirm(toLong(args.get(2)), null);
else if (argc == 4 && "--all".equals(args.get(2))) paymentConfirmAll(args.get(3));
else if (argc == 4) paymentConfirm(toLong(args.get(2)), args.get(3));
else throw new PizzaBotException("invalid command");
}
case "void" -> {
if (argc == 2) throw new PizzaBotException("too few arguments");
else if (argc == 3) paymentVoid(toLong(args.get(2)), null);
else if (argc == 4) paymentVoid(toLong(args.get(2)), args.get(3));
else throw new PizzaBotException("invalid command");
}
default -> throw new PizzaBotException("invalid command");
}
}
default -> throw new PizzaBotException("Unbekannter Befehl.");
}
}
private @NotNull User getUser() {
if (USER.isBound()) {
return USER.get();
} else {
throw new PizzaBotException("Diese Aktion kann nur mit öffentlicher ID ausgeführt werden.");
}
}
//<editor-fold desc="Admin" defaultstate="collapsed">
private void runAs(@NotNull List<String> args) throws SQLException {
// TODO
if (true) throw new PizzaBotException("nicht implementiert");
if (args.size() < 2) throw new PizzaBotException("missing argument");
var username = args.get(1);
// var user = service.getUserByEventAndName(, username);
//
// ScopedValue.runWhere(USER, user, () -> {
// try {
// dispatch(args.subList(2, args.size()));
// } catch (SQLException ex) {
// throw Lombok.sneakyThrow(ex);
// }
// });
//
// if (false) throw new SQLException();
}
private void orderAdd(@NotNull String targetOrder, @NotNull String newUserName) throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
var newUser = service.getUserByName(newUserName);
OrderItem item;
Pizza pizza;
List<UserWithPayment> users;
try {
var targetId = Long.parseLong(targetOrder);
var detail = service.getItemById(targetId)
.orElseThrow(() -> new PizzaBotException(STR."Die Bestellung mit Nummer \{targetId} existiert nicht."));
item = detail.item();
pizza = detail.pizza();
users = detail.users();
} catch (NumberFormatException _) {
var user = service.getUserByEventAndName(order.eventId(), targetOrder);
try {
item = service.getItemByUser(order.id(), user.id())
.orElseThrow(() -> new PizzaBotException(STR."\{user.username()} hat keine Bestellung aufgegeben."));
pizza = service.getPizzaById(item.pizzaId()).orElseThrow();
users = service.getUsersByItem(item);
} catch (NonUniqueResultException ex) {
throw new PizzaBotException(STR."\{user.username()} hat mehr als eine Bestellung aufgegeben. Bitte gibt die Positionsnummer an.", ex);
}
}
var payed = users.stream().filter(UserWithPayment::payed).map(UserWithPayment::username).toList();
if (!payed.isEmpty()) {
throw new PizzaBotException(STR."\{join(payed)} \{payed.size() == 1 ? "hat" : "haben"} schon bezahlt.");
}
if (users.stream().anyMatch(u -> u.id() == newUser.id())) {
throw new PizzaBotException(STR."\{newUser.username()} nimmt bereits an dieser Bestellung teil.");
}
service.add(item, newUser, false);
post(STR."\{newUser.username()} wurde zur Bestellung \{item.id()} (\{pizza.name()}) von \{join(users, UserWithPayment::username)} hinzugefügt.");
}
/**
* Zeigt eine Übersicht alle Bestellungen während der Veranstaltung.
*/
private void summaryAll() throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
summary0(order.eventName(), service.getItemDetailByEvent(order.eventId()));
}
/**
* Zeigt eine Übersicht der Positionen der laufenden Bestellung.
*/
private void summary() throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
summary0(order.name(), service.getItemDetailByOrder(order.id()));
}
/**
* Zeigt eine Übersicht der Positionen der Bestellung mit dem angegebenen Namen.
*/
private void summary(@NotNull String name) throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
var other = service.getOrderByEventAndName(order.eventId(), name);
summary0(other.name(), service.getItemDetailByOrder(other.id()));
}
private void summary0(@NotNull String name, @NotNull Collection<OrderItemDetail> details) {
var items = details.stream().collect(Collectors.groupingBy(
detail -> Pair.of(detail.pizza(), detail.notes()),
() -> new TreeMap<>(Comparator.comparing(Pair::first)),
Collectors.collectingAndThen(
Collectors.groupingBy(
OrderItemDetail::id,
Collectors.mapping(OrderItemDetail::userName, Collectors.joining("+"))
),
Map::values
)
));
var lines = new ArrayList<String>(items.size() + 2);
lines.add("Übersicht " + name);
lines.add("");
if (items.isEmpty()) {
lines.add("(keine Einträge)");
} else {
items.forEach((item, users) -> {
var counts = users.stream().collect(Collectors.groupingBy(
Function.identity(),
Collectors.counting()
));
var usersString = counts.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(entry -> entry.getValue() == 1 ? entry.getKey() : entry.getValue() + "x " + entry.getKey())
.collect(Collectors.joining(", "));
var pizza = item.first().name().toUpperCase(Locale.ROOT);
var notes = item.second() != null ? " " + item.second() : "";
lines.add(STR."\{tabular(users.size())}x \{pizza}\{notes} (\{usersString})");
});
}
post(String.join("\n", lines));
}
/**
* Zeigt die zusammengefasste Rechnung für alle Bestellungen während der Veranstaltung.
*/
private void checkAll() throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
var details = service.getItemDetailByEvent(order.eventId());
var tips = details.stream().collect(Collectors.groupingBy(
OrderItemDetail::order,
Collectors.collectingAndThen(Collectors.toList(), list -> calculateTip(list.getFirst().orderPayment(), list))
));
var items = details.stream().collect(Collectors.groupingBy(
OrderItemDetail::user,
Collectors.reducing(Receipt.empty(), item -> item.receipt(tips.get(item.order())), Receipt::sum)
));
check0(order.eventName(), tips.values().stream().anyMatch(tip -> tip > 0), items);
}
/**
* Zeigt die Rechnung für der laufenden Bestellung.
*/
private void check() throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
check0(order.order());
}
/**
* Zeigt die Rechnung für die Bestellung mit dem angegebenen Namen.
*/
private void check(@NotNull String name) throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
var other = service.getOrderByEventAndName(order.eventId(), name);
check0(other);
}
private static double calculateTip(@Nullable Double payment, List<@NotNull OrderItemDetail> details) {
var total = details.stream().mapToDouble(OrderItemDetail::userPrice).sum();
return payment == null ? 0 : payment / total;
}
private void check0(@NotNull Order order) throws SQLException {
var details = service.getItemDetailByOrder(order.id());
var tip = calculateTip(order.payment(), details);
var items = details.stream().collect(Collectors.groupingBy(
OrderItemDetail::user,
Collectors.reducing(Receipt.empty(), item -> item.receipt(tip), Receipt::sum)
));
check0(order.name(), order.payment() != null, items);
}
private void check0(@NotNull String name, boolean tipped, @NotNull Map<User, @NotNull Receipt> items) {
var lines = new ArrayList<String>();
lines.add("Rechnung für " + name + (tipped ? " (mit Trinkgeld)" : ""));
lines.add("alle Angaben ohne Gewähr");
lines.add("");
if (items.isEmpty()) {
lines.add("(keine Einträge)");
} else {
var toString = (BiConsumer<String, Receipt>) (user, receipt) -> {
if (receipt.payed()) {
lines.add(strikethrough(user + ": " + receipt.totalAsString()));
} else if (receipt.unpayed()) {
lines.add(user + ": " + receipt.totalAsString());
} else {
lines.add(user + ": " + strikethrough(receipt.totalAsString()) + " " + receipt.pendingAsString());
}
};
items.forEach((user, receipt) -> toString.accept(user.username(), receipt));
lines.add("");
toString.accept("Gesamt", items.values().stream().reduce(Receipt::sum).orElseThrow());
}
post(String.join("\n", lines));
}
private void pay(double amount) throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
service.save(order.order().withPayment(amount));
post(FMT."Zahlung von %.2f\{amount} für \{order.name()} wurde gespeichert");
}
private void pay(@NotNull String name, double amount) throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
var other = service.getOrderByEventAndName(order.eventId(), name);
service.save(other.withPayment(amount));
post(FMT."Zahlung von %.2f\{amount} für \{other.name()} wurde gespeichert");
}
// /**
// * Beginnt eine neue Bestellung.
// * @param restaurant die Restaurant-ID
// */
// private void orderStart(@NotNull String restaurant, @NotNull String deadline) {
// }
//
// /**
// * Beginnt eine neue Bestellung.
// * @param restaurantId die Restaurant-ID
// */
// private void orderStart(long restaurantId, @NotNull String deadline) {
// var restaurant = restaurantRepository.getById(restaurantId)
// .orElseThrow(() -> new BreakException(STR."Das Restaurant mit der ID \{restaurantId} existiert nicht."));
// var order = new Order(-1, , restaurantId, , , null);
// orderRepository.save(order);
// }
//
// /**
// * Beendet eine Bestellung.
// * @param payment der gezahlte Betrag inkl. Trinkgeld
// */
// private void orderFinish(double payment) {
// var order = service.getCurrentOrderAsAdmin();
// orderRepository.save(order.withPayment(payment));
// check(order.id());
// }
/**
* Bestätigt den Zahlungseingang für alle ausstehenden Zahlungen der laufenden Veranstaltung eines Benutzers.
*/
private void paymentConfirmAll(@NotNull String name) throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
var user = service.getUserByEventAndName(order.eventId(), name);
var count = service.paymentByEventAndUser(order.eventId(), user.id(), true);
post(STR."\{count} ausstehenden Zahlungen für \{user.username()} wurden bestätigt.");
}
/**
* Bestätigt den Zahlungseingang für eine Bestellung.
*/
private void paymentConfirm(long itemId, @Nullable String username) throws SQLException {
payment0(itemId, username, true, (pizza, user) -> STR."Zahlungseingang für \{pizza} (\{user}) wurde bestätigt.");
}
/**
* Widerruft die Bestätigung eines Zahlungseingangs.
*/
private void paymentVoid(long itemId, @Nullable String username) throws SQLException {
payment0(itemId, username, false, (pizza, user) -> STR."Zahlungseingang für \{pizza} (\{user}) wurde bestätigt.");
}
private void payment0(long itemId, @Nullable String username, boolean payed, @NotNull BinaryOperator<String> message) throws SQLException {
var _ = service.getCurrentOrder().checkAdminAccess(getUser());
var summary = service.getItemById(itemId)
.orElseThrow(() -> new PizzaBotException(STR."Die Bestellung mit Nummer \{itemId} existiert nicht."));
if (username != null) {
var users = summary.users().stream().filter(u -> u.username().startsWith(username)).toList();
if (users.isEmpty()) {
throw new PizzaBotException("Der Benutzer existiert nicht oder hat nicht an der Bestellung teilgenommen.");
} else if (users.size() > 1) {
throw new PizzaBotException("Der Benutzer ist nicht eindeutig.");
} else {
var user = users.getFirst();
service.paymentByItemAndUser(itemId, user.id(), true);
post(message.apply(summary.pizza().name(), user.username()));
}
} else {
service.paymentByItem(summary.item().id(), payed);
post(message.apply(summary.pizza().name(), join(" + ", summary.users(), UserWithPayment::username)));
}
}
//</editor-fold>
//<editor-fold desc="User" defaultstate="collapsed">
/**
* Zeigt Informationen zur aktuell laufenden Bestellung.
*/
private void info() throws SQLException {
var order = service.getCurrentOrder();
var result = service.getRestaurantWithPizzas(order.restaurantId());
var restaurant = result.first();
var pizzas = result.second();
var lines = new ArrayList<String>();
lines.add(STR."Pizzabestellung bei \{restaurant.name()} in \{restaurant.city()}");
lines.add(STR."Bestellannahmeschluss: \{DATE_FORMATTER.format(order.deadline())}");
pizzas.stream().map(Pizza::toPrettyString).forEach(lines::add);
post(String.join("\n", lines));
}
/**
* Listet die eigenen Bestellungen auf.
*/
private void orderList() throws SQLException {
var user = getUser();
var current = service.getCurrentOrder();
var itemsByOrder = service.getItemDetailByEventAndUser(current.eventId(), user.id()).stream().collect(Collectors.groupingBy(
OrderItemDetail::order,
TreeMap::new,
Collectors.toUnmodifiableList()
));
var lines = new ArrayList<String>();
lines.add("Bestellungen von " + user.username());
if (itemsByOrder.isEmpty()) lines.add("(keine Einträge)");
itemsByOrder.forEach((order, items) -> {
lines.add("");
lines.add(order.name());
items.stream().map(OrderItemDetail::toPrettyString).forEach(lines::add);
});
post(String.join("\n", lines));
}
/**
* Storniert die einzige eigene Bestellung.
*/
private void orderRevoke() throws SQLException {
var order = service.getCurrentOrder().checkDeadline();
var user = getUser();
try {
var item = service.getItemByUser(order.id(), user.id());
if (item.isEmpty()) {
post("Du hast keine Bestellung aufgegeben.");
} else {
orderRevoke0(item.get());
}
} catch (NonUniqueResultException ex) {
post("Du hast mehr als eine Bestellung aufgegeben. Bitte gib die Positionsnummer oder den Namen der Pizza an.");
}
}
/**
* Storniert die angegebene, eigene Bestellung.
*/
private void orderRevoke(long itemId) throws SQLException {
var order = service.getCurrentOrder().checkDeadline();
var item = service.getItemById(itemId);
if (item.isEmpty() || item.get().item().orderId() != order.id()) {
post(STR."Die Position mit Nummer \{itemId} existiert nicht.");
} else {
orderRevoke0(item.get().checkUserAccess(getUser()).item());
}
}
/**
* Storniert die eigene Bestellung der angegebene Pizza.
*/
private void orderRevoke(@NotNull String name) throws SQLException {
var order = service.getCurrentOrder().checkDeadline();
var user = getUser();
try {
var item = service.getItemByPizzaName(order.id(), user.id(), name);
if (item.isEmpty()) {
post("Du hast keine solche Pizza bestellt.");
} else {
orderRevoke0(item.get());
}
} catch (NonUniqueResultException ex) {
post("Du hast mehr als eine solche Pizza bestellt. Bitte gib die Positionsnummer an.");
}
}
private void orderRevoke0(@NotNull OrderItem item) throws SQLException {
var pizza = service.getPizzaById(item.pizzaId()).orElseThrow();
service.delete(item);
post(STR."Die Position \{item.id()} (\{pizza.name()}) wurde storniert.");
}
/**
* Storniert alle eigenen Bestellungen.
*/
private void orderRevokeAll() throws SQLException {
var order = service.getCurrentOrder().checkDeadline();
var user = getUser();
var items = service.getItemsByUser(order.id(), user.id());
if (items.isEmpty()) {
post("Du hast keine Bestellung aufgegeben.");
} else {
for (var item : items) {
service.delete(item);
}
post("Alle deine Bestellungen wurden storniert.");
}
}
/**
* Bestellt die Pizza mit dem angegebenen Namen.
*/
private void order(@NotNull String nameOrNummer, @Nullable String notes) throws SQLException {
var order = service.getCurrentOrder().checkDeadline();
var pizza = service.getPizza(order.restaurantId(), nameOrNummer);
order0(order, pizza, notes);
}
private void order0(@NotNull OrderDetail order, @NotNull Pizza pizza, @Nullable String notes) throws SQLException {
var id = service.order(order, getUser(), pizza, notes);
post(STR."Pizza \{pizza.name()} wurde zur Bestellung hinzugefügt (Positionsnummer \{id}).");
}
//</editor-fold>
private void helpUser() {
post("""
Marek is back!
!marek info - Menü anzeigen
Bestellung
!marek order (NUMMER | NAME) [NOTIZEN] - Pizza bestellen
!marek order revoke [NUMMER | NAME | --all] - Bestellung(en) stornieren
!marek order list - Bestellungen auflisten
""");
}
private void helpAdmin() {
post("""
!marek --user USER order (NUMMER | NAME) [NOTIZEN] - Pizza bestellen
!marek --user USER order revoke [NUMMER | NAME | --all] - Bestellung(en) stornieren
!marek --user USER order list - Bestellungen auflisten
!marek summary [NAME | --all] - Bestellübersicht
!marek check [NAME | --all] - Rechnung anzeigen
!marek pay [NAME] AMOUNT - Rechnungsbetrag angeben
!marek order list --all - Alle Bestellungen auflisten
!marek order add (NUMMER | NAME) USER - Fügt USER zur Bestellung mit Nummer NUMMER / von NAME hinzu
!marek payment confirm NUMMER [USER] - Zahlungseingang bestätigen
!marek payment confirm --all USER - Zahlungseingang bestätigen
!marek payment void NUMMER [USER] - Widerruf der Bestätigung des Zahlungseingangs
""");
}
@Override
protected void post(@NotNull String message, @Nullable String name, @Nullable Boolean bottag, @Nullable Boolean publicId) {
var annoyance = ANNOYANCE.orElse(0d);
if (annoyance == 0) {
super.post(message, name, bottag, publicId);
} else if (annoyance == 1) {
super.post(message.toUpperCase(Locale.ROOT), name, bottag, publicId);
} else {
var infuriatedMessage = message.codePoints()
.map(chr -> Math.random() < annoyance ? Character.toUpperCase(chr) : chr)
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
.toString();
super.post(infuriatedMessage, name, bottag, publicId);
}
}
private boolean isPrimaryChannel(@NotNull String channel) {
return primaryChannels == null || primaryChannels.contains(channel);
}
}

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

@ -0,0 +1,298 @@
package eu.jonahbauer.chat.bot.pizza;
import eu.jonahbauer.chat.bot.database.Database;
import eu.jonahbauer.chat.bot.database.NonUniqueResultException;
import eu.jonahbauer.chat.bot.pizza.model.*;
import eu.jonahbauer.chat.bot.pizza.model.table.*;
import eu.jonahbauer.chat.bot.pizza.model.view.OrderDetail;
import eu.jonahbauer.chat.bot.pizza.model.view.OrderItemDetail;
import eu.jonahbauer.chat.bot.pizza.util.Pair;
import org.intellij.lang.annotations.Language;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.sql.SQLException;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public class PizzaService {
private final @NotNull Database database;
public PizzaService(@NotNull Database database) {
this.database = Objects.requireNonNull(database);
}
public @NotNull OrderDetail getCurrentOrder() throws SQLException {
@Language("SQL")
var query = "SELECT * FROM order_detail WHERE active = TRUE ORDER BY deadline DESC LIMIT 1";
return database.executeUniqueQuery(OrderDetail.class, query)
.orElseThrow(() -> new PizzaBotException("Aktuell läuft keine Pizzabestellung."));
}
public @NotNull Pair<@NotNull Restaurant, @NotNull List<@NotNull Pizza>> getRestaurantWithPizzas(long restaurantId) throws SQLException {
return database.transaction(() -> {
var restaurant = database.findById(Restaurant.class, restaurantId).orElseThrow();
var pizzas = database.findAllWhere(Pizza.class, Pizza.Fields.restaurantId, restaurantId);
return Pair.of(restaurant, pizzas);
});
}
// Payment
public void save(@NotNull Order order) throws SQLException {
database.update(order);
}
public @NotNull User getUserByName(@NotNull String name) throws SQLException {
try {
@Language("SQL")
var query = """
SELECT "user".* FROM "user"
WHERE "user"."username" LIKE ?
""";
return database.executeUniqueQuery(User.class, query, stmt -> stmt.setString(1, name + "%"))
.orElseThrow(() -> new PizzaBotException("Der Benutzer existiert nicht."));
} catch (NonUniqueResultException _) {
throw new PizzaBotException("Der Benutzer ist nicht eindeutig.");
}
}
public @NotNull User getUserByEventAndName(long eventId, @NotNull String name) throws SQLException {
try {
@Language("SQL")
var query = """
SELECT "user".* FROM "user"
WHERE "user"."username" LIKE ?
AND EXISTS(
SELECT * FROM "order_item_to_user"
JOIN "order_item" ON "order_item_to_user"."order_item_id" = "order_item"."id"
JOIN "order" ON "order_item"."order_id" = "order"."id"
WHERE "order_item_to_user"."user_id" = "user"."id"
AND "order"."event_id" = ?
)
""";
return database.executeUniqueQuery(User.class, query, stmt -> {
stmt.setString(1, name + "%");
stmt.setLong(2, eventId);
}).orElseThrow(() -> new PizzaBotException("Der Benutzer existiert nicht oder hat an keiner Pizzabestellung teilgenommen."));
} catch (NonUniqueResultException _) {
throw new PizzaBotException("Der Benutzer ist nicht eindeutig.");
}
}
public void paymentByItem(long itemId, boolean payed) throws SQLException {
database.executeUpdate("UPDATE order_item_to_user SET payed = ? WHERE order_item_id = ?", stmt -> {
stmt.setBoolean(1, payed);
stmt.setLong(2, itemId);
});
}
public int paymentByEventAndUser(long eventId, long userId, boolean payed) throws SQLException {
@Language("SQL")
var query = """
UPDATE "order_item_to_user"
SET "payed" = ?
WHERE "order_item_to_user"."user_id" = ?
AND EXISTS(
SELECT *
FROM "order_item"
JOIN "order" ON "order_item"."order_id" = "order"."id"
WHERE "order"."event_id" = ? AND "order_item"."id" = "order_item_to_user"."order_item_id"
)
""";
return database.executeUpdate(query, stmt -> {
stmt.setBoolean(1, payed);
stmt.setLong(2, userId);
stmt.setLong(3, eventId);
});
}
public void paymentByItemAndUser(long itemId, long userId, boolean payed) throws SQLException {
database.executeUpdate("UPDATE order_item_to_user SET payed = ? WHERE order_item_id = ? AND user_id = ?", stmt -> {
stmt.setBoolean(1, payed);
stmt.setLong(2, itemId);
stmt.setLong(3, userId);
});
}
// Order
public @NotNull Optional<Pizza> getPizzaById(long id) throws SQLException {
return database.findById(Pizza.class, id);
}
public @NotNull Pizza getPizza(long restaurantId, @NotNull String nameOrNummer) throws SQLException {
try {
var nummer = Integer.parseInt(nameOrNummer);
return getPizzaByNummer(restaurantId, nummer);
} catch (NumberFormatException _) {
return getPizzaByName(restaurantId, nameOrNummer);
}
}
private @NotNull Pizza getPizzaByNummer(long restaurantId, int nummer) throws SQLException {
@Language("SQL")
var query = "SELECT * FROM pizza WHERE restaurant_id = ? AND number = ?";
return database.executeUniqueQuery(Pizza.class, query, stmt -> {
stmt.setLong(1, restaurantId);
stmt.setInt(2, nummer);
}).orElseThrow(() -> new PizzaBotException("Diese Pizza kenne ich nicht."));
}
private @NotNull Pizza getPizzaByName(long restaurantId, @NotNull String name) throws SQLException {
try {
@Language("SQL")
var query = "SELECT * FROM pizza WHERE restaurant_id = ? AND name LIKE ?";
return database.executeUniqueQuery(Pizza.class, query, stmt -> {
stmt.setLong(1, restaurantId);
stmt.setString(2, name + "%");
}).orElseThrow(() -> new PizzaBotException("Diese Pizza kenne ich nicht."));
} catch (NonUniqueResultException _) {
throw new PizzaBotException("Diese Pizza kenne ich nicht.");
}
}
public long order(@NotNull OrderDetail order, @NotNull User user, @NotNull Pizza pizza, @Nullable String notes) throws SQLException {
return database.transaction(() -> {
try {
database.insert(user);
} catch (SQLException _) {}
var item = new OrderItem(-1, order.id(), pizza.id(), notes);
var itemId = database.insert(item);
var mapping = new OrderItemToUser(itemId, user.id(), false);
database.insert(mapping);
return itemId;
});
}
public void add(@NotNull OrderItem item, @NotNull User user, boolean payed) throws SQLException {
database.insert(new OrderItemToUser(item.id(), user.id(), payed));
}
public @NotNull List<@NotNull UserWithPayment> getUsersByItem(@NotNull OrderItem item) throws SQLException {
@Language("SQL")
var query = """
SELECT
"user"."id" AS "id",
"user"."username" AS "username",
"order_item_to_user"."order_item_id" AS "order_item_id",
"order_item_to_user"."payed" AS "payed"
FROM "user"
JOIN "order_item_to_user" ON "user"."id" = "order_item_to_user"."user_id"
WHERE "order_item_to_user"."order_item_id" = ?
""";
return database.executeQuery(UserWithPayment.class, query, stmt -> stmt.setLong(1, item.id()));
}
// Revoke
public @NotNull Optional<OrderItemSummary> getItemById(long itemId) throws SQLException {
return database.transaction(() -> {
var item = database.findById(OrderItem.class, itemId);
if (item.isEmpty()) return Optional.empty();
var pizza = database.findById(Pizza.class, item.get().pizzaId()).orElseThrow();
var users = getUsersByItem(item.get());
return Optional.of(new OrderItemSummary(item.get(), pizza, users));
});
}
public @NotNull Optional<OrderItem> getItemByPizzaName(long orderId, long userId, @NotNull String name) throws SQLException {
@Language("SQL")
var query = """
SELECT "order_item".*
FROM "order_item"
JOIN "pizza" ON "order_item"."pizza_id" = "pizza"."id"
JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id"
WHERE "order_item"."order_id" = ? AND "order_item_to_user"."user_id" = ? AND "pizza"."name" LIKE ?
""";
return database.executeUniqueQuery(OrderItem.class, query, stmt -> {
stmt.setLong(1, orderId);
stmt.setLong(2, userId);
stmt.setString(3, name + "%");
});
}
public @NotNull Optional<OrderItem> getItemByUser(long orderId, long userId) throws SQLException {
@Language("SQL")
var query = """
SELECT "order_item".*
FROM "order_item"
JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id"
WHERE "order_item"."order_id" = ? AND "order_item_to_user"."user_id" = ?
""";
return database.executeUniqueQuery(OrderItem.class, query, stmt -> {
stmt.setLong(1, orderId);
stmt.setLong(2, userId);
});
}
public @NotNull List<@NotNull OrderItem> getItemsByUser(long orderId, long userId) throws SQLException {
@Language("SQL")
var query = """
SELECT "order_item".*
FROM "order_item"
JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id"
WHERE "order_item"."order_id" = ? AND "order_item_to_user"."user_id" = ?
""";
return database.executeQuery(OrderItem.class, query, stmt -> {
stmt.setLong(1, orderId);
stmt.setLong(2, userId);
});
}
public boolean delete(@NotNull OrderItem item) throws SQLException {
return database.delete(OrderItem.class, item.id());
}
// Summary
public @NotNull Order getOrderByEventAndName(long eventId, @NotNull String name) throws SQLException {
try {
@Language("SQL")
var query = """
SELECT "order".*
FROM "order"
WHERE "order"."event_id" = ? AND "order"."name" LIKE ?
""";
return database.executeUniqueQuery(Order.class, query, stmt -> {
stmt.setLong(1, eventId);
stmt.setString(2, name + "%");
}).orElseThrow(() -> new PizzaBotException("Diese Bestellung kenne ich nicht."));
} catch (NonUniqueResultException ex) {
throw new PizzaBotException("Die Bestellung ist nicht eindeutig.");
}
}
// Order List
public @NotNull List<@NotNull OrderItemDetail> getItemDetailByEvent(long eventId) throws SQLException {
return database.findAllWhere(OrderItemDetail.class, OrderItemDetail.Fields.eventId, eventId);
}
public @NotNull List<@NotNull OrderItemDetail> getItemDetailByOrder(long orderId) throws SQLException {
return database.findAllWhere(OrderItemDetail.class, OrderItemDetail.Fields.orderId, orderId);
}
public @NotNull List<@NotNull OrderItemDetail> getItemDetailByEventAndUser(long eventId, long userId) throws SQLException {
@Language("SQL")
var query = """
SELECT *
FROM order_item_detail
WHERE event_id = ? AND EXISTS(
SELECT * FROM "order_item_to_user"
WHERE "user_id" = ? AND "order_item_id" = "order_item_detail"."id"
)
""";
return database.executeQuery(OrderItemDetail.class, query, stmt -> {
stmt.setLong(1, eventId);
stmt.setLong(2, userId);
});
}
}

@ -0,0 +1,18 @@
package eu.jonahbauer.chat.bot.pizza.model;
import eu.jonahbauer.chat.bot.pizza.PizzaBotException;
import eu.jonahbauer.chat.bot.pizza.model.table.OrderItem;
import eu.jonahbauer.chat.bot.pizza.model.table.Pizza;
import eu.jonahbauer.chat.bot.pizza.model.table.User;
import org.jetbrains.annotations.NotNull;
import java.util.List;
public record OrderItemSummary(@NotNull OrderItem item, @NotNull Pizza pizza, @NotNull List<UserWithPayment> users) {
public @NotNull OrderItemSummary checkUserAccess(@NotNull User user) {
if (users.stream().noneMatch(u -> u.id() == user.id())) {
throw new PizzaBotException("Du bist nicht berechtigt, diese Bestellung zu bearbeiten.");
}
return this;
}
}

@ -0,0 +1,14 @@
package eu.jonahbauer.chat.bot.pizza.model;
import lombok.experimental.FieldNameConstants;
@FieldNameConstants
public record OrderItemToUser(long orderItemId, long userId, long parts, boolean payed) {
public OrderItemToUser(long orderItemId, long userId, boolean payed) {
this(orderItemId, userId, 1, payed);
}
public OrderItemToUser withPayed(boolean payed) {
return new OrderItemToUser(orderItemId, userId, parts, payed);
}
}

@ -0,0 +1,40 @@
package eu.jonahbauer.chat.bot.pizza.model;
import org.jetbrains.annotations.NotNull;
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.tabular;
public record Receipt(double total, double tipped, double pending, double pendingTipped) {
public Receipt(double total, boolean payed, double tip) {
this(total, total * tip, payed ? 0 : total, payed ? 0 : total * tip);
}
public boolean payed() {
return pending == 0;
}
public boolean unpayed() {
return total == pending;
}
public @NotNull String totalAsString() {
return tabular(total, 0) + " €" + (tipped != 0 ? " (" + tabular(tipped, 0) + " €)" : "");
}
public @NotNull String pendingAsString() {
return tabular(pending, 0) + " €" + (pendingTipped != 0 ? " (" + tabular(pendingTipped, 0) + " €)" : "");
}
public static @NotNull Receipt empty() {
return new Receipt(0, 0, 0, 0);
}
public static @NotNull Receipt sum(@NotNull Receipt first, @NotNull Receipt second) {
return new Receipt(
first.total + second.total,
first.tipped + second.tipped,
first.pending + second.pending,
first.pendingTipped + second.pendingTipped
);
}
}

@ -0,0 +1,7 @@
package eu.jonahbauer.chat.bot.pizza.model;
import org.jetbrains.annotations.NotNull;
public record UserWithPayment(long id, @NotNull String username, long orderItemId, boolean payed) {
}

@ -0,0 +1,9 @@
package eu.jonahbauer.chat.bot.pizza.model.table;
import eu.jonahbauer.chat.bot.database.Entity;
import lombok.experimental.FieldNameConstants;
import org.jetbrains.annotations.NotNull;
@FieldNameConstants
public record Event(long id, @NotNull String name, long adminId, boolean active) implements Entity<Event> {
}

@ -0,0 +1,33 @@
package eu.jonahbauer.chat.bot.pizza.model.table;
import eu.jonahbauer.chat.bot.database.Entity;
import lombok.experimental.FieldNameConstants;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.util.Objects;
@FieldNameConstants
public record Order(
long id,
long eventId,
long restaurantId,
@NotNull String name,
@NotNull Instant deadline,
@Nullable Double payment
) implements Entity<Order>, Comparable<Order> {
public Order {
Objects.requireNonNull(deadline);
}
public @NotNull Order withPayment(@Nullable Double payment) {
return new Order(id, eventId, restaurantId, name, deadline, payment);
}
@Override
public int compareTo(@NotNull Order o) {
return this.deadline.compareTo(o.deadline);
}
}

@ -0,0 +1,14 @@
package eu.jonahbauer.chat.bot.pizza.model.table;
import eu.jonahbauer.chat.bot.database.Entity;
import lombok.experimental.FieldNameConstants;
import org.jetbrains.annotations.Nullable;
@FieldNameConstants
public record OrderItem(
long id,
long orderId,
long pizzaId,
@Nullable String notes
) implements Entity<OrderItem> {
}

@ -0,0 +1,33 @@
package eu.jonahbauer.chat.bot.pizza.model.table;
import eu.jonahbauer.chat.bot.database.Entity;
import lombok.experimental.FieldNameConstants;
import org.jetbrains.annotations.NotNull;
import java.util.Locale;
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.tabular;
@FieldNameConstants
public record Pizza(
long id,
long restaurantId,
int number,
@NotNull String name,
@NotNull String ingredients,
double price
) implements Entity<Pizza>, Comparable<Pizza> {
public @NotNull String toPrettyString() {
var number = tabular(number());
var price = tabular(price(), 5);
var name = name().toUpperCase(Locale.ROOT);
var ingredients = ingredients();
return STR."\{number} | \{price} € | \{name} \{ingredients}";
}
@Override
public int compareTo(@NotNull Pizza o) {
return Integer.compare(number, o.number);
}
}

@ -0,0 +1,20 @@
package eu.jonahbauer.chat.bot.pizza.model.table;
import eu.jonahbauer.chat.bot.database.Entity;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
public record Restaurant(
long id,
@NotNull String name,
@NotNull String city,
@Nullable String phone,
@Nullable String notes
) implements Entity<Restaurant> {
public Restaurant {
Objects.requireNonNull(name);
Objects.requireNonNull(city);
}
}

@ -0,0 +1,13 @@
package eu.jonahbauer.chat.bot.pizza.model.table;
import lombok.experimental.FieldNameConstants;
import org.jetbrains.annotations.NotNull;
import java.util.Objects;
@FieldNameConstants
public record User(long id, @NotNull String username) {
public User {
Objects.requireNonNull(username);
}
}

@ -0,0 +1,42 @@
package eu.jonahbauer.chat.bot.pizza.model.view;
import eu.jonahbauer.chat.bot.pizza.PizzaBotException;
import eu.jonahbauer.chat.bot.pizza.model.table.Order;
import eu.jonahbauer.chat.bot.pizza.model.table.User;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
public record OrderDetail(
// Order
long id,
@NotNull String name,
@NotNull Instant deadline,
@Nullable Double payment,
// Event
long eventId,
@NotNull String eventName,
long adminId,
// Restaurant
long restaurantId,
@NotNull String restaurantName
) {
public @NotNull Order order() {
return new Order(id, eventId, restaurantId, name, deadline, payment);
}
public @NotNull OrderDetail checkDeadline() {
if (Instant.now().isAfter(deadline())) {
throw new PizzaBotException("Du kannst diese Bestellung nicht mehr bearbeiten, da der Bestellannahmeschluss schon vorbei ist.");
}
return this;
}
public @NotNull OrderDetail checkAdminAccess(@NotNull User user) {
if (adminId() != user.id()) {
throw new PizzaBotException("Du bist nicht berechtigt, diese Aktion durchzuführen.");
}
return this;
}
}

@ -0,0 +1,79 @@
package eu.jonahbauer.chat.bot.pizza.model.view;
import eu.jonahbauer.chat.bot.pizza.model.Receipt;
import eu.jonahbauer.chat.bot.pizza.model.table.Order;
import eu.jonahbauer.chat.bot.pizza.model.table.Pizza;
import eu.jonahbauer.chat.bot.pizza.model.table.User;
import lombok.experimental.FieldNameConstants;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.util.Locale;
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.strikethrough;
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.tabular;
@FieldNameConstants
public record OrderItemDetail(
// OrderItem
long id,
@Nullable String notes,
// Order
long orderId,
@NotNull String orderName,
@NotNull Instant orderDeadline,
@Nullable Double orderPayment,
// Event
long eventId,
// Restaurant
long restaurantId,
@NotNull String restaurantName,
// Pizza
long pizzaId,
int pizzaNumber,
@NotNull String pizzaName,
double pizzaPrice,
// User
long userId,
@NotNull String userName,
long userParts,
boolean userPayed,
long userCount
) {
public @NotNull Order order() {
return new Order(orderId, eventId, restaurantId, orderName, orderDeadline, orderPayment);
}
public @NotNull Pizza pizza() {
return new Pizza(pizzaId, restaurantId, pizzaNumber, pizzaName, "N/A", pizzaPrice);
}
public @NotNull User user() {
return new User(userId, userName);
}
public @NotNull Receipt receipt(double tip) {
return new Receipt(userPrice(), userPayed(), tip);
}
public double userPrice() {
return userParts() * pizzaPrice() / userCount();
}
public @NotNull String toPrettyString() {
String price;
if (userCount() == 1) {
price = tabular(pizzaPrice(), 0) + " €";
} else {
price = tabular(pizzaPrice(), 0) + " € / " + tabular(userCount(), 0) + " = " + tabular(userPrice(), 0) + " €";
}
if (userPayed()) price = strikethrough(price);
if (notes() != null) {
return STR."\{id()} | \{pizzaName().toUpperCase(Locale.ROOT)} \{notes()} | \{price}";
} else {
return STR."\{id()} | \{pizzaName().toUpperCase(Locale.ROOT)} | \{price}";
}
}
}

@ -0,0 +1,90 @@
package eu.jonahbauer.chat.bot.pizza.util;
import eu.jonahbauer.chat.bot.pizza.PizzaBotException;
import lombok.experimental.UtilityClass;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@UtilityClass
public class ArgumentParser {
public static @NotNull List<@NotNull String> parse(@NotNull String string) {
var list = new ArrayList<String>();
var quote = (char) 0;
var escaped = false;
var current = new StringBuilder();
var it = string.chars().iterator();
while (it.hasNext()) {
var chr = (char) it.nextInt();
if (escaped) {
if (chr != '\n') current.append(chr);
} else if (chr == '\\' && quote != '\'') {
escaped = true;
} else if (quote == 0 && chr == '"') {
quote = '"';
} else if (quote == 0 && chr == '\'') {
quote = '\'';
} else if (quote == chr) {
quote = 0;
} else if (chr == ' ' || chr == '\t') {
if (!current.isEmpty()) {
list.add(current.toString());
current.setLength(0);
}
} else if (chr == '\n') {
break;
} else {
current.append(chr);
}
}
if (escaped) {
throw new IllegalArgumentException("incomplete escape sequence");
} else if (quote != 0) {
throw new IllegalArgumentException("incomplete quote");
}
if (!current.isEmpty()) {
list.add(current.toString());
}
return Collections.unmodifiableList(list);
}
public static boolean isLong(@NotNull String string) {
try {
Long.parseLong(string);
return true;
} catch (NumberFormatException _) {
return false;
}
}
public static long toLong(@NotNull String string) {
try {
return Long.parseLong(string);
} catch (NumberFormatException ex) {
throw new PizzaBotException(string + " ist keine gültige Zahl.");
}
}
public static boolean isDouble(@NotNull String string) {
try {
Double.parseDouble(string);
return true;
} catch (NumberFormatException _) {
return false;
}
}
public static double toDouble(@NotNull String string) {
try {
return Double.parseDouble(string);
} catch (NumberFormatException ex) {
throw new PizzaBotException(string + " ist keine gültige Zahl.");
}
}
}

@ -0,0 +1,81 @@
package eu.jonahbauer.chat.bot.pizza.util;
import lombok.experimental.UtilityClass;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.TestOnly;
import java.util.List;
import java.util.Locale;
import java.util.function.Function;
import java.util.stream.Collectors;
@UtilityClass
public class FormatUtils {
public static @NotNull String tabular(long number) {
return tabular(number, 2);
}
public static @NotNull String tabular(long number, int width) {
return tabular(String.format(Locale.ROOT, "%" + (width == 0 ? "" : width) + "d", number));
}
public static @NotNull String tabular(double number) {
return tabular(number, 6);
}
public static @NotNull String tabular(double number, int width) {
return tabular(String.format(Locale.ROOT, "%" + (width == 0 ? "" : width) + ".2f", number));
}
private static @NotNull String tabular(@NotNull String string) {
var out = new StringBuilder();
for (int i = 0, length = string.length(); i < length; i++) {
var chr = string.charAt(i);
if (chr == ' ') {
out.append('\u2007'); // figure space
} else {
out.append('\u2060'); // word-joiner to prevent kerning
out.append(chr);
}
}
return out.toString();
}
@SuppressWarnings("UnnecessaryUnicodeEscape")
public static @NotNull String strikethrough(@NotNull String string) {
var out = new StringBuilder();
for (int i = 0, length = string.length(); i < length; i++) {
out.append(string.charAt(i)).append('\u0336');
}
return out.toString();
}
@TestOnly
@SuppressWarnings("UnnecessaryUnicodeEscape")
public static @NotNull String strip(@NotNull String string) {
var out = new StringBuilder();
for (int i = 0, length = string.length(); i < length; i++) {
var chr = string.charAt(i);
if (chr != '\u0336' && chr != '\u0335' && chr != '\u2007' && chr != '\u2060') {
out.append(chr);
}
}
return out.toString();
}
public static @NotNull String join(@NotNull List<@NotNull String> strings) {
return switch (strings.size()) {
case 0 -> "";
case 1 -> strings.getFirst();
default -> String.join(", ", strings.subList(0, strings.size() - 1)) + " und " + strings.getLast();
};
}
public static <T> @NotNull String join(@NotNull List<@NotNull T> items, @NotNull Function<? super T, String> toString) {
return join(items.stream().map(toString).toList());
}
public static <T> @NotNull String join(@NotNull String separator, @NotNull List<@NotNull T> items, @NotNull Function<? super T, String> toString) {
return items.stream().map(toString).collect(Collectors.joining(separator));
}
}

@ -0,0 +1,9 @@
package eu.jonahbauer.chat.bot.pizza.util;
import org.jetbrains.annotations.NotNull;
public record Pair<F, S>(F first, S second) {
public static <F, S> @NotNull Pair<F, S> of(F first, S second) {
return new Pair<>(first, second);
}
}

@ -0,0 +1,16 @@
import eu.jonahbauer.chat.bot.api.ChatBot;
import eu.jonahbauer.chat.bot.pizza.PizzaBot;
module eu.jonahbauer.chat.bot.pizza {
exports eu.jonahbauer.chat.bot.pizza.model.table to eu.jonahbauer.chat.bot.database;
exports eu.jonahbauer.chat.bot.pizza.model.view to eu.jonahbauer.chat.bot.database;
exports eu.jonahbauer.chat.bot.pizza.model to eu.jonahbauer.chat.bot.database;
requires eu.jonahbauer.chat.bot.api;
requires eu.jonahbauer.chat.bot.database;
requires org.slf4j;
requires static lombok;
provides ChatBot with PizzaBot;
}

@ -0,0 +1,115 @@
CREATE TABLE "user"
(
"id" INTEGER PRIMARY KEY,
"username" VARCHAR(255) NOT NULL,
CONSTRAINT "uq_username" UNIQUE ("username")
);
CREATE TABLE "restaurant"
(
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"name" VARCHAR(255) NOT NULL,
"city" VARCHAR(255) NOT NULL,
"phone" VARCHAR(255) NULL DEFAULT NULL,
"notes" VARCHAR(255) NULL DEFAULT NULL
);
CREATE TABLE "pizza"
(
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"restaurant_id" INTEGER NOT NULL,
"number" INTEGER NOT NULL,
"name" VARCHAR(255) NOT NULL,
"ingredients" VARCHAR(255) NOT NULL,
"price" VARCHAR(255) NOT NULL,
CONSTRAINT "fk_pizza_restaurant" FOREIGN KEY ("restaurant_id") REFERENCES "restaurant" ("id") ON DELETE CASCADE,
CONSTRAINT "uq_pizza_number" UNIQUE ("restaurant_id", "number")
);
CREATE TABLE "event"
(
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"name" VARCHAR(255) NOT NULL,
"admin_id" INTEGER NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT "fk_event_admin" FOREIGN KEY ("admin_id") REFERENCES "user" ("id")
);
CREATE UNIQUE INDEX "uq_event_active" ON "event" ("active")
WHERE "active" = TRUE;
CREATE TABLE "order"
(
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"event_id" INTEGER NOT NULL,
"restaurant_id" INTEGER NOT NULL,
"name" VARCHAR(255) NOT NULL,
"deadline" TIMESTAMP NOT NULL,
"payment" DOUBLE NULL,
CONSTRAINT "fk_order_event" FOREIGN KEY ("event_id") REFERENCES "event" ("id"),
CONSTRAINT "fk_order_restaurant" FOREIGN KEY ("restaurant_id") REFERENCES "restaurant" ("id")
);
CREATE TABLE "order_item"
(
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"order_id" INTEGER NOT NULL,
"pizza_id" INTEGER NOT NULL,
"notes" VARCHAR(255),
CONSTRAINT "fk_order_item_order" FOREIGN KEY ("order_id") REFERENCES "order" ("id") ON DELETE CASCADE,
CONSTRAINT "fk_order_item_pizza" FOREIGN KEY ("pizza_id") REFERENCES "pizza" ("id")
);
CREATE TABLE "order_item_to_user"
(
"order_item_id" INTEGER NOT NULL,
"user_id" INTEGER NOT NULL,
"parts" INTEGER NOT NULL DEFAULT 1,
"payed" BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY ("order_item_id", "user_id"),
CONSTRAINT "fk_order_item_to_user_order_item" FOREIGN KEY ("order_item_id") REFERENCES "order_item" ("id") ON DELETE CASCADE,
CONSTRAINT "fk_order_item_to_user_user_id" FOREIGN KEY ("user_id") REFERENCES "user" ("id")
);
CREATE VIEW "order_item_detail"
AS
SELECT "order_item"."id",
"order_item"."notes",
"order"."id" AS "order_id",
"order"."name" AS "order_name",
"order"."deadline" AS "order_deadline",
"order"."payment" AS "order_payment",
"order"."event_id" AS "event_id",
"restaurant"."id" AS "restaurant_id",
"restaurant"."name" AS "restaurant_name",
"pizza"."id" AS "pizza_id",
"pizza"."number" AS "pizza_number",
"pizza"."name" AS "pizza_name",
"pizza"."price" AS "pizza_price",
"order_item_to_user"."user_id" AS "user_id",
"order_item_to_user"."payed" AS "user_payed",
"order_item_to_user"."parts" AS "user_parts",
"user"."username" AS "user_name",
(SELECT SUM("parts") FROM "order_item_to_user" WHERE "order_item_id" = "order_item"."id") AS "user_count"
FROM "order"
JOIN "restaurant" ON "order"."restaurant_id" = "restaurant"."id"
JOIN "order_item" ON "order"."id" = "order_item"."order_id"
JOIN "pizza" ON "order_item"."pizza_id" = "pizza"."id"
JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id"
JOIN "user" ON "order_item_to_user"."user_id" = "user"."id";
CREATE VIEW "order_detail"
AS
SELECT "order"."id",
"order"."name",
"order"."deadline",
"order"."payment",
"event"."id" AS "event_id",
"event"."name" AS "event_name",
"event"."admin_id",
"event"."active",
"restaurant"."id" AS "restaurant_id",
"restaurant"."name" AS "restaurant_name"
FROM "event"
JOIN "order" ON "event"."id" = "order"."event_id"
JOIN "restaurant" ON "order"."restaurant_id" = "restaurant"."id";

@ -0,0 +1,38 @@
INSERT INTO "restaurant"("name", "city", "phone", "notes")
VALUES ('Hell''s Pizza', 'Sonthofen', '08321 7881930', '2023');
WITH temp(id) AS (SELECT last_insert_rowid())
INSERT INTO "pizza"("restaurant_id", "number", "name", "ingredients", "price")
SELECT * FROM temp CROSS JOIN (
VALUES (10, 'Pizzabrot', 'mit Tomaten & Knoblauch', 6.50),
(11, 'Margherita', 'mit Tomaten, Mozzarella & Basilikum', 9.50),
(12, 'Salami', 'mit Tomaten, Mozzarella & Salami (gegen Aufpreis mit Rindersalami)', 10.00),
(13, 'Prosciutto', 'mit Tomaten, Mozzarella & Schinken', 10.00),
(14, 'Salsiccia', 'mit Tomaten, Mozzarella & scharfer Salami', 9.50),
(15, 'Funghi', 'mit Tomaten, Mozzarella & Champignons', 9.50),
(16, 'Romana', 'mit Tomaten, Mozzarella, Salami & Champignons', 10.50),
(17, 'Hawaii', 'mit Tomaten, Mozzarella, Schinken & Ananas', 10.50),
(18, 'Regina', 'mit Tomaten, Mozzarella, Schinken & Champignons', 10.50),
(19, 'Toscana', 'mit Tomaten, Mozzarella, Salami, Champignons & Peperoni', 11.00),
(20, 'Tonno', 'mit Tomaten, Mozzarella, Thunfisch, Zwiebeln & Oliven', 11.00),
(21, 'Quattro Formaggi', 'mit Tomaten & vier Käsesorten', 10.50),
(22, 'Calzone', 'mit Tomaten, Mozzarella, Schinken & Champignons', 10.50),
(24, 'Diavolo', 'mit Tomaten, Mozzarella, scharfer Salami, Peperoni & Oliven', 11.00),
(25, 'Rucoletta', 'mit Tomaten, Mozzarella, Rucola, Tomatenstücken & Parmesan', 11.00),
(26, 'Quattro Stagioni', 'mit Tomaten, Mozzarella, Schinken, Salami, Champignons, Artischocken, Sardellen & Oliven', 11.00),
(27, 'Vegana', 'mit Tomaten & verschiedenem Gemüse', 10.50),
(28, 'Siciliana', 'mit Tomaten, Mozzarella, Peperoni, Kapern, Sardellen, Oliven & Knoblauch', 10.00),
(29, 'Rustica', 'mit Tomaten, Mozzarella, Speck, Champignons, Gorgonzola & Knoblauch', 11.00),
(30, 'Caprese', 'mit Tomaten, Mozzarella, Tomatenscheiben & Basilikum', 11.00),
(31, 'Calabria', 'mit Tomaten, Mozzarella, scharfer Salami, Zwiebeln & original Schafskäse', 12.00),
(32, 'Verdura', 'mit Tomaten, Mozzarella, gegrilltem Gemüse & Knoblauch', 12.00),
(33, 'Mare', 'mit Tomaten, Meeresfrüchten & Knoblauch', 13.00),
(35, 'Mafiosa', 'mit Tomaten, Mozzarella, Speck, scharfer Salami, Champignons, Spinat & Peperoni', 12.00),
(36, 'Reale', 'mit Tomaten, Mozzarella, original Parmaschinken, Rucola & Parmesan', 13.90),
(38, 'Mediterranea', 'mit Tomatenscheiben, Mozzarella, Thunfisch, Kapern & Rucola', 12.00),
(39, 'Tricolore', 'mit Büffel-Mozzarella, Tomatenscheiben & Rucola-Pesto', 13.00),
(40, 'Alfio Spezial', 'mit Mozzarella, Tomatenstücken, Salsiccia, Zwiebeln, Parmesan, Oliven & Basilikum', 12.00),
(41, 'Allgäuer', 'mit Tomaten, Mozzarella, Speck, Bergkäse, Röstzwiebeln & Knoblauch', 11.50),
(42, 'Buon Gustaio', 'mit Tomaten, Mozzarella, Spinat, Gorgonzola & Knoblauch', 11.00),
(44, 'Mista', 'mit Tomaten, Mozzarella, Schinken, Salami, Pilzen & Thunfisch', 11.00)
);

@ -0,0 +1,46 @@
-- Hell's Pizza Sonthofen
-- Stand: Oktober 2023
INSERT INTO "restaurant"("name", "city", "phone", "notes")
VALUES ('Hell''s Pizza', 'Sonthofen', '08321 7881930', '2024');
WITH temp(id) AS (SELECT last_insert_rowid())
INSERT INTO "pizza"("restaurant_id", "number", "name", "ingredients", "price")
SELECT * FROM temp CROSS JOIN (
VALUES (10, 'Pizzabrot', 'mit Tomaten & Knoblauch', 7.00),
(11, 'Margherita', 'mit Tomaten, Mozzarella & Basilikum', 10.00),
(12, 'Salami', 'mit Tomaten, Mozzarella & Salami (gegen Aufpreis mit Rindersalami)', 10.50),
(13, 'Prosciutto', 'mit Tomaten, Mozzarella & Schinken', 10.50),
(14, 'Salsiccia', 'mit Tomaten, Mozzarella & scharfer Salami', 10.50),
(15, 'Funghi', 'mit Tomaten, Mozzarella & Champignons', 10.00),
(16, 'Romana', 'mit Tomaten, Mozzarella, Salami & Champignons', 11.00),
(17, 'Hawaii', 'mit Tomaten, Mozzarella, Schinken & Ananas', 11.00),
(18, 'Regina', 'mit Tomaten, Mozzarella, Schinken & Champignons', 11.00),
(19, 'Toscana', 'mit Tomaten, Mozzarella, Salami, Champignons & Peperoni', 11.50),
(20, 'Tonno', 'mit Tomaten, Mozzarella, Thunfisch, Zwiebeln & Oliven', 11.50),
(21, 'Quattro Formaggi', 'mit Tomaten & vier Käsesorten', 11.00),
(22, 'Calzone', 'mit Tomaten, Mozzarella, Schinken & Champignons', 11.00),
(23, 'Capriccio', 'mit Tomaten, Mozzarella, Schinken, Champignons, Artischocken, Eier', 13.00),
(24, 'Diavolo', 'mit Tomaten, Mozzarella, scharfer Salami, Peperoni & Oliven', 11.50),
(25, 'Rucoletta', 'mit Tomaten, Mozzarella, Rucola, Tomatenstücken & Parmesan', 12.00),
(26, 'Quattro Stagioni', 'mit Tomaten, Mozzarella, Schinken, Salami, Champignons, Artischocken, Sardellen & Oliven', 12.90),
(27, 'Vegana', 'mit Tomaten & verschiedenem Gemüse', 11.00),
(28, 'Siciliana', 'mit Tomaten, Mozzarella, Peperoni, Kapern, Sardellen, Oliven & Knoblauch', 10.50),
(29, 'Rustica', 'mit Tomaten, Mozzarella, Speck, Champignons, Gorgonzola & Knoblauch', 11.50),
(30, 'Caprese', 'mit Tomaten, Mozzarella, Tomatenscheiben & Basilikum', 11.50),
(31, 'Calabria', 'mit Tomaten, Mozzarella, scharfer Salami, Zwiebeln & original Schafskäse', 12.00),
(32, 'Verdura', 'mit Tomaten, Mozzarella, gegrilltem Gemüse & Knoblauch', 12.00),
(33, 'Mare', 'mit Tomaten, Meeresfrüchten & Knoblauch', 13.50),
(34, 'Contadina', 'mit Büffelmozzarella, Parmaschinken Julienne, Champignons & Rucola', 13.90),
(35, 'Mafiosa', 'mit Tomaten, Mozzarella, Speck, scharfer Salami, Champignons, Spinat & Peperoni', 12.90),
(36, 'Reale', 'mit Tomaten, Mozzarella, original Parmaschinken, Rucola & Parmesan', 14.90),
(38, 'Mediterranea', 'mit Tomatenscheiben, Mozzarella, Thunfisch, Kapern & Rucola', 12.90),
(39, 'Tricolore', 'mit Büffel-Mozzarella, Tomatenscheiben & Rucola-Pesto', 13.90),
(40, 'Alfio Spezial', 'mit Mozzarella, Tomatenstücken, Salsiccia, Zwiebeln, Parmesan, Oliven & Basilikum', 12.90),
(41, 'Allgäuer', 'mit Tomaten, Mozzarella, Speck, Bergkäse, Röstzwiebeln & Knoblauch DAS ORIGINAL', 12.90),
(42, 'Buon Gustaio', 'mit Tomaten, Mozzarella, Spinat, Gorgonzola & Knoblauch', 11.90),
(44, 'Mista', 'mit Tomaten, Mozzarella, Schinken, Salami, Pilzen & Thunfisch', 11.90),
(48, 'Cudduruni', 'Tomatenstücken, Zwiebeln, Parmesan, Oliven, Basilikum, Chili-Olivenöl NEU', 13.90),
(49, 'Bufalina', 'mit Tomaten, Büffelmozzarella, frischer Basilikum und Olivenöl NEU', 14.90)
);

@ -0,0 +1,629 @@
package eu.jonahbauer.chat.bot.pizza;
import eu.jonahbauer.chat.bot.api.Message.Post;
import eu.jonahbauer.chat.bot.pizza.model.*;
import eu.jonahbauer.chat.bot.pizza.model.table.*;
import eu.jonahbauer.chat.bot.test.MockChat;
import eu.jonahbauer.chat.bot.database.Database;
import eu.jonahbauer.chat.bot.test.ChatBotFactoryAccess;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.*;
import static java.util.FormatProcessor.FMT;
import static org.junit.jupiter.api.Assertions.*;
class PizzaBotTest {
@TempDir
static Path temp;
Database database;
MockChat chat;
PizzaBot bot;
User admin;
User user;
User user2;
Event event;
Order order0; // alte bestellung (bestellannahmeschluss vorbei)
Order order1; // alte bestellung
Order order; // laufende bestellung
Pizza salami;
Pizza margherita;
@BeforeEach
void init() throws IOException, SQLException {
var file = Files.createTempFile(temp, "database", ".db");
database = new Database("jdbc:sqlite:" + file.toAbsolutePath());
run("init.sql");
run("restaurants/sonthofen_2023.sql");
chat = new MockChat();
bot = ChatBotFactoryAccess.create(() -> new PizzaBot(Collections.singleton("marek"), database));
admin = database.save(new User(0, "Admin"));
user = database.save(new User(1337, "User1"));
user2 = database.save(new User(42, "User2"));
event = database.save(new Event(0, "Veranstaltung", admin.id(), true));
order0 = database.save(new Order(0, event.id(), 1, "28.03.2024", Instant.ofEpochMilli(System.currentTimeMillis() - 3_600_000), 90.0));
order1 = database.save(new Order(0, event.id(), 1, "29.03.2024", Instant.ofEpochMilli(System.currentTimeMillis() + 3_600_000), null));
order = database.save(new Order(0, event.id(), 1, "30.03.2024", Instant.ofEpochMilli(System.currentTimeMillis() + 7_200_000), null));
salami = database.findWhere(Pizza.class, Pizza.Fields.name, "Salami").orElseThrow();
margherita = database.findWhere(Pizza.class, Pizza.Fields.name, "Margherita").orElseThrow();
}
@AfterEach
void print() {
for (var message : chat.getMessages()) {
System.out.println("\n" + message.name() + "\n" + message.message());
}
}
private void run(@NotNull String script) throws SQLException, IOException {
try (var conn = database.getConnection()) {
var runner = new ScriptRunner(conn, false, true);
runner.setLogWriter(null);
try (var stream = Objects.requireNonNull(PizzaBot.class.getResourceAsStream(script))) {
try (var reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
runner.runScript(reader);
}
}
}
}
private void deadline() throws SQLException {
database.delete(order);
database.delete(order1);
order = order0;
}
private void post(@NotNull User user, @NotNull String message) {
var post = new Post(0, user.username(), message, "marek", LocalDateTime.now(), 0L, user.id(), user.username(), "ffffff", 0);
bot.onMessage(chat, post);
}
@Nested
class OrderPizza {
@Test
void orderByPizzaName() throws SQLException {
post(user, "!marek order SALAMI");
var items = database.findAll(OrderItem.class);
assertEquals(1, items.size());
var item = items.getFirst();
assertNull(item.notes());
assertEquals(order.id(), item.orderId());
assertEquals(salami.id(), item.pizzaId());
var mappings = database.findAll(OrderItemToUser.class);
assertEquals(List.of(new OrderItemToUser(item.id(), user.id(), false)), mappings);
}
@Test
void orderByPizzaNameWithNotes() throws SQLException {
post(user, "!marek order SALAMI Hello World");
var items = database.findAll(OrderItem.class);
assertEquals(1, items.size());
var item = items.getFirst();
assertEquals("Hello World", item.notes());
assertEquals(order.id(), item.orderId());
assertEquals(salami.id(), item.pizzaId());
}
@Test
void orderByPizzaNameFailsAfterDeadline() throws SQLException {
deadline();
post(user, "!marek order SALAMI");
var items = database.findAll(OrderItem.class);
assertEquals(0, items.size());
}
@Test
void orderByPizzaNumber() throws SQLException {
post(user, "!marek order 13");
var items = database.findAll(OrderItem.class);
assertEquals(1, items.size());
var item = items.getFirst();
assertNull(item.notes());
assertEquals(order.id(), item.orderId());
var pizza = database.findById(Pizza.class, item.pizzaId());
assertTrue(pizza.isPresent());
assertEquals("Prosciutto", pizza.get().name());
}
@Test
void orderAddById() throws SQLException {
var item = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(admin, STR."!marek order add \{item.id()} \{user2.username()}");
assertEquals(
List.of(
new OrderItemToUser(item.id(), user.id(), false),
new OrderItemToUser(item.id(), user2.id(), false)
),
database.findAll(OrderItemToUser.class)
);
}
@Test
void orderAddByName() throws SQLException {
var item = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(admin, STR."!marek order add \{user.username()} \{user2.username()}");
assertEquals(
List.of(
new OrderItemToUser(item.id(), user.id(), false),
new OrderItemToUser(item.id(), user2.id(), false)
),
database.findAll(OrderItemToUser.class)
);
}
@Test
void orderAddByNameFailsWhenNotUnique() throws SQLException {
var item1 = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item1.id(), user.id(), false));
var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item2.id(), user.id(), false));
post(admin, STR."!marek order add \{user.username()} \{user2.username()}");
assertEquals(
List.of(
new OrderItemToUser(item1.id(), user.id(), false),
new OrderItemToUser(item2.id(), user.id(), false)
),
database.findAll(OrderItemToUser.class)
);
}
@Test
void orderAddByIdFailsWhenAlreadyPayed() throws SQLException {
var item = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
database.insert(new OrderItemToUser(item.id(), user2.id(), true));
post(admin, STR."!marek order add \{item.id()} \{admin.username()}");
assertEquals(
List.of(
new OrderItemToUser(item.id(), user.id(), false),
new OrderItemToUser(item.id(), user2.id(), true)
),
database.findAll(OrderItemToUser.class)
);
}
}
@Nested
class Revoke {
@Test
void orderRevoke() throws SQLException {
var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item1.id(), user.id(), false));
var item2 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item2.id(), user2.id(), false));
post(user, "!marek order revoke");
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item2), items);
}
@Test
void orderRevokeFailsAfterDeadline() throws SQLException {
deadline();
var item = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(user, "!marek order revoke");
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item), items);
}
@Test
void orderRevokeFailsWithMultipleItems() throws SQLException {
var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item1.id(), user.id(), false));
var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item2.id(), user.id(), false));
post(user, "!marek order revoke");
var items = database.findAll(OrderItem.class);
assertEquals(2, items.size());
}
@Test
void orderRevokeFailsWhenItemBelongsToOtherOrder() throws SQLException {
var item = database.save(new OrderItem(0, order1.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(user, "!marek order revoke");
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item), items);
}
@Test
void orderRevokeByNameWithMultipleItems() throws SQLException {
var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item1.id(), user.id(), false));
var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item2.id(), user.id(), false));
post(user, "!marek order revoke SALAMI");
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item1), items);
}
@Test
void orderRevokeByNameFailsWhenItemBelongsToOtherUser() throws SQLException {
var item = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(user2, "!marek order revoke MARGHERITA");
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item), items);
}
@Test
void orderRevokeByNameFailsWhenItemBelongsToOtherOrder() throws SQLException {
var item = database.save(new OrderItem(0, order1.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(user, "!marek order revoke MARGHERITA");
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item), items);
}
@Test
void orderRevokeById() throws SQLException {
var item = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(user, "!marek order revoke " + item.id());
var items = database.findAll(OrderItem.class);
assertEquals(List.of(), items);
}
@Test
void orderRevokeByIdFailsWhenItemBelongsToOtherUser() throws SQLException {
var item = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(user2, "!marek order revoke " + item.id());
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item), items);
}
@Test
void orderRevokeByIdFailsWhenItemBelongsToOtherOrder() throws SQLException {
var item = database.save(new OrderItem(0, order1.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(user, "!marek order revoke " + item.id());
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item), items);
}
@Test
void orderRevokeAll() throws SQLException {
var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item1.id(), user.id(), false));
var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item2.id(), user.id(), false));
var item3 = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item3.id(), user2.id(), false));
post(user, "!marek order revoke --all");
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item3), items);
}
}
@Nested
class Payment {
OrderItem item;
OrderItemToUser itemToUser;
OrderItemToUser itemToUser2;
@BeforeEach
void init() throws SQLException {
item = database.save(new OrderItem(0, order.id(), margherita.id(), null));
itemToUser = database.save(new OrderItemToUser(item.id(), user.id(), false));
itemToUser2 = database.save(new OrderItemToUser(item.id(), user2.id(), false));
}
@Test
void paymentConfirmById() throws SQLException {
post(admin, "!marek payment confirm " + item.id());
var mappings = database.findAll(OrderItemToUser.class);
assertEquals(List.of(itemToUser.withPayed(true), itemToUser2.withPayed(true)), mappings);
}
@Test
void paymentConfirmByIdFailsWhenNotAdmin() throws SQLException {
post(user, "!marek payment confirm " + item.id());
var mappings = database.findAll(OrderItemToUser.class);
assertEquals(List.of(itemToUser, itemToUser2), mappings);
}
@Test
void paymentConfirmByIdAndUser() throws SQLException {
post(admin, "!marek payment confirm " + item.id() + " " + user.username());
var mappings = database.findAll(OrderItemToUser.class);
assertEquals(List.of(itemToUser.withPayed(true), itemToUser2), mappings);
}
@Test
void paymentConfirmByIdAndUserFailsWhenNotAdmin() throws SQLException {
post(user, "!marek payment confirm " + item.id() + " " + user.username());
var mappings = database.findAll(OrderItemToUser.class);
assertEquals(List.of(itemToUser, itemToUser2), mappings);
}
@Test
void paymentConfirmByUser() throws SQLException {
var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null));
var item2ToUser = database.save(new OrderItemToUser(item2.id(), user.id(), false));
var item2ToUser2 = database.save(new OrderItemToUser(item2.id(), user2.id(), false));
post(admin, "!marek payment confirm --all " + user.username());
var mappings = database.findAll(OrderItemToUser.class);
assertEquals(
List.of(itemToUser.withPayed(true), itemToUser2, item2ToUser.withPayed(true), item2ToUser2),
mappings
);
}
}
@Nested
class Pay {
@Test
void pay() throws SQLException {
post(admin, "!marek pay 42.5");
var order = database.findById(Order.class, PizzaBotTest.this.order.id());
assertTrue(order.isPresent());
assertEquals(42.5, order.get().payment());
}
@Test
void payFailsWhenNotAdmin() throws SQLException {
post(user, "!marek pay 42.5");
var order = database.findById(Order.class, PizzaBotTest.this.order.id());
assertTrue(order.isPresent());
assertEquals(PizzaBotTest.this.order, order.get());
}
@Test
void payWithOrderName() throws SQLException {
post(admin, "!marek pay " + order0.name() + " 42.5");
var order0 = database.findById(Order.class, PizzaBotTest.this.order0.id());
assertTrue(order0.isPresent());
assertEquals(42.5, order0.get().payment());
var order = database.findById(Order.class, PizzaBotTest.this.order.id());
assertTrue(order.isPresent());
assertNull(order.get().payment());
}
}
@Nested
class CheckAndSummary {
OrderItem item1;
OrderItemToUser item1ToUser;
OrderItem item2;
OrderItemToUser item2ToUser;
OrderItem item3;
OrderItemToUser item3ToUser;
OrderItem item4;
OrderItemToUser item4ToUser2;
OrderItem item5;
OrderItemToUser item5ToUser;
OrderItemToUser item5ToUser2;
@BeforeEach
void init() throws SQLException {
item1 = database.save(new OrderItem(0, order0.id(), margherita.id(), null));
item1ToUser = database.save(new OrderItemToUser(item1.id(), user.id(), false));
item2 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
item2ToUser = database.save(new OrderItemToUser(item2.id(), user.id(), false));
item3 = database.save(new OrderItem(0, order.id(), salami.id(), null));
item3ToUser = database.save(new OrderItemToUser(item3.id(), user.id(), false));
item4 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
item4ToUser2 = database.save(new OrderItemToUser(item4.id(), user2.id(), false));
item5 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
item5ToUser = database.save(new OrderItemToUser(item5.id(), user.id(), false));
item5ToUser2 = database.save(new OrderItemToUser(item5.id(), user2.id(), false));
}
@Test
void check() {
post(admin, "!marek check");
var message = chat.getMessages().getFirst();
var expected = FMT."""
Rechnung für %s\{order.name()}
alle Angaben ohne Gewähr
%s\{user.username()}: %.2f\{1.5 * margherita.price() + salami.price()}
%s\{user2.username()}: %.2f\{1.5 * margherita.price()}
Gesamt: %.2f\{3 * margherita.price() + salami.price()}
""".trim();
assertEquals(expected, strip(message.message()));
}
@Test
void checkTip() throws SQLException {
database.update(order.withPayment(40d));
post(admin, "!marek check");
var message = chat.getMessages().getFirst();
var expected = FMT."""
Rechnung für %s\{order.name()} (mit Trinkgeld)
alle Angaben ohne Gewähr
%s\{user.username()}: %.2f\{1.5 * margherita.price() + salami.price()} (25.19 )
%s\{user2.username()}: %.2f\{1.5 * margherita.price()} (14.81 )
Gesamt: %.2f\{3 * margherita.price() + salami.price()} (40.00 )
""".trim();
assertEquals(expected, strip(message.message()));
}
@Test
void checkPayed() throws SQLException {
database.executeUpdate("UPDATE order_item_to_user SET payed = TRUE WHERE user_id = " + user.id());
post(admin, "!marek check");
var message = chat.getMessages().getFirst();
var expected = FMT."""
Rechnung für %s\{order.name()}
alle Angaben ohne Gewähr
%s\{user.username()}: %.2f\{1.5 * margherita.price() + salami.price()}
%s\{user2.username()}: %.2f\{1.5 * margherita.price()}
Gesamt: %.2f\{3 * margherita.price() + salami.price()} %.2f\{1.5 * margherita.price()}
""".trim();
assertEquals(expected, strip(message.message()));
assertEquals(strikethrough("User1: " + tabular(1.5 * margherita.price() + salami.price(), 0) + " €"), message.message().split("\n")[3]);
}
@Test
void checkPartialPayed() throws SQLException {
database.executeUpdate("UPDATE order_item_to_user SET payed = TRUE WHERE order_item_id = " + item5.id());
var user1Total = 1.5 * margherita.price() + salami.price();
var user1Remaining = margherita.price() + salami.price();
var user2Total = 1.5 * margherita.price();
var user2Remaining = margherita.price();
post(admin, "!marek check");
var message = chat.getMessages().getFirst();
var expected = FMT."""
Rechnung für %s\{order.name()}
alle Angaben ohne Gewähr
%s\{user.username()}: %.2f\{user1Total} %.2f\{user1Remaining}
%s\{user2.username()}: %.2f\{user2Total} %.2f\{user2Remaining}
Gesamt: %.2f\{user1Total + user2Total} %.2f\{user1Remaining + user2Remaining}
""".trim();
assertEquals(expected, strip(message.message()));
assertEquals(
STR."\{user.username()}: \{strikethrough(tabular(user1Total, 0)+ " ")} \{tabular(user1Remaining, 0)} €",
message.message().split("\n")[3]
);
assertEquals(
STR."\{user2.username()}: \{strikethrough(tabular(user2Total, 0)+ " ")} \{tabular(user2Remaining, 0)} €",
message.message().split("\n")[4]
);
}
@Test
void checkAllTip() throws SQLException {
database.update(order.withPayment(40d));
post(admin, "!marek check --all");
var message = chat.getMessages().getFirst();
var expected = FMT."""
Rechnung für %s\{event.name()} (mit Trinkgeld)
alle Angaben ohne Gewähr
%s\{user.username()}: %.2f\{2.5 * margherita.price() + salami.price()} (115.19 )
%s\{user2.username()}: %.2f\{1.5 * margherita.price()} (14.81 )
Gesamt: %.2f\{4 * margherita.price() + salami.price()} (130.00 )
""".trim();
assertEquals(expected, strip(message.message()));
}
@Test
void summary() {
post(admin, "!marek summary");
var message = chat.getMessages().getFirst();
var expected = FMT."""
Übersicht %s\{order.name()}
3x \{margherita.name().toUpperCase(Locale.ROOT)} (\{user.username()}, \{user.username()}+\{user2.username()}, \{user2.username()})
1x \{salami.name().toUpperCase(Locale.ROOT)} (\{user.username()})
""".trim();
assertEquals(expected, strip(message.message()));
}
@Test
void summaryAll() {
post(admin, "!marek summary --all");
var message = chat.getMessages().getFirst();
var expected = FMT."""
Übersicht %s\{event.name()}
4x \{margherita.name().toUpperCase(Locale.ROOT)} (2x \{user.username()}, \{user.username()}+\{user2.username()}, \{user2.username()})
1x \{salami.name().toUpperCase(Locale.ROOT)} (\{user.username()})
""".trim();
assertEquals(expected, strip(message.message()));
}
}
}

@ -0,0 +1,248 @@
package eu.jonahbauer.chat.bot.pizza;
/*
* Slightly modified version of the com.ibatis.common.jdbc.ScriptRunner class
* from the iBATIS Apache project. Only removed dependency on Resource class
* and a constructor
*/
/*
* Copyright 2004 Clinton Begin
*
* 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
*
* http://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.
*/
import java.io.IOException;
import java.io.LineNumberReader;
import java.io.PrintWriter;
import java.io.Reader;
import java.sql.*;
/**
* Tool to run database scripts
*/
@SuppressWarnings("all")
public class ScriptRunner {
private static final String DEFAULT_DELIMITER = ";";
private Connection connection;
private boolean stopOnError;
private boolean autoCommit;
private PrintWriter logWriter = new PrintWriter(System.out);
private PrintWriter errorLogWriter = new PrintWriter(System.err);
private String delimiter = DEFAULT_DELIMITER;
private boolean fullLineDelimiter = false;
/**
* Default constructor
*/
public ScriptRunner(Connection connection, boolean autoCommit,
boolean stopOnError) {
this.connection = connection;
this.autoCommit = autoCommit;
this.stopOnError = stopOnError;
}
public void setDelimiter(String delimiter, boolean fullLineDelimiter) {
this.delimiter = delimiter;
this.fullLineDelimiter = fullLineDelimiter;
}
/**
* Setter for logWriter property
*
* @param logWriter
* - the new value of the logWriter property
*/
public void setLogWriter(PrintWriter logWriter) {
this.logWriter = logWriter;
}
/**
* Setter for errorLogWriter property
*
* @param errorLogWriter
* - the new value of the errorLogWriter property
*/
public void setErrorLogWriter(PrintWriter errorLogWriter) {
this.errorLogWriter = errorLogWriter;
}
/**
* Runs an SQL script (read in using the Reader parameter)
*
* @param reader
* - the source of the script
*/
public void runScript(Reader reader) throws IOException, SQLException {
try {
boolean originalAutoCommit = connection.getAutoCommit();
try {
if (originalAutoCommit != this.autoCommit) {
connection.setAutoCommit(this.autoCommit);
}
runScript(connection, reader);
} finally {
connection.setAutoCommit(originalAutoCommit);
}
} catch (IOException e) {
throw e;
} catch (SQLException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("Error running script. Cause: " + e, e);
}
}
/**
* Runs an SQL script (read in using the Reader parameter) using the
* connection passed in
*
* @param conn
* - the connection to use for the script
* @param reader
* - the source of the script
* @throws SQLException
* if any SQL errors occur
* @throws IOException
* if there is an error reading from the Reader
*/
private void runScript(Connection conn, Reader reader) throws IOException,
SQLException {
StringBuffer command = null;
try {
LineNumberReader lineReader = new LineNumberReader(reader);
String line = null;
while ((line = lineReader.readLine()) != null) {
if (command == null) {
command = new StringBuffer();
}
String trimmedLine = line.trim();
if (trimmedLine.startsWith("--")) {
println(trimmedLine);
} else if (trimmedLine.length() < 1
|| trimmedLine.startsWith("//")) {
// Do nothing
} else if (trimmedLine.length() < 1
|| trimmedLine.startsWith("--")) {
// Do nothing
} else if (!fullLineDelimiter
&& trimmedLine.endsWith(getDelimiter())
|| fullLineDelimiter
&& trimmedLine.equals(getDelimiter())) {
command.append(line.substring(0, line
.lastIndexOf(getDelimiter())));
command.append(" ");
Statement statement = conn.createStatement();
println(command);
boolean hasResults = false;
if (stopOnError) {
hasResults = statement.execute(command.toString());
} else {
try {
statement.execute(command.toString());
} catch (SQLException e) {
e.fillInStackTrace();
printlnError("Error executing: " + command);
printlnError(e);
}
}
if (autoCommit && !conn.getAutoCommit()) {
conn.commit();
}
ResultSet rs = statement.getResultSet();
if (hasResults && rs != null) {
ResultSetMetaData md = rs.getMetaData();
int cols = md.getColumnCount();
for (int i = 0; i < cols; i++) {
String name = md.getColumnLabel(i);
print(name + "\t");
}
println("");
while (rs.next()) {
for (int i = 0; i < cols; i++) {
String value = rs.getString(i);
print(value + "\t");
}
println("");
}
}
command = null;
try {
statement.close();
} catch (Exception e) {
// Ignore to workaround a bug in Jakarta DBCP
}
Thread.yield();
} else {
command.append(line);
command.append(" ");
}
}
if (!autoCommit) {
conn.commit();
}
} catch (SQLException e) {
e.fillInStackTrace();
printlnError("Error executing: " + command);
printlnError(e);
throw e;
} catch (IOException e) {
e.fillInStackTrace();
printlnError("Error executing: " + command);
printlnError(e);
throw e;
} finally {
conn.rollback();
flush();
}
}
private String getDelimiter() {
return delimiter;
}
private void print(Object o) {
if (logWriter != null) {
System.out.print(o);
}
}
private void println(Object o) {
if (logWriter != null) {
logWriter.println(o);
}
}
private void printlnError(Object o) {
if (errorLogWriter != null) {
errorLogWriter.println(o);
}
}
private void flush() {
if (logWriter != null) {
logWriter.flush();
}
if (errorLogWriter != null) {
errorLogWriter.flush();
}
}
}

@ -0,0 +1,7 @@
plugins {
id("net.researchgate.release") version "3.0.2"
}
release {
failOnUnversionedFiles = false
}

@ -9,7 +9,7 @@ val libs = versionCatalogs.named("libs")
dependencies { dependencies {
implementation(project(":bot-api")) implementation(project(":bot-api"))
implementation(libs.findLibrary("log4j2-api").get()) implementation(libs.findLibrary("slf4j").get())
} }
tasks.withType<JavaCompile> { tasks.withType<JavaCompile> {

@ -35,6 +35,15 @@ tasks.withType<Test> {
jvmArgs("--enable-preview") jvmArgs("--enable-preview")
} }
tasks.register("deps") {
group = "build"
description = "resolves all resolvable configurations"
doLast {
configurations.filter { it.isCanBeResolved }.forEach { it.resolve() }
}
}
val application = extensions.findByType<JavaApplication>() val application = extensions.findByType<JavaApplication>()
application?.apply { application?.apply {
applicationDefaultJvmArgs = listOf("--enable-preview") applicationDefaultJvmArgs = listOf("--enable-preview")

@ -0,0 +1,68 @@
package eu.jonahbauer.chat.build.jlink
import org.gradle.api.DefaultTask
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.api.plugins.internal.JavaPluginHelper
import org.gradle.api.provider.ListProperty
import org.gradle.api.tasks.*
import org.gradle.api.tasks.internal.JavaExecExecutableUtils
import org.gradle.jvm.toolchain.JavaToolchainService
import org.gradle.jvm.toolchain.internal.JavaExecutableUtils
import org.gradle.kotlin.dsl.getByType
abstract class JLinkTask : DefaultTask() {
@get:OutputDirectory
abstract val output : DirectoryProperty
@get:InputFile
abstract val executable : RegularFileProperty
@get:InputFiles
abstract val modulePath : ConfigurableFileCollection
@get:Input
abstract val modules : ListProperty<String>
@get:Input
abstract val options : ListProperty<String>
init {
group = "distribution"
description = "assembles a modular runtime image using jlink"
output.convention(project.layout.buildDirectory.dir("dist/jre"))
modules.convention(listOf("ALL-MODULE-PATH"))
if (project.pluginManager.hasPlugin("java")) {
val feature = JavaPluginHelper.getJavaComponent(project).mainFeature
modulePath.from(feature.runtimeClasspathConfiguration)
modulePath.from(feature.jarTask)
val toolchains = project.extensions.getByType(JavaToolchainService::class)
val java = project.extensions.getByType(JavaPluginExtension::class)
executable.convention(toolchains.launcherFor(java.toolchain)
.map { it.metadata.installationPath }
.map { it.dir("bin").file("jlink") })
}
}
@TaskAction
fun execute() {
val args = options.get() + listOf(
"--module-path", modulePath.asPath,
"--output", output.get().asFile.path,
"--add-modules", modules.get().joinToString(",")
)
val executable = executable.get().asFile.path
project.delete(output)
project.exec {
this.executable = executable
this.args = args
}
}
}

@ -0,0 +1 @@
version=0.2.0-SNAPSHOT

@ -1,22 +1,20 @@
[versions] [versions]
annotations = "24.1.0" annotations = "24.1.0"
jackson = "2.16.1" gson = "2.10.1"
hikari = "5.1.0"
junit = "5.10.2" junit = "5.10.2"
log4j2 = "2.22.1" logback = "1.5.3"
lombok = "1.18.30" lombok = "1.18.30"
slf4j = "2.0.12"
sqlite = "3.44.1.0"
[libraries] [libraries]
annotations = { module = "org.jetbrains:annotations", version.ref = "annotations" } annotations = { module = "org.jetbrains:annotations", version.ref = "annotations" }
jackson-annotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jackson"} gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson"} hikari = { module = "com.zaxxer:HikariCP", version.ref = "hikari" }
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson"} logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" } 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" } lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" }
slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
[bundles] sqlite = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite"}
jackson = ["jackson-databind", "jackson-annotations", "jackson-datatype-jsr310"]
log4j2 = ["log4j2-api", "log4j2-core", "log4j2-slf4j"]

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-rc-4-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

@ -4,7 +4,6 @@ plugins {
} }
group = "eu.jonahbauer.chat" group = "eu.jonahbauer.chat"
version = "0.1.0-SNAPSHOT"
dependencies { dependencies {
api(project(":bot-api")) { api(project(":bot-api")) {

@ -14,7 +14,7 @@ import java.util.List;
public interface ChatBotSupportMXBean { public interface ChatBotSupportMXBean {
@SneakyThrows @SneakyThrows
static @NotNull ObjectName getObjectName(String name) { static @NotNull ObjectName getObjectName(@NotNull String name) {
return ObjectName.getInstance(STR."eu.jonahbauer.chat.bot.server:component=ChatBots,name=\{quote(name)}"); return ObjectName.getInstance(STR."eu.jonahbauer.chat.bot.server:component=ChatBots,name=\{quote(name)}");
} }

@ -1,26 +1,45 @@
import eu.jonahbauer.chat.build.jlink.JLinkTask
plugins { plugins {
id("chat-bot.java-conventions") id("chat-bot.java-conventions")
application
} }
group = "eu.jonahbauer.chat" group = "eu.jonahbauer.chat"
version = "0.1.0-SNAPSHOT"
val bots = project(":bots").subprojects val bot : Configuration by configurations.creating
configurations.runtimeOnly {
extendsFrom(bot)
}
dependencies { dependencies {
implementation(project(":bot-api")) implementation(project(":bot-api"))
implementation(project(":management")) implementation(project(":management"))
implementation(libs.bundles.log4j2) implementation(libs.gson)
implementation(libs.bundles.jackson) implementation(libs.slf4j)
runtimeOnly(libs.logback)
bots.forEach { project.project(":bots").subprojects.forEach {
implementation(project(it.path)) bot(project(it.path))
} }
} }
application { tasks.named<JavaCompile>("compileJava") {
mainClass.set("eu.jonahbauer.chat.server.Main") options.javaModuleMainClass = "eu.jonahbauer.chat.server.Main"
mainModule.set("eu.jonahbauer.chat.server") }
applicationDefaultJvmArgs = listOf("--enable-preview")
tasks.register<JLinkTask>("jlink") {
modules = listOf("ALL-MODULE-PATH", "jdk.crypto.ec")
options = listOf("--no-header-files", "--no-man-pages", "--strip-debug", "--compress=zip-6")
} }
tasks.register<Exec>("run") {
group = "application"
description = "runs the application using the custom runtime image"
executable = project.layout.buildDirectory.file("dist/jre/bin/java").get().asFile.path
args("--enable-preview", "-m", "eu.jonahbauer.chat.server")
dependsOn(tasks.named<JLinkTask>("jlink"))
}

@ -1,26 +1,27 @@
package eu.jonahbauer.chat.server; package eu.jonahbauer.chat.server;
import com.fasterxml.jackson.databind.ObjectMapper; import com.google.gson.GsonBuilder;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import eu.jonahbauer.chat.bot.config.BotConfig; import eu.jonahbauer.chat.bot.config.BotConfig;
import eu.jonahbauer.chat.server.bot.BotConfigDeserializer; import eu.jonahbauer.chat.server.json.BotConfigDeserializer;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.net.URL;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
@Slf4j
public record Config( public record Config(
@NotNull Account account, @NotNull Account account,
@NotNull Set<@NotNull String> channels, @NotNull Set<@NotNull String> channels,
@JsonDeserialize(contentUsing = BotConfigDeserializer.class)
@NotNull Map<@NotNull String, @NotNull BotConfig> bots @NotNull Map<@NotNull String, @NotNull BotConfig> bots
) { ) {
private static final String CONFIGURATION_FILE_ENV = "CHAT_BOT_CONFIG"; private static final String CONFIGURATION_FILE_ENV = "CHAT_BOT_CONFIG";
private static final String CONFIGURATION_FILE_PROPERTY = "chatbot.configurationFile"; private static final String CONFIGURATION_FILE_PROPERTY = "chatbot.configuration";
private static final String DEFAULT_CONFIGURATION_FILE = "file:./config.json"; private static final String DEFAULT_CONFIGURATION_FILE = "file:./config.json";
public static @NotNull Config load() throws IOException { public static @NotNull Config load() throws IOException {
@ -38,8 +39,14 @@ public record Config(
} }
public static @NotNull Config read(@NotNull URL url) throws IOException { public static @NotNull Config read(@NotNull URL url) throws IOException {
var mapper = new ObjectMapper(); log.info("Loading configuration from " + url);
return mapper.readValue(url, Config.class);
var gson = new GsonBuilder()
.registerTypeAdapter(BotConfig.class, new BotConfigDeserializer())
.create();
try (var in = url.openStream(); var reader = new InputStreamReader(in)) {
return gson.fromJson(reader, Config.class);
}
} }
public Config { public Config {

@ -1,30 +1,32 @@
package eu.jonahbauer.chat.server; package eu.jonahbauer.chat.server;
import eu.jonahbauer.chat.server.bot.ChatBotSupervisor; 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 eu.jonahbauer.chat.server.socket.SocketSupervisor;
import eu.jonahbauer.chat.server.util.Lazy.MutableLazy;
import javax.management.JMException;
import java.io.IOException; import java.io.IOException;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
public class Main { public class Main {
public static void main(String[] args) throws IOException, InterruptedException { public static void main(String[] args) throws IOException, InterruptedException, JMException {
// initialize ChatBotManager and SocketManager
var _ = ChatBotManager.INSTANCE;
var _ = SocketManager.INSTANCE;
var config = Config.load(); var config = Config.load();
ChatBotSupervisor.INSTANCE.start(config); var chatBotSupervisorLazy = new MutableLazy<ChatBotSupervisor>();
SocketSupervisor.INSTANCE.start(config); var socketSupervisorLazy = new MutableLazy<SocketSupervisor>();
try (
var chatBotSupervisor = chatBotSupervisorLazy.set(new ChatBotSupervisor(socketSupervisorLazy));
var socketSupervisor = socketSupervisorLazy.set(new SocketSupervisor(chatBotSupervisorLazy))
) {
chatBotSupervisor.start(config);
socketSupervisor.start(config);
try { try {
// keep main thread running // keep main thread running
new CountDownLatch(1).await(); new CountDownLatch(1).await();
} catch (InterruptedException e) { } catch (InterruptedException _) {
SocketSupervisor.INSTANCE.stop(); // ignore
ChatBotSupervisor.INSTANCE.stop(); }
} }
} }
} }

@ -1,61 +0,0 @@
package eu.jonahbauer.chat.server.bot;
import com.fasterxml.jackson.core.JsonParseException;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import eu.jonahbauer.chat.bot.config.BotConfig;
import eu.jonahbauer.chat.bot.config.BotConfigurationException;
import java.io.IOException;
import java.util.ArrayList;
public class BotConfigDeserializer extends StdDeserializer<BotConfig> {
protected BotConfigDeserializer() {
super(BotConfig.class);
}
@Override
public BotConfig deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
if (p.currentToken() == JsonToken.START_OBJECT) {
p.nextToken();
}
var builder = BotConfig.builder();
while (p.currentToken() == JsonToken.FIELD_NAME) {
var key = p.currentName();
switch (p.nextToken()) {
case VALUE_STRING -> builder.value(key, p.getValueAsString());
case VALUE_FALSE -> builder.value(key, false);
case VALUE_TRUE -> builder.value(key, true);
case VALUE_NULL -> {}
case VALUE_NUMBER_FLOAT -> builder.value(key, p.getDoubleValue());
case VALUE_NUMBER_INT -> builder.value(key, p.getLongValue());
case START_ARRAY -> {
var list = new ArrayList<String>();
while (p.nextToken() != JsonToken.END_ARRAY) {
if (p.currentToken() == JsonToken.VALUE_STRING) {
list.add(p.getValueAsString());
} else {
throw new BotConfigurationException("Unsupported property type in array.");
}
}
builder.value(key, list);
}
case START_OBJECT -> throw new BotConfigurationException("Unsupported property type: object");
default -> throw new BotConfigurationException("Unsupported property property type.");
}
p.nextToken();
}
if (p.currentToken() == JsonToken.END_OBJECT) {
return builder.build();
}
throw new JsonParseException(p, "Unexpected token: " + p.currentToken(), p.currentLocation());
}
}

@ -6,32 +6,43 @@ import eu.jonahbauer.chat.bot.config.BotConfig;
import eu.jonahbauer.chat.bot.impl.BotCreationException; import eu.jonahbauer.chat.bot.impl.BotCreationException;
import eu.jonahbauer.chat.bot.impl.ChatBotFactory; import eu.jonahbauer.chat.bot.impl.ChatBotFactory;
import eu.jonahbauer.chat.server.Config; import eu.jonahbauer.chat.server.Config;
import eu.jonahbauer.chat.server.management.impl.ChatBotManager;
import eu.jonahbauer.chat.server.management.impl.ChatBotSupport; import eu.jonahbauer.chat.server.management.impl.ChatBotSupport;
import eu.jonahbauer.chat.server.socket.SocketSupervisor; import eu.jonahbauer.chat.server.socket.SocketSupervisor;
import lombok.extern.log4j.Log4j2; import eu.jonahbauer.chat.server.util.Lazy;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NonBlocking; import org.jetbrains.annotations.NonBlocking;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import javax.management.JMException;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.*; import java.util.*;
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue; import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
@Log4j2 @Slf4j
public enum ChatBotSupervisor { public final class ChatBotSupervisor implements AutoCloseable {
INSTANCE;
private static final Duration POST_EXPIRATION = Duration.ofSeconds(2); private static final Duration POST_EXPIRATION = Duration.ofSeconds(2);
private static final int MESSAGE_QUEUE_SIZE = 10; private static final int MESSAGE_QUEUE_SIZE = 10;
/** /**
* Map of running bots indexed by their name. * Map of running bots indexed by their name.
*/ */
private final @NotNull Map<@NotNull String, @NotNull RunningBot> bots = new HashMap<>(); private final @NotNull ConcurrentMap<@NotNull String, @NotNull RunningBot> bots = new ConcurrentHashMap<>();
private final @NotNull ReentrantLock lock = new ReentrantLock();
private final @NotNull Lazy<SocketSupervisor> socketSupervisor;
public ChatBotSupervisor(@NotNull Lazy<SocketSupervisor> socketSupervisor) throws JMException {
ChatBotManager.init(this);
this.socketSupervisor = socketSupervisor;
}
/** /**
* Dispatches the message to all running bots. If a bot has too many messages queued up, the message may not be * Dispatches the message to all running bots. If a bot has too many messages queued up, the message may not be
@ -52,9 +63,16 @@ public enum ChatBotSupervisor {
* @param config a configuration * @param config a configuration
* @throws IllegalStateException if any bots are running already * @throws IllegalStateException if any bots are running already
*/ */
public synchronized void start(@NotNull Config config) { public void start(@NotNull Config config) {
if (!bots.isEmpty()) throw new IllegalStateException("start(Config) may not be used when any bots are running already"); lock.lock();
config.bots().forEach(this::start); try {
if (!bots.isEmpty()) {
throw new IllegalStateException("start(Config) may not be used when any bots are running already");
}
config.bots().forEach(this::start);
} finally {
lock.unlock();
}
} }
/** /**
@ -64,7 +82,7 @@ public enum ChatBotSupervisor {
* @param channels the channels the bot will be active in * @param channels the channels the bot will be active in
* @throws BotCreationException if the bot could not be created * @throws BotCreationException if the bot could not be created
*/ */
public synchronized void start(@NotNull String name, @NotNull String type, @NotNull List<String> channels) { public void start(@NotNull String name, @NotNull String type, @NotNull List<String> channels) {
start(name, BotConfig.builder().type(type).channels(channels).build()); start(name, BotConfig.builder().type(type).channels(channels).build());
} }
@ -74,9 +92,16 @@ public enum ChatBotSupervisor {
* @param config the bot configuration * @param config the bot configuration
* @throws BotCreationException if the bot could not be created * @throws BotCreationException if the bot could not be created
*/ */
public synchronized void start(@NotNull String name, @NotNull BotConfig config) { public void start(@NotNull String name, @NotNull BotConfig config) {
if (bots.containsKey(name)) throw new BotCreationException("Duplicate bot name: " + name); lock.lock();
bots.put(name, new RunningBot(name, config)); try {
if (bots.containsKey(name)) {
throw new BotCreationException("Duplicate bot name: " + name);
}
bots.put(name, new RunningBot(name, config));
} finally {
lock.unlock();
}
} }
/** /**
@ -87,10 +112,13 @@ public enum ChatBotSupervisor {
public void stop(@NotNull String name) throws InterruptedException { public void stop(@NotNull String name) throws InterruptedException {
RunningBot bot; RunningBot bot;
synchronized (this) { lock.lock();
try {
bot = bots.remove(name); bot = bots.remove(name);
if (bot == null) return; if (bot == null) return;
bot.stop(); bot.stop();
} finally {
lock.unlock();
} }
bot.join(); bot.join();
@ -103,12 +131,15 @@ public enum ChatBotSupervisor {
public void stop() throws InterruptedException { public void stop() throws InterruptedException {
var stopped = new ArrayList<RunningBot>(); var stopped = new ArrayList<RunningBot>();
synchronized (this) { lock.lock();
try {
for (RunningBot bot : bots.values()) { for (RunningBot bot : bots.values()) {
stopped.add(bot); stopped.add(bot);
bot.stop(); bot.stop();
} }
bots.clear(); bots.clear();
} finally {
lock.unlock();
} }
for (var bot : stopped) { for (var bot : stopped) {
@ -116,6 +147,11 @@ public enum ChatBotSupervisor {
} }
} }
@Override
public void close() throws InterruptedException {
stop();
}
/** /**
* {@return a map of all currently running bots and their effective config} * {@return a map of all currently running bots and their effective config}
*/ */
@ -144,52 +180,31 @@ public enum ChatBotSupervisor {
/** /**
* Manages a {@link ChatBot} instance running on its own thread. Takes care of recreating the bot after it threw * 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 * an exception. {@linkplain ChatBotSupport#register(ChatBotSupervisor, String, BotConfig) Registers} the bot
* and {@linkplain ChatBotSupport#unregister(String) unregisters} it during {@link #stop()}. * during construction and {@linkplain ChatBotSupport#unregister(String) unregisters} it during {@link #stop()}.
*/ */
private static class RunningBot implements Runnable { private class RunningBot implements Runnable {
private final @NotNull String name; private final @NotNull String name;
private final @NotNull BotConfig config; private final @NotNull ChatBot bot;
private final @NotNull ChatBotFactory<?> factory;
private final @NotNull Thread thread; private final @NotNull Thread thread;
private final @NotNull BlockingQueue<@NotNull PendingPost> queue = new ArrayBlockingQueue<>(MESSAGE_QUEUE_SIZE); private final @NotNull BlockingQueue<@NotNull PendingPost> queue = new ArrayBlockingQueue<>(MESSAGE_QUEUE_SIZE);
private final AtomicBoolean stopped = new AtomicBoolean(); private final @NotNull AtomicBoolean stopped = new AtomicBoolean();
private volatile ChatBot bot;
public RunningBot(@NotNull String name, @NotNull BotConfig config) { public RunningBot(@NotNull String name, @NotNull BotConfig config) {
this.name = name; this.name = name;
this.config = config;
this.factory = getChatBotFactory(config.getType()); var factory = getChatBotFactory(config.getType());
this.bot = factory.create(config);
log.info("starting bot {}...", name); log.info("starting bot {}...", name);
ChatBotSupport.register(name, config); ChatBotSupport.register(ChatBotSupervisor.this, name, config);
this.thread = Thread.ofVirtual().name("ChatBot[" + name + "]").start(this); this.thread = Thread.ofVirtual().name("ChatBot[" + name + "]").start(this);
} }
@Override @Override
public void run() { public void run() {
log.info("started bot {}", name); 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()) { while (!stopped.get()) {
try { try {
var post = queue.take(); var post = queue.take();
@ -200,11 +215,24 @@ public enum ChatBotSupervisor {
} else if (!bot.getConfig().getChannels().contains(post.post().channel())) { } else if (!bot.getConfig().getChannels().contains(post.post().channel())) {
log.debug("Ignoring message {} because channel is not listened to.", post.post()); log.debug("Ignoring message {} because channel is not listened to.", post.post());
} else { } else {
bot.onMessage(SocketSupervisor.INSTANCE, post.post()); bot.onMessage(ChatBotSupervisor.this.socketSupervisor.get(), post.post());
} }
} catch (InterruptedException _) { } catch (InterruptedException _) {
// ignore
} catch (Exception ex) {
log.warn("bot {} threw an exception during message handling", name, ex);
} catch (Throwable ex) {
log.error("bot {} threw an error during message handling", name, ex);
} }
} }
try {
bot.onStop();
} catch (Exception ex) {
log.warn("bot {} threw an exception during shutdown", name, ex);
}
log.info("stopped bot {}", name);
} }
@NonBlocking @NonBlocking
@ -233,8 +261,7 @@ public enum ChatBotSupervisor {
} }
public @NotNull BotConfig getConfig() { public @NotNull BotConfig getConfig() {
var bot = this.bot; return bot.getConfig();
return bot != null ? bot.getConfig() : config;
} }
private record PendingPost(@NotNull Message.Post post, @NotNull Instant expiration) {} private record PendingPost(@NotNull Message.Post post, @NotNull Instant expiration) {}

@ -0,0 +1,30 @@
package eu.jonahbauer.chat.server.json;
import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
public class BooleanTypeAdapter extends TypeAdapter<Boolean> {
@Override
public void write(@NotNull JsonWriter out, @Nullable Boolean value) throws IOException {
if (value == null) {
out.nullValue();
} else {
out.value(value ? 1 : 0);
}
}
@Override
public Boolean read(@NotNull JsonReader in) throws IOException {
return switch (in.peek()) {
case NUMBER -> in.nextInt() != 0;
case BOOLEAN -> in.nextBoolean();
case NULL -> null;
default -> throw new IllegalStateException("Expected a number but was " + in.peek());
};
}
}

@ -0,0 +1,55 @@
package eu.jonahbauer.chat.server.json;
import com.google.gson.*;
import eu.jonahbauer.chat.bot.config.BotConfig;
import eu.jonahbauer.chat.bot.config.BotConfigurationException;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.lang.reflect.Type;
public class BotConfigDeserializer implements JsonDeserializer<BotConfig> {
@Override
public @Nullable BotConfig deserialize(@NotNull JsonElement json, @NotNull Type typeOfT, @NotNull JsonDeserializationContext context) throws JsonParseException {
if (json.isJsonNull()) return null;
if (!json.isJsonObject()) throw new BotConfigurationException("Expected an object");
var obj = json.getAsJsonObject();
var builder = BotConfig.builder();
obj.asMap().forEach((key, value) -> {
if (value instanceof JsonPrimitive primitive) {
if (primitive.isBoolean()) {
builder.value(key, primitive.getAsBoolean());
} else if (primitive.isString()) {
builder.value(key, primitive.getAsString());
} else if (primitive.isNumber()) {
switch (primitive.getAsNumber()) {
case Long l -> builder.value(key, l);
case Double d -> builder.value(key, d);
case Number n -> throw new BotConfigurationException("Invalid number: " + n);
}
} else {
throw new AssertionError();
}
} else if (value.isJsonArray()) {
var list = value.getAsJsonArray().asList().stream()
.map(e -> {
if (e instanceof JsonPrimitive primitive && primitive.isString()) {
return primitive.getAsString();
} else {
throw new BotConfigurationException("Unsupported property type in array.");
}
})
.toList();
builder.value(key, list);
} else {
throw new BotConfigurationException("Unsupported property type.");
}
});
return builder.build();
}
}

@ -0,0 +1,33 @@
package eu.jonahbauer.chat.server.json;
import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class LocalDateTimeTypeAdapter extends TypeAdapter<LocalDateTime> {
private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Override
public void write(@NotNull JsonWriter out, @Nullable LocalDateTime value) throws IOException {
if (value == null) {
out.nullValue();
} else {
out.value(formatter.format(value));
}
}
@Override
public LocalDateTime read(JsonReader in) throws IOException {
return switch (in.peek()) {
case NULL -> null;
case STRING -> formatter.parse(in.nextString(), LocalDateTime::from);
default -> throw new IllegalStateException("Expected a string but was " + in.peek());
};
}
}

@ -0,0 +1,327 @@
/*
* Copyright (C) 2011 Google Inc.
*
* 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
*
* http://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.
*/
package eu.jonahbauer.chat.server.json;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonPrimitive;
import com.google.gson.TypeAdapter;
import com.google.gson.TypeAdapterFactory;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Adapts values whose runtime type may differ from their declaration type. This is necessary when a
* field's type is not the same type that GSON should create when deserializing that field. For
* example, consider these types:
*
* <pre>{@code
* abstract class Shape {
* int x;
* int y;
* }
* class Circle extends Shape {
* int radius;
* }
* class Rectangle extends Shape {
* int width;
* int height;
* }
* class Diamond extends Shape {
* int width;
* int height;
* }
* class Drawing {
* Shape bottomShape;
* Shape topShape;
* }
* }</pre>
*
* <p>Without additional type information, the serialized JSON is ambiguous. Is the bottom shape in
* this drawing a rectangle or a diamond?
*
* <pre>{@code
* {
* "bottomShape": {
* "width": 10,
* "height": 5,
* "x": 0,
* "y": 0
* },
* "topShape": {
* "radius": 2,
* "x": 4,
* "y": 1
* }
* }
* }</pre>
*
* This class addresses this problem by adding type information to the serialized JSON and honoring
* that type information when the JSON is deserialized:
*
* <pre>{@code
* {
* "bottomShape": {
* "type": "Diamond",
* "width": 10,
* "height": 5,
* "x": 0,
* "y": 0
* },
* "topShape": {
* "type": "Circle",
* "radius": 2,
* "x": 4,
* "y": 1
* }
* }
* }</pre>
*
* Both the type field name ({@code "type"}) and the type labels ({@code "Rectangle"}) are
* configurable.
*
* <h2>Registering Types</h2>
*
* Create a {@code RuntimeTypeAdapterFactory} by passing the base type and type field name to the
* {@link #of} factory method. If you don't supply an explicit type field name, {@code "type"} will
* be used.
*
* <pre>{@code
* RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory
* = RuntimeTypeAdapterFactory.of(Shape.class, "type");
* }</pre>
*
* Next register all of your subtypes. Every subtype must be explicitly registered. This protects
* your application from injection attacks. If you don't supply an explicit type label, the type's
* simple name will be used.
*
* <pre>{@code
* shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
* shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
* shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
* }</pre>
*
* Finally, register the type adapter factory in your application's GSON builder:
*
* <pre>{@code
* Gson gson = new GsonBuilder()
* .registerTypeAdapterFactory(shapeAdapterFactory)
* .create();
* }</pre>
*
* Like {@code GsonBuilder}, this API supports chaining:
*
* <pre>{@code
* RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
* .registerSubtype(Rectangle.class)
* .registerSubtype(Circle.class)
* .registerSubtype(Diamond.class);
* }</pre>
*
* <h2>Serialization and deserialization</h2>
*
* In order to serialize and deserialize a polymorphic object, you must specify the base type
* explicitly.
*
* <pre>{@code
* Diamond diamond = new Diamond();
* String json = gson.toJson(diamond, Shape.class);
* }</pre>
*
* And then:
*
* <pre>{@code
* Shape shape = gson.fromJson(json, Shape.class);
* }</pre>
*/
public final class RuntimeTypeAdapterFactory<T> implements TypeAdapterFactory {
private final Class<?> baseType;
private final String typeFieldName;
private final Map<String, Class<?>> labelToSubtype = new LinkedHashMap<>();
private final Map<Class<?>, String> subtypeToLabel = new LinkedHashMap<>();
private final boolean maintainType;
private boolean recognizeSubtypes;
private RuntimeTypeAdapterFactory(Class<?> baseType, String typeFieldName, boolean maintainType) {
if (typeFieldName == null || baseType == null) {
throw new NullPointerException();
}
this.baseType = baseType;
this.typeFieldName = typeFieldName;
this.maintainType = maintainType;
}
/**
* Creates a new runtime type adapter using for {@code baseType} using {@code typeFieldName} as
* the type field name. Type field names are case sensitive.
*
* @param maintainType true if the type field should be included in deserialized objects
*/
public static <T> RuntimeTypeAdapterFactory<T> of(
Class<T> baseType, String typeFieldName, boolean maintainType) {
return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, maintainType);
}
/**
* Creates a new runtime type adapter using for {@code baseType} using {@code typeFieldName} as
* the type field name. Type field names are case sensitive.
*/
public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType, String typeFieldName) {
return new RuntimeTypeAdapterFactory<>(baseType, typeFieldName, false);
}
/**
* Creates a new runtime type adapter for {@code baseType} using {@code "type"} as the type field
* name.
*/
public static <T> RuntimeTypeAdapterFactory<T> of(Class<T> baseType) {
return new RuntimeTypeAdapterFactory<>(baseType, "type", false);
}
/**
* Ensures that this factory will handle not just the given {@code baseType}, but any subtype of
* that type.
*/
public RuntimeTypeAdapterFactory<T> recognizeSubtypes() {
this.recognizeSubtypes = true;
return this;
}
/**
* Registers {@code type} identified by {@code label}. Labels are case sensitive.
*
* @throws IllegalArgumentException if either {@code type} or {@code label} have already been
* registered on this type adapter.
*/
public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type, String label) {
if (type == null || label == null) {
throw new NullPointerException();
}
if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) {
throw new IllegalArgumentException("types and labels must be unique");
}
labelToSubtype.put(label, type);
subtypeToLabel.put(type, label);
return this;
}
/**
* Registers {@code type} identified by its {@link Class#getSimpleName simple name}. Labels are
* case sensitive.
*
* @throws IllegalArgumentException if either {@code type} or its simple name have already been
* registered on this type adapter.
*/
public RuntimeTypeAdapterFactory<T> registerSubtype(Class<? extends T> type) {
return registerSubtype(type, type.getSimpleName());
}
@Override
public <R> TypeAdapter<R> create(Gson gson, TypeToken<R> type) {
if (type == null) {
return null;
}
Class<?> rawType = type.getRawType();
boolean handle =
recognizeSubtypes ? baseType.isAssignableFrom(rawType) : baseType.equals(rawType);
if (!handle) {
return null;
}
final TypeAdapter<JsonElement> jsonElementAdapter = gson.getAdapter(JsonElement.class);
final Map<String, TypeAdapter<?>> labelToDelegate = new LinkedHashMap<>();
final Map<Class<?>, TypeAdapter<?>> subtypeToDelegate = new LinkedHashMap<>();
for (Map.Entry<String, Class<?>> entry : labelToSubtype.entrySet()) {
TypeAdapter<?> delegate = gson.getDelegateAdapter(this, TypeToken.get(entry.getValue()));
labelToDelegate.put(entry.getKey(), delegate);
subtypeToDelegate.put(entry.getValue(), delegate);
}
return new TypeAdapter<R>() {
@Override
public R read(JsonReader in) throws IOException {
JsonElement jsonElement = jsonElementAdapter.read(in);
JsonElement labelJsonElement;
if (maintainType) {
labelJsonElement = jsonElement.getAsJsonObject().get(typeFieldName);
} else {
labelJsonElement = jsonElement.getAsJsonObject().remove(typeFieldName);
}
if (labelJsonElement == null) {
throw new JsonParseException(
"cannot deserialize "
+ baseType
+ " because it does not define a field named "
+ typeFieldName);
}
String label = labelJsonElement.getAsString();
@SuppressWarnings("unchecked") // registration requires that subtype extends T
TypeAdapter<R> delegate = (TypeAdapter<R>) labelToDelegate.get(label);
if (delegate == null) {
throw new JsonParseException(
"cannot deserialize "
+ baseType
+ " subtype named "
+ label
+ "; did you forget to register a subtype?");
}
return delegate.fromJsonTree(jsonElement);
}
@Override
public void write(JsonWriter out, R value) throws IOException {
Class<?> srcType = value.getClass();
String label = subtypeToLabel.get(srcType);
@SuppressWarnings("unchecked") // registration requires that subtype extends T
TypeAdapter<R> delegate = (TypeAdapter<R>) subtypeToDelegate.get(srcType);
if (delegate == null) {
throw new JsonParseException(
"cannot serialize " + srcType.getName() + "; did you forget to register a subtype?");
}
JsonObject jsonObject = delegate.toJsonTree(value).getAsJsonObject();
if (maintainType) {
jsonElementAdapter.write(out, jsonObject);
return;
}
JsonObject clone = new JsonObject();
if (jsonObject.has(typeFieldName)) {
throw new JsonParseException(
"cannot serialize "
+ srcType.getName()
+ " because it already defines a field named "
+ typeFieldName);
}
clone.add(typeFieldName, new JsonPrimitive(label));
for (Map.Entry<String, JsonElement> e : jsonObject.entrySet()) {
clone.add(e.getKey(), e.getValue());
}
jsonElementAdapter.write(out, clone);
}
}.nullSafe();
}
}

@ -4,7 +4,9 @@ import eu.jonahbauer.chat.server.management.annotations.ManagedAttribute;
import eu.jonahbauer.chat.server.management.annotations.ManagedBean; import eu.jonahbauer.chat.server.management.annotations.ManagedBean;
import eu.jonahbauer.chat.server.management.annotations.ManagedOperation; import eu.jonahbauer.chat.server.management.annotations.ManagedOperation;
import eu.jonahbauer.chat.server.management.annotations.ManagedParameter; import eu.jonahbauer.chat.server.management.annotations.ManagedParameter;
import lombok.extern.log4j.Log4j2; import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import javax.management.*; import javax.management.*;
import java.lang.reflect.Method; import java.lang.reflect.Method;
@ -14,14 +16,14 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.function.Function; import java.util.function.Function;
@Log4j2 @Slf4j
public class AdvancedMBean extends StandardMBean { class AdvancedMBean extends StandardMBean {
public <T> AdvancedMBean(T implementation) { public AdvancedMBean(@NotNull Object implementation) {
super(implementation, null, true); super(implementation, null, true);
} }
@Override @Override
protected String getDescription(MBeanInfo info) { protected @Nullable String getDescription(@Nullable MBeanInfo info) {
var clazz = getMBeanInterface(); var clazz = getMBeanInterface();
var annotation = clazz.getAnnotation(ManagedBean.class); var annotation = clazz.getAnnotation(ManagedBean.class);
if (annotation != null && !"".equals(annotation.description())) { if (annotation != null && !"".equals(annotation.description())) {
@ -32,9 +34,7 @@ public class AdvancedMBean extends StandardMBean {
} }
@Override @Override
protected int getImpact(MBeanOperationInfo info) { protected int getImpact(@Nullable MBeanOperationInfo info) {
if (info == null) return MBeanOperationInfo.UNKNOWN;
var method = findOperation(info); var method = findOperation(info);
var annotation = method != null ? method.getAnnotation(ManagedOperation.class) : null; var annotation = method != null ? method.getAnnotation(ManagedOperation.class) : null;
if (annotation != null) { if (annotation != null) {
@ -45,9 +45,7 @@ public class AdvancedMBean extends StandardMBean {
} }
@Override @Override
protected String getDescription(MBeanOperationInfo info) { protected @Nullable String getDescription(@Nullable MBeanOperationInfo info) {
if (info == null) return null;
var method = findOperation(info); var method = findOperation(info);
var annotation = method != null ? method.getAnnotation(ManagedOperation.class) : null; var annotation = method != null ? method.getAnnotation(ManagedOperation.class) : null;
if (annotation != null && !"".equals(annotation.description())) { if (annotation != null && !"".equals(annotation.description())) {
@ -58,13 +56,9 @@ public class AdvancedMBean extends StandardMBean {
} }
@Override @Override
protected String getParameterName(MBeanOperationInfo op, MBeanParameterInfo param, int sequence) { protected @Nullable String getParameterName(@Nullable MBeanOperationInfo op, @Nullable MBeanParameterInfo param, int sequence) {
if (op == null) return super.getParameterName(op, param, sequence);
var method = findOperation(op); var method = findOperation(op);
if (method == null) return super.getParameterName(op, param, sequence); var annotation = method != null ? method.getParameters()[sequence].getAnnotation(ManagedParameter.class) : null;
var annotation = method.getParameters()[sequence].getAnnotation(ManagedParameter.class);
if (annotation != null && !"".equals(annotation.name())) { if (annotation != null && !"".equals(annotation.name())) {
return annotation.name(); return annotation.name();
} else { } else {
@ -73,13 +67,9 @@ public class AdvancedMBean extends StandardMBean {
} }
@Override @Override
protected String getDescription(MBeanOperationInfo op, MBeanParameterInfo param, int sequence) { protected @Nullable String getDescription(@Nullable MBeanOperationInfo op, @Nullable MBeanParameterInfo param, int sequence) {
if (op == null) return super.getParameterName(op, param, sequence);
var method = findOperation(op); var method = findOperation(op);
if (method == null) return super.getParameterName(op, param, sequence); var annotation = method != null ? method.getParameters()[sequence].getAnnotation(ManagedParameter.class) : null;
var annotation = method.getParameters()[sequence].getAnnotation(ManagedParameter.class);
if (annotation != null && !"".equals(annotation.description())) { if (annotation != null && !"".equals(annotation.description())) {
return annotation.description(); return annotation.description();
} else { } else {
@ -88,7 +78,9 @@ public class AdvancedMBean extends StandardMBean {
} }
@Override @Override
protected String getDescription(MBeanAttributeInfo info) { protected @Nullable String getDescription(@Nullable MBeanAttributeInfo info) {
if (info == null) return super.getDescription(info);
var name = info.getName(); var name = info.getName();
var type = getType(info, MBeanAttributeInfo::getType); var type = getType(info, MBeanAttributeInfo::getType);
@ -107,7 +99,7 @@ public class AdvancedMBean extends StandardMBean {
return super.getDescription(info); return super.getDescription(info);
} }
private <T extends MBeanFeatureInfo> String getType(T info, Function<T, String> getType) { private <T extends MBeanFeatureInfo> String getType(@NotNull T info, @NotNull Function<T, String> getType) {
var descriptor = info.getDescriptor(); var descriptor = info.getDescriptor();
if (descriptor == null) return getType.apply(info); if (descriptor == null) return getType.apply(info);
@ -119,7 +111,9 @@ public class AdvancedMBean extends StandardMBean {
} }
} }
private Method findOperation(MBeanOperationInfo info) { private @Nullable Method findOperation(@Nullable MBeanOperationInfo info) {
if (info == null) return null;
var name = info.getName(); var name = info.getName();
var params = new ArrayList<String>(); var params = new ArrayList<String>();
@ -130,11 +124,11 @@ public class AdvancedMBean extends StandardMBean {
return findMethod(name, params); return findMethod(name, params);
} }
private Method findMethod(String name, String...parameters) { private @Nullable Method findMethod(@NotNull String name, @NotNull String @NotNull... parameters) {
return findMethod(name, Arrays.asList(parameters)); return findMethod(name, Arrays.asList(parameters));
} }
private Method findMethod(String name, List<String> parameters) { private @Nullable Method findMethod(@NotNull String name, @NotNull List<@NotNull String> parameters) {
var clazz = getMBeanInterface(); var clazz = getMBeanInterface();
methods: for (var method : clazz.getMethods()) { methods: for (var method : clazz.getMethods()) {
if (!method.getName().equals(name)) continue; if (!method.getName().equals(name)) continue;
@ -150,7 +144,7 @@ public class AdvancedMBean extends StandardMBean {
return null; return null;
} }
private static String capitalize(String name) { private static @NotNull String capitalize(@NotNull String name) {
if (name.isEmpty()) return name; if (name.isEmpty()) return name;
return name.substring(0, 1).toUpperCase(Locale.ROOT) + name.substring(1); return name.substring(0, 1).toUpperCase(Locale.ROOT) + name.substring(1);
} }

@ -5,26 +5,31 @@ import eu.jonahbauer.chat.bot.impl.ChatBotFactory;
import eu.jonahbauer.chat.server.bot.ChatBotSupervisor; import eu.jonahbauer.chat.server.bot.ChatBotSupervisor;
import eu.jonahbauer.chat.server.management.BotConfigSupport; import eu.jonahbauer.chat.server.management.BotConfigSupport;
import eu.jonahbauer.chat.server.management.ChatBotManagerMXBean; import eu.jonahbauer.chat.server.management.ChatBotManagerMXBean;
import lombok.SneakyThrows;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Unmodifiable;
import javax.management.JMException;
import java.lang.management.ManagementFactory; import java.lang.management.ManagementFactory;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public enum ChatBotManager implements ChatBotManagerMXBean { public final class ChatBotManager implements ChatBotManagerMXBean {
INSTANCE; private final @NotNull ChatBotSupervisor supervisor;
@SneakyThrows public static void init(@NotNull ChatBotSupervisor supervisor) throws JMException {
ChatBotManager() { var impl = new ChatBotManager(supervisor);
var server = ManagementFactory.getPlatformMBeanServer(); var server = ManagementFactory.getPlatformMBeanServer();
server.registerMBean(new AdvancedMBean(this), ChatBotManagerMXBean.NAME); server.registerMBean(new AdvancedMBean(impl), ChatBotManagerMXBean.NAME);
}
private ChatBotManager(@NotNull ChatBotSupervisor supervisor) {
this.supervisor = Objects.requireNonNull(supervisor);
} }
@Override @Override
public void start(@NotNull String name, @NotNull String type, @NotNull List<@NotNull String> channels) { public void start(@NotNull String name, @NotNull String type, @NotNull List<@NotNull String> channels) {
try { try {
ChatBotSupervisor.INSTANCE.start(name, type, channels); supervisor.start(name, type, channels);
} catch (BotCreationException ex) { } catch (BotCreationException ex) {
throw new IllegalArgumentException(ex.getMessage(), ex.getCause()); throw new IllegalArgumentException(ex.getMessage(), ex.getCause());
} }
@ -33,7 +38,7 @@ public enum ChatBotManager implements ChatBotManagerMXBean {
@Override @Override
public void start(@NotNull String name, @NotNull BotConfigSupport config) { public void start(@NotNull String name, @NotNull BotConfigSupport config) {
try { try {
ChatBotSupervisor.INSTANCE.start(name, config.unwrap()); supervisor.start(name, config.unwrap());
} catch (BotCreationException ex) { } catch (BotCreationException ex) {
throw new IllegalArgumentException(ex.getMessage(), ex.getCause()); throw new IllegalArgumentException(ex.getMessage(), ex.getCause());
} }
@ -41,23 +46,23 @@ public enum ChatBotManager implements ChatBotManagerMXBean {
@Override @Override
public void stop(@NotNull String name) throws InterruptedException { public void stop(@NotNull String name) throws InterruptedException {
ChatBotSupervisor.INSTANCE.stop(name); supervisor.stop(name);
} }
@Override @Override
public void stop() throws InterruptedException { public void stop() throws InterruptedException {
ChatBotSupervisor.INSTANCE.stop(); supervisor.stop();
} }
@Override @Override
public @NotNull Map<@NotNull String, @NotNull BotConfigSupport> getBots() { public @Unmodifiable @NotNull Map<@NotNull String, @NotNull BotConfigSupport> getBots() {
var out = new TreeMap<String, BotConfigSupport>(); var out = new TreeMap<String, BotConfigSupport>();
ChatBotSupervisor.INSTANCE.getBots().forEach((key, value) -> out.put(key, new BotConfigSupport(value))); supervisor.getBots().forEach((key, value) -> out.put(key, new BotConfigSupport(value)));
return Collections.unmodifiableMap(out); return Collections.unmodifiableMap(out);
} }
@Override @Override
public @NotNull SortedSet<@NotNull String> getBotImplementations() { public @Unmodifiable @NotNull SortedSet<@NotNull String> getBotImplementations() {
return Collections.unmodifiableSortedSet( return Collections.unmodifiableSortedSet(
ChatBotFactory.implementations().stream() ChatBotFactory.implementations().stream()
.map(Class::getCanonicalName) .map(Class::getCanonicalName)

@ -5,26 +5,30 @@ import eu.jonahbauer.chat.server.bot.ChatBotSupervisor;
import eu.jonahbauer.chat.server.management.BotConfigSupport; import eu.jonahbauer.chat.server.management.BotConfigSupport;
import eu.jonahbauer.chat.server.management.ChatBotSupportMXBean; import eu.jonahbauer.chat.server.management.ChatBotSupportMXBean;
import lombok.Getter; import lombok.Getter;
import lombok.extern.log4j.Log4j2; import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import java.lang.management.ManagementFactory; import java.lang.management.ManagementFactory;
import java.util.Objects;
@Getter @Getter
@Log4j2 @Slf4j
public class ChatBotSupport implements ChatBotSupportMXBean { public final class ChatBotSupport implements ChatBotSupportMXBean {
private final String name; private final @NotNull ChatBotSupervisor supervisor;
private final BotConfigSupport config; private final @NotNull String name;
private final @NotNull BotConfigSupport config;
public static void register(String name, BotConfig config) { public static void register(@NotNull ChatBotSupervisor supervisor, @NotNull String name, @NotNull BotConfig config) {
try { try {
var impl = new ChatBotSupport(supervisor, name, config);
var server = ManagementFactory.getPlatformMBeanServer(); var server = ManagementFactory.getPlatformMBeanServer();
server.registerMBean(new AdvancedMBean(new ChatBotSupport(name, config)), ChatBotSupportMXBean.getObjectName(name)); server.registerMBean(new AdvancedMBean(impl), ChatBotSupportMXBean.getObjectName(name));
} catch (Exception ex) { } catch (Exception ex) {
log.error("Could not register bot as an MBean.", ex); log.error("Could not register bot as an MBean.", ex);
} }
} }
public static void unregister(String name) { public static void unregister(@NotNull String name) {
try { try {
var server = ManagementFactory.getPlatformMBeanServer(); var server = ManagementFactory.getPlatformMBeanServer();
server.unregisterMBean(ChatBotSupportMXBean.getObjectName(name)); server.unregisterMBean(ChatBotSupportMXBean.getObjectName(name));
@ -33,12 +37,13 @@ public class ChatBotSupport implements ChatBotSupportMXBean {
} }
} }
private ChatBotSupport(String name, BotConfig config) { private ChatBotSupport(@NotNull ChatBotSupervisor supervisor, @NotNull String name, @NotNull BotConfig config) {
this.name = name; this.supervisor = Objects.requireNonNull(supervisor);
this.name = Objects.requireNonNull(name);
this.config = new BotConfigSupport(config); this.config = new BotConfigSupport(config);
} }
public void stop() throws InterruptedException { public void stop() throws InterruptedException {
ChatBotSupervisor.INSTANCE.stop(name); supervisor.stop(name);
} }
} }

@ -2,43 +2,48 @@ package eu.jonahbauer.chat.server.management.impl;
import eu.jonahbauer.chat.server.management.SocketManagerMXBean; import eu.jonahbauer.chat.server.management.SocketManagerMXBean;
import eu.jonahbauer.chat.server.socket.SocketSupervisor; import eu.jonahbauer.chat.server.socket.SocketSupervisor;
import lombok.SneakyThrows;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import javax.management.JMException;
import java.lang.management.ManagementFactory; import java.lang.management.ManagementFactory;
import java.util.Objects;
import java.util.SortedSet; import java.util.SortedSet;
public enum SocketManager implements SocketManagerMXBean { public final class SocketManager implements SocketManagerMXBean {
INSTANCE; private final @NotNull SocketSupervisor supervisor;
@SneakyThrows public static void init(@NotNull SocketSupervisor supervisor) throws JMException {
SocketManager() { var impl = new SocketManager(supervisor);
var server = ManagementFactory.getPlatformMBeanServer(); var server = ManagementFactory.getPlatformMBeanServer();
server.registerMBean(new AdvancedMBean(this), SocketManagerMXBean.NAME); server.registerMBean(new AdvancedMBean(impl), SocketManagerMXBean.NAME);
}
private SocketManager(@NotNull SocketSupervisor supervisor) {
this.supervisor = Objects.requireNonNull(supervisor);
} }
@Override @Override
public void setCredentials(@NotNull String username, @NotNull String password) { public void setCredentials(@NotNull String username, @NotNull String password) {
SocketSupervisor.INSTANCE.setAccount(username, password); supervisor.setAccount(username, password);
} }
@Override @Override
public void start(@NotNull String channel) { public void start(@NotNull String channel) {
SocketSupervisor.INSTANCE.start(channel); supervisor.start(channel);
} }
@Override @Override
public void stop(@NotNull String channel) throws InterruptedException { public void stop(@NotNull String channel) throws InterruptedException {
SocketSupervisor.INSTANCE.stop(channel); supervisor.stop(channel);
} }
@Override @Override
public void stop() throws InterruptedException { public void stop() throws InterruptedException {
SocketSupervisor.INSTANCE.stop(); supervisor.stop();
} }
@Override @Override
public @NotNull SortedSet<@NotNull String> getChannels() { public @NotNull SortedSet<@NotNull String> getChannels() {
return SocketSupervisor.INSTANCE.getChannels(); return supervisor.getChannels();
} }
} }

@ -3,30 +3,32 @@ package eu.jonahbauer.chat.server.management.impl;
import eu.jonahbauer.chat.server.management.SocketState; import eu.jonahbauer.chat.server.management.SocketState;
import eu.jonahbauer.chat.server.management.SocketSupportMXBean; import eu.jonahbauer.chat.server.management.SocketSupportMXBean;
import eu.jonahbauer.chat.server.socket.SocketSupervisor; import eu.jonahbauer.chat.server.socket.SocketSupervisor;
import lombok.AccessLevel;
import lombok.Getter; import lombok.Getter;
import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j;
import lombok.extern.log4j.Log4j2; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.lang.management.ManagementFactory; import java.lang.management.ManagementFactory;
import java.util.Date; import java.util.Date;
import java.util.Objects;
@Log4j2 @Slf4j
@Getter @Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE) public final class SocketSupport implements SocketSupportMXBean {
public class SocketSupport implements SocketSupportMXBean { private final @NotNull SocketSupervisor supervisor;
private final String channel; private final @NotNull String channel;
public static void register(String channel) { public static void register(@NotNull SocketSupervisor supervisor, @NotNull String channel) {
try { try {
var impl = new SocketSupport(supervisor, channel);
var server = ManagementFactory.getPlatformMBeanServer(); var server = ManagementFactory.getPlatformMBeanServer();
server.registerMBean(new AdvancedMBean(new SocketSupport(channel)), SocketSupportMXBean.getObjectName(channel)); server.registerMBean(new AdvancedMBean(impl), SocketSupportMXBean.getObjectName(channel));
} catch (Exception ex) { } catch (Exception ex) {
log.error("Could not register socket as an MBean.", ex); log.error("Could not register socket as an MBean.", ex);
} }
} }
public static void unregister(String name) { public static void unregister(@NotNull String name) {
try { try {
var server = ManagementFactory.getPlatformMBeanServer(); var server = ManagementFactory.getPlatformMBeanServer();
server.unregisterMBean(SocketSupportMXBean.getObjectName(name)); server.unregisterMBean(SocketSupportMXBean.getObjectName(name));
@ -35,30 +37,35 @@ public class SocketSupport implements SocketSupportMXBean {
} }
} }
private SocketSupport(@NotNull SocketSupervisor supervisor, @NotNull String channel) {
this.supervisor = Objects.requireNonNull(supervisor);
this.channel = Objects.requireNonNull(channel);
}
@Override @Override
public Date getCooldownUntil() { public @Nullable Date getCooldownUntil() {
var cooldown = SocketSupervisor.INSTANCE.getCooldownUntil(channel); var cooldown = supervisor.getCooldownUntil(channel);
return cooldown != null ? Date.from(cooldown) : null; return cooldown != null ? Date.from(cooldown) : null;
} }
@Override @Override
public SocketState getState() { public @Nullable SocketState getState() {
return SocketSupervisor.INSTANCE.getState(channel); return supervisor.getState(channel);
} }
@Override @Override
public void stop() throws InterruptedException { public void stop() throws InterruptedException {
SocketSupervisor.INSTANCE.stop(channel); supervisor.stop(channel);
} }
@Override @Override
public void restart() { public void restart() {
SocketSupervisor.INSTANCE.restart(channel); supervisor.restart(channel);
} }
@Override @Override
public void send(String name, String message, boolean bottag, boolean publicid) { public void send(@NotNull String name, @NotNull String message, boolean bottag, boolean publicid) {
SocketSupervisor.INSTANCE.send(channel, name, message, bottag, publicid); supervisor.send(channel, name, message, bottag, publicid);
} }
} }

@ -1,6 +1,5 @@
package eu.jonahbauer.chat.server.socket; package eu.jonahbauer.chat.server.socket;
import com.fasterxml.jackson.annotation.JsonFormat;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.util.Objects; import java.util.Objects;
@ -10,8 +9,8 @@ public record OutgoingMessage(
@NotNull String message, @NotNull String message,
@NotNull String channel, @NotNull String channel,
long delay, long delay,
@JsonFormat(shape = JsonFormat.Shape.NUMBER) boolean publicid, boolean publicid,
@JsonFormat(shape = JsonFormat.Shape.NUMBER) boolean bottag boolean bottag
) { ) {
public OutgoingMessage { public OutgoingMessage {
Objects.requireNonNull(channel, "channel"); Objects.requireNonNull(channel, "channel");

@ -1,21 +1,23 @@
package eu.jonahbauer.chat.server.socket; package eu.jonahbauer.chat.server.socket;
import com.fasterxml.jackson.core.JsonProcessingException; import com.google.gson.*;
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.Chat;
import eu.jonahbauer.chat.bot.api.Message; import eu.jonahbauer.chat.bot.api.Message;
import eu.jonahbauer.chat.server.Config; import eu.jonahbauer.chat.server.Config;
import eu.jonahbauer.chat.server.bot.ChatBotSupervisor; import eu.jonahbauer.chat.server.bot.ChatBotSupervisor;
import eu.jonahbauer.chat.server.json.BooleanTypeAdapter;
import eu.jonahbauer.chat.server.json.LocalDateTimeTypeAdapter;
import eu.jonahbauer.chat.server.json.RuntimeTypeAdapterFactory;
import eu.jonahbauer.chat.server.management.SocketState; import eu.jonahbauer.chat.server.management.SocketState;
import eu.jonahbauer.chat.server.management.impl.SocketManager;
import eu.jonahbauer.chat.server.management.impl.SocketSupport; import eu.jonahbauer.chat.server.management.impl.SocketSupport;
import eu.jonahbauer.chat.server.util.Lazy;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2; import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.Level;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
import java.io.IOException; import javax.management.JMException;
import java.net.CookieManager; import java.net.CookieManager;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
@ -25,23 +27,32 @@ import java.net.http.HttpResponse;
import java.net.http.WebSocket; import java.net.http.WebSocket;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime;
import java.util.*; import java.util.*;
import java.util.concurrent.*; import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
import static eu.jonahbauer.chat.server.util.UrlTemplateProcessor.URL; import static eu.jonahbauer.chat.server.util.UrlTemplateProcessor.URL;
@Log4j2 @Slf4j
public enum SocketSupervisor implements Chat { public final class SocketSupervisor implements Chat, AutoCloseable {
INSTANCE;
private static final URI AUTH_SERVER = URI.create("https://chat.qed-verein.de/rubychat/account"); 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 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 String SERVER = "wss://chat.qed-verein.de/websocket?position=0&version=2&channel=";
private static final int PING_INTERVAL = 30; private static final int PING_INTERVAL = 30;
private static final ScheduledExecutorService SCHEDULER = Executors.newScheduledThreadPool(1, Thread.ofVirtual().name("SocketSupervisor").factory()); private static final ScheduledExecutorService SCHEDULER = Executors.newScheduledThreadPool(1, Thread.ofVirtual().name("SocketSupervisor").factory());
private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new JavaTimeModule()); private static final Gson GSON = new GsonBuilder()
.registerTypeAdapterFactory(RuntimeTypeAdapterFactory.of(Message.class)
.registerSubtype(Message.Ping.class, "ping")
.registerSubtype(Message.Post.class, "post")
.registerSubtype(Message.Ack.class, "ack")
.registerSubtype(Message.Pong.class, "pong")
)
.registerTypeAdapter(boolean.class, new BooleanTypeAdapter())
.registerTypeAdapter(Boolean.class, new BooleanTypeAdapter())
.registerTypeAdapter(LocalDateTime.class, new LocalDateTimeTypeAdapter())
.create();
private volatile @Nullable Config.Account account; private volatile @Nullable Config.Account account;
private volatile @Nullable Credentials credentials; private volatile @Nullable Credentials credentials;
@ -49,6 +60,13 @@ public enum SocketSupervisor implements Chat {
private final @NotNull CookieManager cookie = new CookieManager(); private final @NotNull CookieManager cookie = new CookieManager();
private final @NotNull HttpClient client = HttpClient.newBuilder().cookieHandler(cookie).build(); private final @NotNull HttpClient client = HttpClient.newBuilder().cookieHandler(cookie).build();
private final @NotNull ConcurrentMap<@NotNull String, @NotNull ChatClient> sockets = new ConcurrentHashMap<>(); private final @NotNull ConcurrentMap<@NotNull String, @NotNull ChatClient> sockets = new ConcurrentHashMap<>();
private final @NotNull Lazy<ChatBotSupervisor> chatBotSupervisor;
private final @NotNull ReentrantLock lock = new ReentrantLock();
public SocketSupervisor(@NotNull Lazy<ChatBotSupervisor> chatBotSupervisor) throws JMException {
SocketManager.init(this);
this.chatBotSupervisor = chatBotSupervisor;
}
public void setAccount(@NotNull Config.Account account) { public void setAccount(@NotNull Config.Account account) {
this.account = Objects.requireNonNull(account, "account"); this.account = Objects.requireNonNull(account, "account");
@ -67,10 +85,10 @@ public enum SocketSupervisor implements Chat {
} else { } else {
var out = new OutgoingMessage(name, message, channel, socket.delay, publicId, bottag); var out = new OutgoingMessage(name, message, channel, socket.delay, publicId, bottag);
try { try {
socket.send(MAPPER.writeValueAsString(out)); socket.send(GSON.toJson(out, OutgoingMessage.class));
log.info("Sending message: {}", out); log.info("Sending message: {}", out);
return true; return true;
} catch (JsonProcessingException e) { } catch (Exception e) {
log.error("Could not serialize message: {}", out, e); log.error("Could not serialize message: {}", out, e);
return false; return false;
} }
@ -82,11 +100,18 @@ public enum SocketSupervisor implements Chat {
* @param config a configuration * @param config a configuration
* @throws IllegalStateException if any sockets are already running * @throws IllegalStateException if any sockets are already running
*/ */
public synchronized void start(@NotNull Config config) { public void start(@NotNull Config config) {
if (!this.sockets.isEmpty()) throw new IllegalStateException("start(Config) may not be used when any sockets are running already"); lock.lock();
try {
if (!this.sockets.isEmpty()) {
throw new IllegalStateException("start(Config) may not be used when any sockets are running already");
}
setAccount(config.account()); setAccount(config.account());
config.channels().forEach(this::start); config.channels().forEach(this::start);
} finally {
lock.unlock();
}
} }
/** /**
@ -94,11 +119,18 @@ public enum SocketSupervisor implements Chat {
* @param channel the channel * @param channel the channel
* @throws IllegalStateException if a socket is already connected to that channel * @throws IllegalStateException if a socket is already connected to that channel
*/ */
public synchronized void start(@NotNull String channel) { public void start(@NotNull String channel) {
if (sockets.containsKey(channel)) throw new SocketCreationException("Duplicate channel: " + channel); lock.lock();
try {
if (sockets.containsKey(channel)) {
throw new SocketCreationException("Duplicate channel: " + channel);
}
var socket = new ChatClient(channel); var socket = new ChatClient(channel);
this.sockets.put(channel, socket); this.sockets.put(channel, socket);
} finally {
lock.unlock();
}
} }
/** /**
@ -151,6 +183,11 @@ public enum SocketSupervisor implements Chat {
} }
} }
@Override
public void close() throws InterruptedException {
stop();
}
public @NotNull SortedSet<@NotNull String> getChannels() { public @NotNull SortedSet<@NotNull String> getChannels() {
return Collections.unmodifiableSortedSet(new TreeSet<>(sockets.keySet())); return Collections.unmodifiableSortedSet(new TreeSet<>(sockets.keySet()));
} }
@ -191,7 +228,7 @@ public enum SocketSupervisor implements Chat {
return credentials; return credentials;
} }
private record Credentials(String userid, String pwhash) {} private record Credentials(@NotNull String userid, @NotNull String pwhash) {}
private sealed interface ChatClientState { private sealed interface ChatClientState {
@ -218,7 +255,7 @@ public enum SocketSupervisor implements Chat {
} }
private final class ChatClient implements WebSocket.Listener, ChatClientState { private final class ChatClient implements WebSocket.Listener, ChatClientState {
private final String channel; private final @NotNull String channel;
private final @NotNull ReentrantLock lock = new ReentrantLock(); private final @NotNull ReentrantLock lock = new ReentrantLock();
private final @NotNull CountDownLatch stopped = new CountDownLatch(1); private final @NotNull CountDownLatch stopped = new CountDownLatch(1);
@ -227,7 +264,7 @@ public enum SocketSupervisor implements Chat {
public ChatClient(@NotNull String channel) { public ChatClient(@NotNull String channel) {
this.channel = channel; this.channel = channel;
SocketSupport.register(channel); SocketSupport.register(SocketSupervisor.this, channel);
this.state.onEnter(); this.state.onEnter();
} }
@ -419,18 +456,23 @@ public enum SocketSupervisor implements Chat {
private void onMessage(@NotNull List<@NotNull CharSequence> parts) { private void onMessage(@NotNull List<@NotNull CharSequence> parts) {
var text = String.join("", parts); var text = String.join("", parts);
Message message;
try { try {
var message = MAPPER.readValue(text, Message.class); message = GSON.fromJson(text, Message.class);
} catch (Exception e) {
log.warn("Could not parse as message: {}", text, e);
return;
}
var level = message instanceof Message.Post || message instanceof Message.Ack ? Level.INFO : Level.DEBUG; if (message instanceof Message.Post) {
log.log(level, "Received message: {}", message); log.info("Received message: {}", message);
} else {
log.debug("Received message: {}", message);
}
if (message instanceof Message.Post post) { if (message instanceof Message.Post post) {
delay = post.id() + 1; delay = post.id() + 1;
ChatBotSupervisor.INSTANCE.onMessage(post); SocketSupervisor.this.chatBotSupervisor.get().onMessage(post);
}
} catch (JsonProcessingException e) {
log.warn("Could not parse as message: {}", text, e);
} }
} }
@ -502,12 +544,8 @@ public enum SocketSupervisor implements Chat {
} }
private void ping() { private void ping() {
try { socket.sendText(GSON.toJson(Message.PING, Message.class), true);
socket.sendText(MAPPER.writeValueAsString(Message.PING), true); log.debug("Sending message: {}", Message.PING);
log.debug("Sending message: {}", Message.PING);
} catch (IOException ex) {
log.error("Failed to send ping", ex);
}
} }
@Override @Override

@ -0,0 +1,26 @@
package eu.jonahbauer.chat.server.util;
import org.jetbrains.annotations.NotNull;
import java.util.Objects;
public interface Lazy<T> {
@NotNull T get();
class MutableLazy<T> implements Lazy<T> {
private T value;
@Override
public @NotNull T get() {
var value = this.value;
if (value == null) throw new IllegalStateException();
return value;
}
public @NotNull T set(@NotNull T value) {
if (this.value != null) throw new IllegalStateException();
this.value = Objects.requireNonNull(value);
return value;
}
}
}

@ -1,17 +1,15 @@
module eu.jonahbauer.chat.server { module eu.jonahbauer.chat.server {
exports 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.bot to com.google.gson;
opens eu.jonahbauer.chat.server.socket to com.fasterxml.jackson.databind; opens eu.jonahbauer.chat.server.socket to com.google.gson;
requires com.fasterxml.jackson.core; requires com.google.gson;
requires com.fasterxml.jackson.databind;
requires com.fasterxml.jackson.datatype.jsr310;
requires eu.jonahbauer.chat.bot.api; requires eu.jonahbauer.chat.bot.api;
requires eu.jonahbauer.chat.server.management; requires eu.jonahbauer.chat.server.management;
requires java.management; requires java.management;
requires java.net.http; requires java.net.http;
requires org.apache.logging.log4j; requires org.slf4j;
requires org.jetbrains.annotations;
requires static transitive org.jetbrains.annotations;
requires static lombok; requires static lombok;
} }

@ -1,12 +0,0 @@
<Configuration>
<Appenders>
<Console name="console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight{%-5level} [%15.15t] %style{%c{1}}{cyan} : %msg%n" />
</Console>
</Appenders>
<Loggers>
<Root level="info" additivity="false">
<AppenderRef ref="console" />
</Root>
</Loggers>
</Configuration>

@ -0,0 +1,11 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) [%15.15t] %cyan(%c{1}) : %msg%n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="STDOUT"/>
</root>
</configuration>

@ -4,6 +4,7 @@ rootProject.name = "ChatBot"
include("server") include("server")
include("bot-api") include("bot-api")
include("bot-database")
include("bots") include("bots")
include("management") include("management")
@ -11,6 +12,9 @@ include("management")
include("bots:ping-bot") include("bots:ping-bot")
project(":bots:ping-bot").name = "ping-bot" project(":bots:ping-bot").name = "ping-bot"
include("bots:pizza-bot")
project(":bots:pizza-bot").name = "pizza-bot"
dependencyResolutionManagement { dependencyResolutionManagement {
repositories { repositories {
mavenCentral() mavenCentral()

Loading…
Cancel
Save