add sleep bot
parent
ff6b1c3e3c
commit
86bbe021b0
@ -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
|
||||||
|
);
|
Loading…
Reference in New Issue