add sleep bot
This commit is contained in:
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
|
||||
|
10
bots/sleep-tracker/build.gradle.kts
Normal file
10
bots/sleep-tracker/build.gradle.kts
Normal file
@ -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);
|
||||
}
|
||||
}
|
14
bots/sleep-tracker/src/main/java/module-info.java
Normal file
14
bots/sleep-tracker/src/main/java/module-info.java
Normal file
@ -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…
x
Reference in New Issue
Block a user