5 Commits
0.1.0 ... 0.2.0

Author SHA1 Message Date
jbb01
7940ab5a82 [Gradle Release Plugin] - pre tag commit: '0.2.0'. 2024-04-04 14:28:29 +02:00
jbb01
86bbe021b0 add sleep bot 2024-04-04 14:27:57 +02:00
jbb01
ff6b1c3e3c [Gradle Release Plugin] - new version commit: '0.2.0-SNAPSHOT'. 2024-04-04 14:08:07 +02:00
jbb01
0add75c182 [Gradle Release Plugin] - pre tag commit: '0.1.0'. 2024-04-04 14:07:48 +02:00
143347b2d9 add pizza bot 2024-04-04 14:05:21 +02:00
12 changed files with 377 additions and 49 deletions

View File

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

View File

@@ -23,14 +23,12 @@ 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 {
@@ -328,23 +326,20 @@ public class PizzaBot extends ChatBot {
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})");
});
}
if (items.isEmpty()) lines.add("(keine Einträge)");
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));
}
@@ -405,36 +400,27 @@ public class PizzaBot extends ChatBot {
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());
}
items.forEach((user, receipt) -> {
if (receipt.payed()) {
lines.add(strikethrough(user.username() + ": " + receipt.totalAsString()));
} else if (receipt.unpayed()) {
lines.add(user.username() + ": " + receipt.totalAsString());
} else {
lines.add(user.username() + ": " + strikethrough(receipt.totalAsString()) + " " + receipt.pendingAsString());
}
});
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");
}
// /**

View File

@@ -45,8 +45,9 @@ public class FormatUtils {
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');
out.append('\u0336').append(string.charAt(i));
}
out.append('\u0335');
return out.toString();
}

View File

@@ -507,8 +507,6 @@ class PizzaBotTest {
%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()));
}
@@ -525,8 +523,6 @@ class PizzaBotTest {
%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()));
}
@@ -543,8 +539,6 @@ class PizzaBotTest {
%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]);
@@ -567,8 +561,6 @@ class PizzaBotTest {
%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(
@@ -593,8 +585,6 @@ class PizzaBotTest {
%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()));
}

View 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")))
}

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1 +1 @@
version=0.1.0
version=0.2.0

View File

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