diff --git a/Dockerfile b/Dockerfile index 91ab9ec..9d28c6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ 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/ +COPY ./bots/sleep-tracker/build.gradle.kts ./bots/sleep-tracker/ RUN ./gradlew deps --no-daemon --info --stacktrace # Build Application diff --git a/bots/sleep-tracker/build.gradle.kts b/bots/sleep-tracker/build.gradle.kts new file mode 100644 index 0000000..65604fa --- /dev/null +++ b/bots/sleep-tracker/build.gradle.kts @@ -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"))) +} \ No newline at end of file diff --git a/bots/sleep-tracker/src/main/java/eu/jonahbauer/chat/bot/sleep/SleepBot.java b/bots/sleep-tracker/src/main/java/eu/jonahbauer/chat/bot/sleep/SleepBot.java new file mode 100644 index 0000000..0d25873 --- /dev/null +++ b/bots/sleep-tracker/src/main/java/eu/jonahbauer/chat/bot/sleep/SleepBot.java @@ -0,0 +1,289 @@ +package eu.jonahbauer.chat.bot.sleep; + +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.sleep.model.EventType; +import eu.jonahbauer.chat.bot.sleep.model.SleepEvent; +import lombok.Lombok; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; + +@Slf4j +public class SleepBot extends ChatBot { + private static final BotConfig.Key.OfString DATABASE = BotConfig.Key.ofString("database"); + + // name matching + private static final BotConfig.Key.OfStringArray WHITELIST = BotConfig.Key.ofStringArray("whitelist"); + private static final BotConfig.Key.OfStringArray BLACKLIST = BotConfig.Key.ofStringArray("blacklist"); + private static final BotConfig.Key.OfBoolean WHITESPACE_SENSITIVE = BotConfig.Key.ofBoolean("whitespace_sensitive"); + + // type matching + private static final BotConfig.Key.OfStringArray MINK_KEYWORDS = BotConfig.Key.ofStringArray("mink"); + private static final BotConfig.Key.OfStringArray ANKH_KEYWORDS = BotConfig.Key.ofStringArray("ankh"); + private static final BotConfig.Key.OfLong MAX_MESSAGE_LENGTH = BotConfig.Key.ofLong("max_message_length"); + private static final BotConfig.Key.OfBoolean CASE_SENSITIVE = BotConfig.Key.ofBoolean("case_sensitive"); + + // duration + private static final BotConfig.Key.OfLong MIN_SLEEP_SECONDS = BotConfig.Key.ofLong("min_sleep_seconds"); + private static final BotConfig.Key.OfLong MAX_SLEEP_SECONDS = BotConfig.Key.ofLong("max_sleep_seconds"); + + private static final BotConfig DEFAULT_CONFIG = BotConfig.builder() + .name("Schlaf-Tracker") + .value(WHITESPACE_SENSITIVE, false) + .value(CASE_SENSITIVE, false) + .value(MINK_KEYWORDS, List.of("mink")) + .value(ANKH_KEYWORDS, List.of("ankh")) + .value(MAX_MESSAGE_LENGTH, 100L) + .value(MIN_SLEEP_SECONDS, 3600L) + .value(MAX_SLEEP_SECONDS, 16 * 3600L) + .build(); + + private final @NotNull Database database; + + // name matching + private final @Nullable Filter filter; + private final boolean whitespaceSensitive; + + // type matching + private final @NotNull Set mink; + private final @NotNull Set ankh; + private final boolean caseSensitive; + private final long maxMessageLength; + + // durations + private final @NotNull Duration minSleepDuration; + private final @NotNull Duration maxSleepDuration; + + public SleepBot() { + super(DEFAULT_CONFIG); + + this.database = new Database(getConfig().require(DATABASE)); + + var whitelist = getConfig().get(WHITELIST); + var blacklist = getConfig().get(BLACKLIST); + + if (whitelist.isPresent()) { + var items = new ArrayList<>(whitelist.get()); + blacklist.ifPresent(items::removeAll); + this.filter = new Filter.WhiteList(items); + } else { + this.filter = blacklist.map(Filter.BlackList::new).orElse(null); + } + this.whitespaceSensitive = getConfig().require(WHITESPACE_SENSITIVE); + + this.caseSensitive = getConfig().require(CASE_SENSITIVE); + this.maxMessageLength = getConfig().require(MAX_MESSAGE_LENGTH); + var normalize = caseSensitive + ? (Function, Set>) Set::copyOf + : (Function, Set>) items -> Set.of(items.stream().map(str -> str.toLowerCase(Locale.ROOT)).toArray(String[]::new)); + this.mink = normalize.apply(getConfig().require(MINK_KEYWORDS)); + this.ankh = normalize.apply(getConfig().require(ANKH_KEYWORDS)); + + this.minSleepDuration = Duration.ofSeconds(getConfig().require(MIN_SLEEP_SECONDS)); + this.maxSleepDuration = Duration.ofSeconds(getConfig().require(MAX_SLEEP_SECONDS)); + } + + @Override + protected void onMessage(@NotNull Post post) { + if (!isTracked(post)) return; + + var type = getEventType(post); + if (type.isEmpty()) return; + + var event = new SleepEvent(getUserName(post), type.get(), Instant.now()); + + try { + if (type.get() == EventType.MINK) handleMink(post, event); + database.save(event); + } catch (SQLException ex) { + throw Lombok.sneakyThrow(ex); + } + } + + private void handleMink(@NotNull Post post, @NotNull SleepEvent mink) throws SQLException { + var events = getSleepEvents(getUserName(post)); + var durations = getSleepDurations(events); + if (durations.isEmpty()) return; + + var previous = events.getLast(); + if (previous.type() != EventType.ANKH) { + post("Hat da jemand ein \"Ankh\" vergessen?"); + return; + } + + var duration = Duration.between(previous.timestamp(), mink.timestamp()); + if (duration.compareTo(minSleepDuration) < 0 || duration.compareTo(maxSleepDuration) > 0) return; + + var statistics = Statistics.of(durations); + log.debug("Sleep-Statistics for \"{}\": {}", getUserName(post), statistics); + + if (duration.compareTo(statistics.longest()) > 0) { + post(STR.""" + Guten Morgen \{post.name().trim()}! Du hast \{toString(duration)} geschlafen. \ + So lange hast du seit noch nie geschlafen.\ + """); + } else if (duration.compareTo(statistics.shortest()) < 0) { + post(STR.""" + Guten Morgen \{post.name().trim()}! Du hast \{toString(duration)} geschlafen. \ + So kurz hast du noch nie geschlafen.\ + """); + } else if (duration.compareTo(statistics.average().plus(statistics.deviation())) > 0) { + post(STR.""" + Guten Morgen \{post.name().trim()}! Du hast überdurchschnittliche \{toString(duration)} geschlafen. \ + """); + } else if (duration.compareTo(statistics.average().minus(statistics.deviation())) < 0) { + post(STR.""" + Guten Morgen \{post.name().trim()}! Du hast unterdurchschnittliche \{toString(duration)} geschlafen. \ + """); + } + } + + private @NotNull String toString(@NotNull Duration duration) { + var seconds = duration.getSeconds(); + var hours = seconds / 3600; + var minutes = seconds / 60 % 60; + return STR."\{hours} Stunden und \{minutes} Minuten"; + } + + private boolean isTracked(@NotNull Post post) { + return filter == null || filter.test(getUserName(post)); + } + + private @NotNull String getUserName(@NotNull Post post) { + var name = post.name(); + if (!whitespaceSensitive) name = name.trim(); + if (!caseSensitive) name = name.toLowerCase(Locale.ROOT); + return name; + } + + private @NotNull Optional getEventType(@NotNull Post post) { + var message = post.message(); + if (message.length() > maxMessageLength) return Optional.empty(); + if (!caseSensitive) message = message.toLowerCase(Locale.ROOT); + + for (var msg : mink) if (message.contains(msg)) return Optional.of(EventType.MINK); + for (var msg : ankh) if (message.contains(msg)) return Optional.of(EventType.ANKH); + + return Optional.empty(); + } + + private @NotNull List getSleepEvents(@NotNull String name) throws SQLException { + return database.executeQuery( + SleepEvent.class, "SELECT * FROM sleep_event WHERE user = ? ORDER BY timestamp", + stmt -> stmt.setString(1, name) + ); + } + + private @NotNull List getSleepDurations(@NotNull List<@NotNull SleepEvent> events) { + return getSleepDurations(events, minSleepDuration, maxSleepDuration); + } + + private static @NotNull List getSleepDurations(@NotNull List<@NotNull SleepEvent> events, @NotNull Duration min, @NotNull Duration max) { + var durations = new ArrayList(); + + var it = events.iterator(); + while (true) { + var duration = next(it); + if (duration.isEmpty()) break; + + if (min.compareTo(duration.get()) < 0 && duration.get().compareTo(max) < 0) { + durations.add(duration.get()); + } + } + + return durations; + } + + private static @NotNull Optional next(@NotNull Iterator<@NotNull SleepEvent> it) { + if (!it.hasNext()) return Optional.empty(); + var previous = it.next(); + + while (it.hasNext()) { + var current = it.next(); + if (previous.type() == EventType.ANKH && current.type() == EventType.MINK) { + return Optional.of(Duration.between(previous.timestamp(), current.timestamp())); + } + previous = current; + } + + return Optional.empty(); + } + + private record Statistics( + @NotNull Duration shortest, + @NotNull Duration longest, + @NotNull Duration average, + @NotNull Duration deviation, + int n + ) { + private static @NotNull Statistics of(@NotNull List<@NotNull Duration> durations) { + if (durations.isEmpty()) throw new IllegalArgumentException(); + + var shortest = durations.getFirst(); + var longest = durations.getFirst(); + var sum = Duration.ZERO; + + for (var duration : durations) { + if (duration.compareTo(shortest) < 0) shortest = duration; + if (duration.compareTo(longest) > 0) longest = duration; + sum = sum.plus(duration); + } + + var mean = sum.dividedBy(durations.size()); + long deviation = 0; + + for (Duration duration : durations) { + var delta = duration.getSeconds() - mean.getSeconds(); + deviation = Math.addExact(deviation, Math.multiplyExact(delta, delta)); + } + + var standard = Duration.ofSeconds((long) Math.sqrt((double) deviation / durations.size())); + + return new Statistics(shortest, longest, mean, standard, durations.size()); + } + + private Statistics { + Objects.requireNonNull(shortest); + Objects.requireNonNull(longest); + Objects.requireNonNull(average); + Objects.requireNonNull(deviation); + } + } + + private sealed interface Filter extends Predicate<@NotNull String> { + + record BlackList(@NotNull List<@NotNull String> list) implements Filter { + + public BlackList { + list = List.copyOf(list); + } + + @Override + public boolean test(@NotNull String name) { + return !list.contains(name); + } + } + + record WhiteList(@NotNull List<@NotNull String> list) implements Filter { + + public WhiteList { + list = List.copyOf(list); + } + + @Override + public boolean test(@NotNull String name) { + return list.contains(name); + } + } + } +} diff --git a/bots/sleep-tracker/src/main/java/eu/jonahbauer/chat/bot/sleep/model/EventType.java b/bots/sleep-tracker/src/main/java/eu/jonahbauer/chat/bot/sleep/model/EventType.java new file mode 100644 index 0000000..bb4e301 --- /dev/null +++ b/bots/sleep-tracker/src/main/java/eu/jonahbauer/chat/bot/sleep/model/EventType.java @@ -0,0 +1,5 @@ +package eu.jonahbauer.chat.bot.sleep.model; + +public enum EventType { + MINK, ANKH +} diff --git a/bots/sleep-tracker/src/main/java/eu/jonahbauer/chat/bot/sleep/model/SleepEvent.java b/bots/sleep-tracker/src/main/java/eu/jonahbauer/chat/bot/sleep/model/SleepEvent.java new file mode 100644 index 0000000..51cf57d --- /dev/null +++ b/bots/sleep-tracker/src/main/java/eu/jonahbauer/chat/bot/sleep/model/SleepEvent.java @@ -0,0 +1,23 @@ +package eu.jonahbauer.chat.bot.sleep.model; + +import org.jetbrains.annotations.NotNull; + +import java.time.Instant; +import java.util.Objects; + +public record SleepEvent( + @NotNull String user, + @NotNull EventType type, + @NotNull Instant timestamp +) implements Comparable { + public SleepEvent { + Objects.requireNonNull(user); + Objects.requireNonNull(type); + Objects.requireNonNull(timestamp); + } + + @Override + public int compareTo(@NotNull SleepEvent sleepEvent) { + return timestamp.compareTo(sleepEvent.timestamp); + } +} diff --git a/bots/sleep-tracker/src/main/java/module-info.java b/bots/sleep-tracker/src/main/java/module-info.java new file mode 100644 index 0000000..bb3fb3f --- /dev/null +++ b/bots/sleep-tracker/src/main/java/module-info.java @@ -0,0 +1,14 @@ +import eu.jonahbauer.chat.bot.api.ChatBot; +import eu.jonahbauer.chat.bot.sleep.SleepBot; + +module eu.jonahbauer.chat.bot.sleep { + exports eu.jonahbauer.chat.bot.sleep.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 SleepBot; +} \ No newline at end of file diff --git a/bots/sleep-tracker/src/main/resources/eu/jonahbauer/chat/bot/sleep/init.sql b/bots/sleep-tracker/src/main/resources/eu/jonahbauer/chat/bot/sleep/init.sql new file mode 100644 index 0000000..ea2803e --- /dev/null +++ b/bots/sleep-tracker/src/main/resources/eu/jonahbauer/chat/bot/sleep/init.sql @@ -0,0 +1,6 @@ +CREATE TABLE sleep_event( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user VARCHAR(255) NOT NULL, + type VARCHAR(16) NOT NULL CHECK (type IN ('MINK', 'ANKH')), + timestamp INTEGER NOT NULL +); \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 82e1659..1d9585b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,6 +15,9 @@ project(":bots:ping-bot").name = "ping-bot" include("bots:pizza-bot") project(":bots:pizza-bot").name = "pizza-bot" +include("bots:sleep-tracker") +project(":bots:sleep-tracker").name = "sleep-tracker" + dependencyResolutionManagement { repositories { mavenCentral()