add sleep bot

jbb01 10 months ago
parent ff6b1c3e3c
commit 86bbe021b0

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

@ -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,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<String> mink;
private final @NotNull Set<String> 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<List<String>, Set<String>>) Set::copyOf
: (Function<List<String>, Set<String>>) 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<EventType> 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<SleepEvent> 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<Duration> getSleepDurations(@NotNull List<@NotNull SleepEvent> events) {
return getSleepDurations(events, minSleepDuration, maxSleepDuration);
}
private static @NotNull List<Duration> getSleepDurations(@NotNull List<@NotNull SleepEvent> events, @NotNull Duration min, @NotNull Duration max) {
var durations = new ArrayList<Duration>();
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<Duration> 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);
}
}
}
}

@ -0,0 +1,5 @@
package eu.jonahbauer.chat.bot.sleep.model;
public enum EventType {
MINK, ANKH
}

@ -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<SleepEvent> {
public SleepEvent {
Objects.requireNonNull(user);
Objects.requireNonNull(type);
Objects.requireNonNull(timestamp);
}
@Override
public int compareTo(@NotNull SleepEvent sleepEvent) {
return timestamp.compareTo(sleepEvent.timestamp);
}
}

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

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

@ -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()

Loading…
Cancel
Save