add pizza bot

main
jbb01 10 months ago committed by jbb01
parent 8bc0f8cce0
commit 6d0335b8d9

@ -1,5 +1,6 @@
plugins {
id("java-library")
id("java-test-fixtures")
id("chat-bot.java-conventions")
}

@ -2,7 +2,7 @@ import eu.jonahbauer.chat.bot.api.ChatBot;
module eu.jonahbauer.chat.bot.api {
exports eu.jonahbauer.chat.bot.api;
exports eu.jonahbauer.chat.bot.impl to eu.jonahbauer.chat.server;
exports eu.jonahbauer.chat.bot.impl to eu.jonahbauer.chat.server, eu.jonahbauer.chat.bot.api.fixtures;
requires transitive eu.jonahbauer.chat.bot.config;
requires static transitive org.jetbrains.annotations;

@ -0,0 +1,21 @@
package eu.jonahbauer.chat.bot.test;
import eu.jonahbauer.chat.bot.api.ChatBot;
import eu.jonahbauer.chat.bot.config.BotConfig;
import eu.jonahbauer.chat.bot.impl.ChatBotFactory;
import org.jetbrains.annotations.NotNull;
import java.util.function.Supplier;
public final class ChatBotFactoryAccess {
private ChatBotFactoryAccess() {}
public static <T extends ChatBot> T create(@NotNull BotConfig config, @NotNull Supplier<T> supplier) {
return ScopedValue.where(ChatBotFactory.BOT_CONFIG, config).get(supplier);
}
public static <T extends ChatBot> T create(@NotNull Supplier<T> supplier) {
return create(BotConfig.EMPTY, supplier);
}
}

@ -0,0 +1,27 @@
package eu.jonahbauer.chat.bot.test;
import eu.jonahbauer.chat.bot.api.Chat;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
public class MockChat implements Chat {
private final @NotNull List<@NotNull Message> messages = new ArrayList<>();
@Override
public boolean send(@NotNull String channel, @NotNull String name, @NotNull String message, boolean bottag, boolean publicId) {
messages.add(new Message(channel, name, message, bottag, publicId));
return true;
}
public @NotNull List<@NotNull Message> getMessages() {
return messages;
}
public record Message(@NotNull String channel, @NotNull String name, @NotNull String message, boolean bottag, boolean publicId) {
public Message(@NotNull String channel, @NotNull String name, @NotNull String message) {
this(channel, name, message, true, true);
}
}
}

@ -0,0 +1,7 @@
module eu.jonahbauer.chat.bot.api.fixtures {
exports eu.jonahbauer.chat.bot.test;
requires eu.jonahbauer.chat.bot.api;
requires eu.jonahbauer.chat.bot.config;
requires static transitive org.jetbrains.annotations;
}

@ -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,692 @@
package eu.jonahbauer.chat.bot.pizza;
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.database.NonUniqueResultException;
import eu.jonahbauer.chat.bot.pizza.model.*;
import eu.jonahbauer.chat.bot.pizza.model.table.Order;
import eu.jonahbauer.chat.bot.pizza.model.table.OrderItem;
import eu.jonahbauer.chat.bot.pizza.model.table.Pizza;
import eu.jonahbauer.chat.bot.pizza.model.table.User;
import eu.jonahbauer.chat.bot.pizza.model.view.OrderDetail;
import eu.jonahbauer.chat.bot.pizza.model.view.OrderItemDetail;
import eu.jonahbauer.chat.bot.pizza.util.ArgumentParser;
import eu.jonahbauer.chat.bot.pizza.util.Pair;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.VisibleForTesting;
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 {
private static final @NotNull ScopedValue<Double> ANNOYANCE = ScopedValue.newInstance();
private static final @NotNull ScopedValue<User> USER = ScopedValue.newInstance();
private static final @NotNull BotConfig.Key.OfStringArray PRIMARY_CHANNELS = BotConfig.Key.ofStringArray("primary_channels");
private static final @NotNull BotConfig.Key.OfString DATABASE = BotConfig.Key.ofString("database");
private static final @NotNull DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("HH:mm")
.withZone(ZoneId.of("Europe/Berlin"))
.withLocale(Locale.GERMANY);
private static final @NotNull List<@NotNull String> ANNOYED_RESPONSES = List.of(
"Es ist so dunkel, ich kann dich nicht hören.",
"Jetzt schrei doch nicht so!",
"Fresse!"
);
private final @Nullable Set<@NotNull String> primaryChannels;
private final @NotNull PizzaService service;
public PizzaBot() {
super("Marek");
this.primaryChannels = getConfig().get(PRIMARY_CHANNELS).map(Set::copyOf).orElse(null);
this.service = new PizzaService(new Database(getConfig().require(DATABASE)));
}
@VisibleForTesting
PizzaBot(@Nullable Set<@NotNull String> primaryChannels, @NotNull Database database) {
super("Marek");
this.primaryChannels = primaryChannels;
this.service = new PizzaService(database);
}
@Override
protected void onMessage(@NotNull Post post) {
var message = post.message();
if (message.length() < 6) return;
var command = message.substring(0, 6);
var runnable = (Runnable) () -> {
try {
if ("!marek".equals(command) || "!Marek".equals(command)) {
handle(post);
} else if ("!MAREK".equals(command) && !ANNOYED_RESPONSES.isEmpty()) {
handleAnnoyed();
} else if ("!marek".equalsIgnoreCase(command)) {
var uppercase = command.chars().filter(chr -> 'A' <= chr && chr <= 'Z').count();
ScopedValue.runWhere(ANNOYANCE, uppercase / 5d, () -> handle(post));
}
} catch (PizzaBotException ex) {
post(ex.getMessage());
}
};
if (post.userId() != null && post.userName() != null) {
ScopedValue.runWhere(USER, new User(post.userId(), post.userName()), runnable);
} else {
runnable.run();
}
}
private void handle(@NotNull Post post) {
if (!isPrimaryChannel(post.channel())) {
handleNonPrimaryChannel();
} else {
try {
dispatch(ArgumentParser.parse(post.message().substring(6)));
} catch (SQLException ex) {
log.error("Fehler beim Verarbeiten von {}.", post, ex);
}
}
}
private void handleAnnoyed() {
assert !ANNOYED_RESPONSES.isEmpty();
var idx = (int) (Math.random() * ANNOYED_RESPONSES.size());
post(ANNOYED_RESPONSES.get(idx));
}
private void handleNonPrimaryChannel() {
assert primaryChannels != null;
if (primaryChannels.size() == 1) {
post(STR."Bitte benutze den Channel \{primaryChannels.iterator().next()}.");
} else {
var channels = new StringBuilder();
var it = primaryChannels.iterator();
while (it.hasNext()) {
var channel = it.next();
if (!it.hasNext()) {
channels.append(" oder ");
} else if (!channels.isEmpty()) {
channels.append(", ");
}
channels.append(channel);
}
post(STR."Bitte benutze einen der Channel \{channels.toString()}.");
}
}
private void dispatch(@NotNull List<@NotNull String> args) throws SQLException {
if (args.isEmpty()) {
helpUser();
return;
}
var argc = args.size();
var command = args.getFirst();
switch (command) {
case "--user", "-u" -> runAs(args);
case "help" -> {
if (argc > 1 && "--all".equals(args.get(1))) {
helpAdmin();
} else {
helpUser();
}
}
case "info" -> info();
case "order" -> {
if (argc == 1) throw new PizzaBotException("too few arguments");
switch (args.get(1)) {
case "revoke" -> {
if (argc == 2) orderRevoke();
else if (argc != 3) throw new PizzaBotException("too many arguments");
else switch (args.get(2)) {
case "--all" -> orderRevokeAll();
case String s when isLong(s) -> orderRevoke(toLong(s));
case String name -> orderRevoke(name);
}
}
case "list" -> {
if (argc == 2) orderList();
// else if (argc == 3 && "--all".equals(args.get(2))) orderListAll();
else throw new PizzaBotException("invalid command");
}
case "add" -> {
if (argc < 4) throw new PizzaBotException("too few arguments");
else if (argc > 4) throw new PizzaBotException("too many arguments");
orderAdd(args.get(2), args.get(3));
}
case String name when argc == 2 -> order(name, null);
case String name -> order(name, String.join(" ", args.subList(2, args.size())));
}
}
case "summary" -> {
if (argc == 1) summary();
else if (argc == 2 && "--all".equals(args.get(1))) summaryAll();
else if (argc == 2) summary(args.get(1));
else throw new PizzaBotException("invalid command");
}
case "check" -> {
if (argc == 1) check();
else if (argc == 2 && "--all".equals(args.get(1))) checkAll();
else if (argc == 2) check(args.get(1));
else throw new PizzaBotException("invalid command");
}
case "pay" -> {
if (argc == 1) throw new PizzaBotException("missing argument");
else if (argc == 2) pay(toDouble(args.get(1)));
else if (argc == 3) pay(args.get(1), toDouble(args.get(2)));
else throw new PizzaBotException("too many arguments");
}
case "payment" -> {
if (argc == 1) throw new PizzaBotException("too few arguments");
switch (args.get(1)) {
case "confirm" -> {
if (argc == 2) throw new PizzaBotException("too few arguments");
else if (argc == 3 && "--all".equals(args.get(2))) throw new PizzaBotException("missing arguments");
else if (argc == 3) paymentConfirm(toLong(args.get(2)), null);
else if (argc == 4 && "--all".equals(args.get(2))) paymentConfirmAll(args.get(3));
else if (argc == 4) paymentConfirm(toLong(args.get(2)), args.get(3));
else throw new PizzaBotException("invalid command");
}
case "void" -> {
if (argc == 2) throw new PizzaBotException("too few arguments");
else if (argc == 3) paymentVoid(toLong(args.get(2)), null);
else if (argc == 4) paymentVoid(toLong(args.get(2)), args.get(3));
else throw new PizzaBotException("invalid command");
}
default -> throw new PizzaBotException("invalid command");
}
}
default -> throw new PizzaBotException("Unbekannter Befehl.");
}
}
private @NotNull User getUser() {
if (USER.isBound()) {
return USER.get();
} else {
throw new PizzaBotException("Diese Aktion kann nur mit öffentlicher ID ausgeführt werden.");
}
}
//<editor-fold desc="Admin" defaultstate="collapsed">
private void runAs(@NotNull List<String> args) throws SQLException {
// TODO
if (true) throw new PizzaBotException("nicht implementiert");
if (args.size() < 2) throw new PizzaBotException("missing argument");
var username = args.get(1);
// var user = service.getUserByEventAndName(, username);
//
// ScopedValue.runWhere(USER, user, () -> {
// try {
// dispatch(args.subList(2, args.size()));
// } catch (SQLException ex) {
// throw Lombok.sneakyThrow(ex);
// }
// });
//
// if (false) throw new SQLException();
}
private void orderAdd(@NotNull String targetOrder, @NotNull String newUserName) throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
var newUser = service.getUserByName(newUserName);
OrderItem item;
Pizza pizza;
List<UserWithPayment> users;
try {
var targetId = Long.parseLong(targetOrder);
var detail = service.getItemById(targetId)
.orElseThrow(() -> new PizzaBotException(STR."Die Bestellung mit Nummer \{targetId} existiert nicht."));
item = detail.item();
pizza = detail.pizza();
users = detail.users();
} catch (NumberFormatException _) {
var user = service.getUserByEventAndName(order.eventId(), targetOrder);
try {
item = service.getItemByUser(order.id(), user.id())
.orElseThrow(() -> new PizzaBotException(STR."\{user.username()} hat keine Bestellung aufgegeben."));
pizza = service.getPizzaById(item.pizzaId()).orElseThrow();
users = service.getUsersByItem(item);
} catch (NonUniqueResultException ex) {
throw new PizzaBotException(STR."\{user.username()} hat mehr als eine Bestellung aufgegeben. Bitte gibt die Positionsnummer an.", ex);
}
}
var payed = users.stream().filter(UserWithPayment::payed).map(UserWithPayment::username).toList();
if (!payed.isEmpty()) {
throw new PizzaBotException(STR."\{join(payed)} \{payed.size() == 1 ? "hat" : "haben"} schon bezahlt.");
}
if (users.stream().anyMatch(u -> u.id() == newUser.id())) {
throw new PizzaBotException(STR."\{newUser.username()} nimmt bereits an dieser Bestellung teil.");
}
service.add(item, newUser, false);
post(STR."\{newUser.username()} wurde zur Bestellung \{item.id()} (\{pizza.name()}) von \{join(users, UserWithPayment::username)} hinzugefügt.");
}
/**
* Zeigt eine Übersicht alle Bestellungen während der Veranstaltung.
*/
private void summaryAll() throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
summary0(order.eventName(), service.getItemDetailByEvent(order.eventId()));
}
/**
* Zeigt eine Übersicht der Positionen der laufenden Bestellung.
*/
private void summary() throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
summary0(order.name(), service.getItemDetailByOrder(order.id()));
}
/**
* Zeigt eine Übersicht der Positionen der Bestellung mit dem angegebenen Namen.
*/
private void summary(@NotNull String name) throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
var other = service.getOrderByEventAndName(order.eventId(), name);
summary0(other.name(), service.getItemDetailByOrder(other.id()));
}
private void summary0(@NotNull String name, @NotNull Collection<OrderItemDetail> details) {
var items = details.stream().collect(Collectors.groupingBy(
detail -> Pair.of(detail.pizza(), detail.notes()),
() -> new TreeMap<>(Comparator.comparing(Pair::first)),
Collectors.collectingAndThen(
Collectors.groupingBy(
OrderItemDetail::id,
Collectors.mapping(OrderItemDetail::userName, Collectors.joining("+"))
),
Map::values
)
));
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})");
});
}
post(String.join("\n", lines));
}
/**
* Zeigt die zusammengefasste Rechnung für alle Bestellungen während der Veranstaltung.
*/
private void checkAll() throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
var details = service.getItemDetailByEvent(order.eventId());
var tips = details.stream().collect(Collectors.groupingBy(
OrderItemDetail::order,
Collectors.collectingAndThen(Collectors.toList(), list -> calculateTip(list.getFirst().orderPayment(), list))
));
var items = details.stream().collect(Collectors.groupingBy(
OrderItemDetail::user,
Collectors.reducing(Receipt.empty(), item -> item.receipt(tips.get(item.order())), Receipt::sum)
));
check0(order.eventName(), tips.values().stream().anyMatch(tip -> tip > 0), items);
}
/**
* Zeigt die Rechnung für der laufenden Bestellung.
*/
private void check() throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
check0(order.order());
}
/**
* Zeigt die Rechnung für die Bestellung mit dem angegebenen Namen.
*/
private void check(@NotNull String name) throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
var other = service.getOrderByEventAndName(order.eventId(), name);
check0(other);
}
private static double calculateTip(@Nullable Double payment, List<@NotNull OrderItemDetail> details) {
var total = details.stream().mapToDouble(OrderItemDetail::userPrice).sum();
return payment == null ? 0 : payment / total;
}
private void check0(@NotNull Order order) throws SQLException {
var details = service.getItemDetailByOrder(order.id());
var tip = calculateTip(order.payment(), details);
var items = details.stream().collect(Collectors.groupingBy(
OrderItemDetail::user,
Collectors.reducing(Receipt.empty(), item -> item.receipt(tip), Receipt::sum)
));
check0(order.name(), order.payment() != null, items);
}
private void check0(@NotNull String name, boolean tipped, @NotNull Map<User, @NotNull Receipt> items) {
var lines = new ArrayList<String>();
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());
}
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");
}
// /**
// * Beginnt eine neue Bestellung.
// * @param restaurant die Restaurant-ID
// */
// private void orderStart(@NotNull String restaurant, @NotNull String deadline) {
// }
//
// /**
// * Beginnt eine neue Bestellung.
// * @param restaurantId die Restaurant-ID
// */
// private void orderStart(long restaurantId, @NotNull String deadline) {
// var restaurant = restaurantRepository.getById(restaurantId)
// .orElseThrow(() -> new BreakException(STR."Das Restaurant mit der ID \{restaurantId} existiert nicht."));
// var order = new Order(-1, , restaurantId, , , null);
// orderRepository.save(order);
// }
//
// /**
// * Beendet eine Bestellung.
// * @param payment der gezahlte Betrag inkl. Trinkgeld
// */
// private void orderFinish(double payment) {
// var order = service.getCurrentOrderAsAdmin();
// orderRepository.save(order.withPayment(payment));
// check(order.id());
// }
/**
* Bestätigt den Zahlungseingang für alle ausstehenden Zahlungen der laufenden Veranstaltung eines Benutzers.
*/
private void paymentConfirmAll(@NotNull String name) throws SQLException {
var order = service.getCurrentOrder().checkAdminAccess(getUser());
var user = service.getUserByEventAndName(order.eventId(), name);
var count = service.paymentByEventAndUser(order.eventId(), user.id(), true);
post(STR."\{count} ausstehenden Zahlungen für \{user.username()} wurden bestätigt.");
}
/**
* Bestätigt den Zahlungseingang für eine Bestellung.
*/
private void paymentConfirm(long itemId, @Nullable String username) throws SQLException {
payment0(itemId, username, true, (pizza, user) -> STR."Zahlungseingang für \{pizza} (\{user}) wurde bestätigt.");
}
/**
* Widerruft die Bestätigung eines Zahlungseingangs.
*/
private void paymentVoid(long itemId, @Nullable String username) throws SQLException {
payment0(itemId, username, false, (pizza, user) -> STR."Zahlungseingang für \{pizza} (\{user}) wurde bestätigt.");
}
private void payment0(long itemId, @Nullable String username, boolean payed, @NotNull BinaryOperator<String> message) throws SQLException {
var _ = service.getCurrentOrder().checkAdminAccess(getUser());
var summary = service.getItemById(itemId)
.orElseThrow(() -> new PizzaBotException(STR."Die Bestellung mit Nummer \{itemId} existiert nicht."));
if (username != null) {
var users = summary.users().stream().filter(u -> u.username().startsWith(username)).toList();
if (users.isEmpty()) {
throw new PizzaBotException("Der Benutzer existiert nicht oder hat nicht an der Bestellung teilgenommen.");
} else if (users.size() > 1) {
throw new PizzaBotException("Der Benutzer ist nicht eindeutig.");
} else {
var user = users.getFirst();
service.paymentByItemAndUser(itemId, user.id(), true);
post(message.apply(summary.pizza().name(), user.username()));
}
} else {
service.paymentByItem(summary.item().id(), payed);
post(message.apply(summary.pizza().name(), join(" + ", summary.users(), UserWithPayment::username)));
}
}
//</editor-fold>
//<editor-fold desc="User" defaultstate="collapsed">
/**
* Zeigt Informationen zur aktuell laufenden Bestellung.
*/
private void info() throws SQLException {
var order = service.getCurrentOrder();
var result = service.getRestaurantWithPizzas(order.restaurantId());
var restaurant = result.first();
var pizzas = result.second();
var lines = new ArrayList<String>();
lines.add(STR."Pizzabestellung bei \{restaurant.name()} in \{restaurant.city()}");
lines.add(STR."Bestellannahmeschluss: \{DATE_FORMATTER.format(order.deadline())}");
pizzas.stream().map(Pizza::toPrettyString).forEach(lines::add);
post(String.join("\n", lines));
}
/**
* Listet die eigenen Bestellungen auf.
*/
private void orderList() throws SQLException {
var user = getUser();
var current = service.getCurrentOrder();
var itemsByOrder = service.getItemDetailByEventAndUser(current.eventId(), user.id()).stream().collect(Collectors.groupingBy(
OrderItemDetail::order,
TreeMap::new,
Collectors.toUnmodifiableList()
));
var lines = new ArrayList<String>();
lines.add("Bestellungen von " + user.username());
if (itemsByOrder.isEmpty()) lines.add("(keine Einträge)");
itemsByOrder.forEach((order, items) -> {
lines.add("");
lines.add(order.name());
items.stream().map(OrderItemDetail::toPrettyString).forEach(lines::add);
});
post(String.join("\n", lines));
}
/**
* Storniert die einzige eigene Bestellung.
*/
private void orderRevoke() throws SQLException {
var order = service.getCurrentOrder().checkDeadline();
var user = getUser();
try {
var item = service.getItemByUser(order.id(), user.id());
if (item.isEmpty()) {
post("Du hast keine Bestellung aufgegeben.");
} else {
orderRevoke0(item.get());
}
} catch (NonUniqueResultException ex) {
post("Du hast mehr als eine Bestellung aufgegeben. Bitte gib die Positionsnummer oder den Namen der Pizza an.");
}
}
/**
* Storniert die angegebene, eigene Bestellung.
*/
private void orderRevoke(long itemId) throws SQLException {
var order = service.getCurrentOrder().checkDeadline();
var item = service.getItemById(itemId);
if (item.isEmpty() || item.get().item().orderId() != order.id()) {
post(STR."Die Position mit Nummer \{itemId} existiert nicht.");
} else {
orderRevoke0(item.get().checkUserAccess(getUser()).item());
}
}
/**
* Storniert die eigene Bestellung der angegebene Pizza.
*/
private void orderRevoke(@NotNull String name) throws SQLException {
var order = service.getCurrentOrder().checkDeadline();
var user = getUser();
try {
var item = service.getItemByPizzaName(order.id(), user.id(), name);
if (item.isEmpty()) {
post("Du hast keine solche Pizza bestellt.");
} else {
orderRevoke0(item.get());
}
} catch (NonUniqueResultException ex) {
post("Du hast mehr als eine solche Pizza bestellt. Bitte gib die Positionsnummer an.");
}
}
private void orderRevoke0(@NotNull OrderItem item) throws SQLException {
var pizza = service.getPizzaById(item.pizzaId()).orElseThrow();
service.delete(item);
post(STR."Die Position \{item.id()} (\{pizza.name()}) wurde storniert.");
}
/**
* Storniert alle eigenen Bestellungen.
*/
private void orderRevokeAll() throws SQLException {
var order = service.getCurrentOrder().checkDeadline();
var user = getUser();
var items = service.getItemsByUser(order.id(), user.id());
if (items.isEmpty()) {
post("Du hast keine Bestellung aufgegeben.");
} else {
for (var item : items) {
service.delete(item);
}
post("Alle deine Bestellungen wurden storniert.");
}
}
/**
* Bestellt die Pizza mit dem angegebenen Namen.
*/
private void order(@NotNull String nameOrNummer, @Nullable String notes) throws SQLException {
var order = service.getCurrentOrder().checkDeadline();
var pizza = service.getPizza(order.restaurantId(), nameOrNummer);
order0(order, pizza, notes);
}
private void order0(@NotNull OrderDetail order, @NotNull Pizza pizza, @Nullable String notes) throws SQLException {
var id = service.order(order, getUser(), pizza, notes);
post(STR."Pizza \{pizza.name()} wurde zur Bestellung hinzugefügt (Positionsnummer \{id}).");
}
//</editor-fold>
private void helpUser() {
post("""
Marek is back!
!marek info - Menü anzeigen
Bestellung
!marek order (NUMMER | NAME) [NOTIZEN] - Pizza bestellen
!marek order revoke [NUMMER | NAME | --all] - Bestellung(en) stornieren
!marek order list - Bestellungen auflisten
""");
}
private void helpAdmin() {
post("""
!marek --user USER order (NUMMER | NAME) [NOTIZEN] - Pizza bestellen
!marek --user USER order revoke [NUMMER | NAME | --all] - Bestellung(en) stornieren
!marek --user USER order list - Bestellungen auflisten
!marek summary [NAME | --all] - Bestellübersicht
!marek check [NAME | --all] - Rechnung anzeigen
!marek pay [NAME] AMOUNT - Rechnungsbetrag angeben
!marek order list --all - Alle Bestellungen auflisten
!marek order add (NUMMER | NAME) USER - Fügt USER zur Bestellung mit Nummer NUMMER / von NAME hinzu
!marek payment confirm NUMMER [USER] - Zahlungseingang bestätigen
!marek payment confirm --all USER - Zahlungseingang bestätigen
!marek payment void NUMMER [USER] - Widerruf der Bestätigung des Zahlungseingangs
""");
}
@Override
protected void post(@NotNull String message, @Nullable String name, @Nullable Boolean bottag, @Nullable Boolean publicId) {
var annoyance = ANNOYANCE.orElse(0d);
if (annoyance == 0) {
super.post(message, name, bottag, publicId);
} else if (annoyance == 1) {
super.post(message.toUpperCase(Locale.ROOT), name, bottag, publicId);
} else {
var infuriatedMessage = message.codePoints()
.map(chr -> Math.random() < annoyance ? Character.toUpperCase(chr) : chr)
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
.toString();
super.post(infuriatedMessage, name, bottag, publicId);
}
}
private boolean isPrimaryChannel(@NotNull String channel) {
return primaryChannels == null || primaryChannels.contains(channel);
}
}

@ -0,0 +1,7 @@
package eu.jonahbauer.chat.bot.pizza;
import lombok.experimental.StandardException;
@StandardException
public class PizzaBotException extends RuntimeException {
}

@ -0,0 +1,298 @@
package eu.jonahbauer.chat.bot.pizza;
import eu.jonahbauer.chat.bot.database.Database;
import eu.jonahbauer.chat.bot.database.NonUniqueResultException;
import eu.jonahbauer.chat.bot.pizza.model.*;
import eu.jonahbauer.chat.bot.pizza.model.table.*;
import eu.jonahbauer.chat.bot.pizza.model.view.OrderDetail;
import eu.jonahbauer.chat.bot.pizza.model.view.OrderItemDetail;
import eu.jonahbauer.chat.bot.pizza.util.Pair;
import org.intellij.lang.annotations.Language;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.sql.SQLException;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public class PizzaService {
private final @NotNull Database database;
public PizzaService(@NotNull Database database) {
this.database = Objects.requireNonNull(database);
}
public @NotNull OrderDetail getCurrentOrder() throws SQLException {
@Language("SQL")
var query = "SELECT * FROM order_detail WHERE active = TRUE ORDER BY deadline DESC LIMIT 1";
return database.executeUniqueQuery(OrderDetail.class, query)
.orElseThrow(() -> new PizzaBotException("Aktuell läuft keine Pizzabestellung."));
}
public @NotNull Pair<@NotNull Restaurant, @NotNull List<@NotNull Pizza>> getRestaurantWithPizzas(long restaurantId) throws SQLException {
return database.transaction(() -> {
var restaurant = database.findById(Restaurant.class, restaurantId).orElseThrow();
var pizzas = database.findAllWhere(Pizza.class, Pizza.Fields.restaurantId, restaurantId);
return Pair.of(restaurant, pizzas);
});
}
// Payment
public void save(@NotNull Order order) throws SQLException {
database.update(order);
}
public @NotNull User getUserByName(@NotNull String name) throws SQLException {
try {
@Language("SQL")
var query = """
SELECT "user".* FROM "user"
WHERE "user"."username" LIKE ?
""";
return database.executeUniqueQuery(User.class, query, stmt -> stmt.setString(1, name + "%"))
.orElseThrow(() -> new PizzaBotException("Der Benutzer existiert nicht."));
} catch (NonUniqueResultException _) {
throw new PizzaBotException("Der Benutzer ist nicht eindeutig.");
}
}
public @NotNull User getUserByEventAndName(long eventId, @NotNull String name) throws SQLException {
try {
@Language("SQL")
var query = """
SELECT "user".* FROM "user"
WHERE "user"."username" LIKE ?
AND EXISTS(
SELECT * FROM "order_item_to_user"
JOIN "order_item" ON "order_item_to_user"."order_item_id" = "order_item"."id"
JOIN "order" ON "order_item"."order_id" = "order"."id"
WHERE "order_item_to_user"."user_id" = "user"."id"
AND "order"."event_id" = ?
)
""";
return database.executeUniqueQuery(User.class, query, stmt -> {
stmt.setString(1, name + "%");
stmt.setLong(2, eventId);
}).orElseThrow(() -> new PizzaBotException("Der Benutzer existiert nicht oder hat an keiner Pizzabestellung teilgenommen."));
} catch (NonUniqueResultException _) {
throw new PizzaBotException("Der Benutzer ist nicht eindeutig.");
}
}
public void paymentByItem(long itemId, boolean payed) throws SQLException {
database.executeUpdate("UPDATE order_item_to_user SET payed = ? WHERE order_item_id = ?", stmt -> {
stmt.setBoolean(1, payed);
stmt.setLong(2, itemId);
});
}
public int paymentByEventAndUser(long eventId, long userId, boolean payed) throws SQLException {
@Language("SQL")
var query = """
UPDATE "order_item_to_user"
SET "payed" = ?
WHERE "order_item_to_user"."user_id" = ?
AND EXISTS(
SELECT *
FROM "order_item"
JOIN "order" ON "order_item"."order_id" = "order"."id"
WHERE "order"."event_id" = ? AND "order_item"."id" = "order_item_to_user"."order_item_id"
)
""";
return database.executeUpdate(query, stmt -> {
stmt.setBoolean(1, payed);
stmt.setLong(2, userId);
stmt.setLong(3, eventId);
});
}
public void paymentByItemAndUser(long itemId, long userId, boolean payed) throws SQLException {
database.executeUpdate("UPDATE order_item_to_user SET payed = ? WHERE order_item_id = ? AND user_id = ?", stmt -> {
stmt.setBoolean(1, payed);
stmt.setLong(2, itemId);
stmt.setLong(3, userId);
});
}
// Order
public @NotNull Optional<Pizza> getPizzaById(long id) throws SQLException {
return database.findById(Pizza.class, id);
}
public @NotNull Pizza getPizza(long restaurantId, @NotNull String nameOrNummer) throws SQLException {
try {
var nummer = Integer.parseInt(nameOrNummer);
return getPizzaByNummer(restaurantId, nummer);
} catch (NumberFormatException _) {
return getPizzaByName(restaurantId, nameOrNummer);
}
}
private @NotNull Pizza getPizzaByNummer(long restaurantId, int nummer) throws SQLException {
@Language("SQL")
var query = "SELECT * FROM pizza WHERE restaurant_id = ? AND number = ?";
return database.executeUniqueQuery(Pizza.class, query, stmt -> {
stmt.setLong(1, restaurantId);
stmt.setInt(2, nummer);
}).orElseThrow(() -> new PizzaBotException("Diese Pizza kenne ich nicht."));
}
private @NotNull Pizza getPizzaByName(long restaurantId, @NotNull String name) throws SQLException {
try {
@Language("SQL")
var query = "SELECT * FROM pizza WHERE restaurant_id = ? AND name LIKE ?";
return database.executeUniqueQuery(Pizza.class, query, stmt -> {
stmt.setLong(1, restaurantId);
stmt.setString(2, name + "%");
}).orElseThrow(() -> new PizzaBotException("Diese Pizza kenne ich nicht."));
} catch (NonUniqueResultException _) {
throw new PizzaBotException("Diese Pizza kenne ich nicht.");
}
}
public long order(@NotNull OrderDetail order, @NotNull User user, @NotNull Pizza pizza, @Nullable String notes) throws SQLException {
return database.transaction(() -> {
try {
database.insert(user);
} catch (SQLException _) {}
var item = new OrderItem(-1, order.id(), pizza.id(), notes);
var itemId = database.insert(item);
var mapping = new OrderItemToUser(itemId, user.id(), false);
database.insert(mapping);
return itemId;
});
}
public void add(@NotNull OrderItem item, @NotNull User user, boolean payed) throws SQLException {
database.insert(new OrderItemToUser(item.id(), user.id(), payed));
}
public @NotNull List<@NotNull UserWithPayment> getUsersByItem(@NotNull OrderItem item) throws SQLException {
@Language("SQL")
var query = """
SELECT
"user"."id" AS "id",
"user"."username" AS "username",
"order_item_to_user"."order_item_id" AS "order_item_id",
"order_item_to_user"."payed" AS "payed"
FROM "user"
JOIN "order_item_to_user" ON "user"."id" = "order_item_to_user"."user_id"
WHERE "order_item_to_user"."order_item_id" = ?
""";
return database.executeQuery(UserWithPayment.class, query, stmt -> stmt.setLong(1, item.id()));
}
// Revoke
public @NotNull Optional<OrderItemSummary> getItemById(long itemId) throws SQLException {
return database.transaction(() -> {
var item = database.findById(OrderItem.class, itemId);
if (item.isEmpty()) return Optional.empty();
var pizza = database.findById(Pizza.class, item.get().pizzaId()).orElseThrow();
var users = getUsersByItem(item.get());
return Optional.of(new OrderItemSummary(item.get(), pizza, users));
});
}
public @NotNull Optional<OrderItem> getItemByPizzaName(long orderId, long userId, @NotNull String name) throws SQLException {
@Language("SQL")
var query = """
SELECT "order_item".*
FROM "order_item"
JOIN "pizza" ON "order_item"."pizza_id" = "pizza"."id"
JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id"
WHERE "order_item"."order_id" = ? AND "order_item_to_user"."user_id" = ? AND "pizza"."name" LIKE ?
""";
return database.executeUniqueQuery(OrderItem.class, query, stmt -> {
stmt.setLong(1, orderId);
stmt.setLong(2, userId);
stmt.setString(3, name + "%");
});
}
public @NotNull Optional<OrderItem> getItemByUser(long orderId, long userId) throws SQLException {
@Language("SQL")
var query = """
SELECT "order_item".*
FROM "order_item"
JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id"
WHERE "order_item"."order_id" = ? AND "order_item_to_user"."user_id" = ?
""";
return database.executeUniqueQuery(OrderItem.class, query, stmt -> {
stmt.setLong(1, orderId);
stmt.setLong(2, userId);
});
}
public @NotNull List<@NotNull OrderItem> getItemsByUser(long orderId, long userId) throws SQLException {
@Language("SQL")
var query = """
SELECT "order_item".*
FROM "order_item"
JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id"
WHERE "order_item"."order_id" = ? AND "order_item_to_user"."user_id" = ?
""";
return database.executeQuery(OrderItem.class, query, stmt -> {
stmt.setLong(1, orderId);
stmt.setLong(2, userId);
});
}
public boolean delete(@NotNull OrderItem item) throws SQLException {
return database.delete(OrderItem.class, item.id());
}
// Summary
public @NotNull Order getOrderByEventAndName(long eventId, @NotNull String name) throws SQLException {
try {
@Language("SQL")
var query = """
SELECT "order".*
FROM "order"
WHERE "order"."event_id" = ? AND "order"."name" LIKE ?
""";
return database.executeUniqueQuery(Order.class, query, stmt -> {
stmt.setLong(1, eventId);
stmt.setString(2, name + "%");
}).orElseThrow(() -> new PizzaBotException("Diese Bestellung kenne ich nicht."));
} catch (NonUniqueResultException ex) {
throw new PizzaBotException("Die Bestellung ist nicht eindeutig.");
}
}
// Order List
public @NotNull List<@NotNull OrderItemDetail> getItemDetailByEvent(long eventId) throws SQLException {
return database.findAllWhere(OrderItemDetail.class, OrderItemDetail.Fields.eventId, eventId);
}
public @NotNull List<@NotNull OrderItemDetail> getItemDetailByOrder(long orderId) throws SQLException {
return database.findAllWhere(OrderItemDetail.class, OrderItemDetail.Fields.orderId, orderId);
}
public @NotNull List<@NotNull OrderItemDetail> getItemDetailByEventAndUser(long eventId, long userId) throws SQLException {
@Language("SQL")
var query = """
SELECT *
FROM order_item_detail
WHERE event_id = ? AND EXISTS(
SELECT * FROM "order_item_to_user"
WHERE "user_id" = ? AND "order_item_id" = "order_item_detail"."id"
)
""";
return database.executeQuery(OrderItemDetail.class, query, stmt -> {
stmt.setLong(1, eventId);
stmt.setLong(2, userId);
});
}
}

@ -0,0 +1,18 @@
package eu.jonahbauer.chat.bot.pizza.model;
import eu.jonahbauer.chat.bot.pizza.PizzaBotException;
import eu.jonahbauer.chat.bot.pizza.model.table.OrderItem;
import eu.jonahbauer.chat.bot.pizza.model.table.Pizza;
import eu.jonahbauer.chat.bot.pizza.model.table.User;
import org.jetbrains.annotations.NotNull;
import java.util.List;
public record OrderItemSummary(@NotNull OrderItem item, @NotNull Pizza pizza, @NotNull List<UserWithPayment> users) {
public @NotNull OrderItemSummary checkUserAccess(@NotNull User user) {
if (users.stream().noneMatch(u -> u.id() == user.id())) {
throw new PizzaBotException("Du bist nicht berechtigt, diese Bestellung zu bearbeiten.");
}
return this;
}
}

@ -0,0 +1,14 @@
package eu.jonahbauer.chat.bot.pizza.model;
import lombok.experimental.FieldNameConstants;
@FieldNameConstants
public record OrderItemToUser(long orderItemId, long userId, long parts, boolean payed) {
public OrderItemToUser(long orderItemId, long userId, boolean payed) {
this(orderItemId, userId, 1, payed);
}
public OrderItemToUser withPayed(boolean payed) {
return new OrderItemToUser(orderItemId, userId, parts, payed);
}
}

@ -0,0 +1,40 @@
package eu.jonahbauer.chat.bot.pizza.model;
import org.jetbrains.annotations.NotNull;
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.tabular;
public record Receipt(double total, double tipped, double pending, double pendingTipped) {
public Receipt(double total, boolean payed, double tip) {
this(total, total * tip, payed ? 0 : total, payed ? 0 : total * tip);
}
public boolean payed() {
return pending == 0;
}
public boolean unpayed() {
return total == pending;
}
public @NotNull String totalAsString() {
return tabular(total, 0) + " €" + (tipped != 0 ? " (" + tabular(tipped, 0) + " €)" : "");
}
public @NotNull String pendingAsString() {
return tabular(pending, 0) + " €" + (pendingTipped != 0 ? " (" + tabular(pendingTipped, 0) + " €)" : "");
}
public static @NotNull Receipt empty() {
return new Receipt(0, 0, 0, 0);
}
public static @NotNull Receipt sum(@NotNull Receipt first, @NotNull Receipt second) {
return new Receipt(
first.total + second.total,
first.tipped + second.tipped,
first.pending + second.pending,
first.pendingTipped + second.pendingTipped
);
}
}

@ -0,0 +1,7 @@
package eu.jonahbauer.chat.bot.pizza.model;
import org.jetbrains.annotations.NotNull;
public record UserWithPayment(long id, @NotNull String username, long orderItemId, boolean payed) {
}

@ -0,0 +1,9 @@
package eu.jonahbauer.chat.bot.pizza.model.table;
import eu.jonahbauer.chat.bot.database.Entity;
import lombok.experimental.FieldNameConstants;
import org.jetbrains.annotations.NotNull;
@FieldNameConstants
public record Event(long id, @NotNull String name, long adminId, boolean active) implements Entity<Event> {
}

@ -0,0 +1,33 @@
package eu.jonahbauer.chat.bot.pizza.model.table;
import eu.jonahbauer.chat.bot.database.Entity;
import lombok.experimental.FieldNameConstants;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.util.Objects;
@FieldNameConstants
public record Order(
long id,
long eventId,
long restaurantId,
@NotNull String name,
@NotNull Instant deadline,
@Nullable Double payment
) implements Entity<Order>, Comparable<Order> {
public Order {
Objects.requireNonNull(deadline);
}
public @NotNull Order withPayment(@Nullable Double payment) {
return new Order(id, eventId, restaurantId, name, deadline, payment);
}
@Override
public int compareTo(@NotNull Order o) {
return this.deadline.compareTo(o.deadline);
}
}

@ -0,0 +1,14 @@
package eu.jonahbauer.chat.bot.pizza.model.table;
import eu.jonahbauer.chat.bot.database.Entity;
import lombok.experimental.FieldNameConstants;
import org.jetbrains.annotations.Nullable;
@FieldNameConstants
public record OrderItem(
long id,
long orderId,
long pizzaId,
@Nullable String notes
) implements Entity<OrderItem> {
}

@ -0,0 +1,33 @@
package eu.jonahbauer.chat.bot.pizza.model.table;
import eu.jonahbauer.chat.bot.database.Entity;
import lombok.experimental.FieldNameConstants;
import org.jetbrains.annotations.NotNull;
import java.util.Locale;
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.tabular;
@FieldNameConstants
public record Pizza(
long id,
long restaurantId,
int number,
@NotNull String name,
@NotNull String ingredients,
double price
) implements Entity<Pizza>, Comparable<Pizza> {
public @NotNull String toPrettyString() {
var number = tabular(number());
var price = tabular(price(), 5);
var name = name().toUpperCase(Locale.ROOT);
var ingredients = ingredients();
return STR."\{number} | \{price} € | \{name} \{ingredients}";
}
@Override
public int compareTo(@NotNull Pizza o) {
return Integer.compare(number, o.number);
}
}

@ -0,0 +1,20 @@
package eu.jonahbauer.chat.bot.pizza.model.table;
import eu.jonahbauer.chat.bot.database.Entity;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
public record Restaurant(
long id,
@NotNull String name,
@NotNull String city,
@Nullable String phone,
@Nullable String notes
) implements Entity<Restaurant> {
public Restaurant {
Objects.requireNonNull(name);
Objects.requireNonNull(city);
}
}

@ -0,0 +1,13 @@
package eu.jonahbauer.chat.bot.pizza.model.table;
import lombok.experimental.FieldNameConstants;
import org.jetbrains.annotations.NotNull;
import java.util.Objects;
@FieldNameConstants
public record User(long id, @NotNull String username) {
public User {
Objects.requireNonNull(username);
}
}

@ -0,0 +1,42 @@
package eu.jonahbauer.chat.bot.pizza.model.view;
import eu.jonahbauer.chat.bot.pizza.PizzaBotException;
import eu.jonahbauer.chat.bot.pizza.model.table.Order;
import eu.jonahbauer.chat.bot.pizza.model.table.User;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
public record OrderDetail(
// Order
long id,
@NotNull String name,
@NotNull Instant deadline,
@Nullable Double payment,
// Event
long eventId,
@NotNull String eventName,
long adminId,
// Restaurant
long restaurantId,
@NotNull String restaurantName
) {
public @NotNull Order order() {
return new Order(id, eventId, restaurantId, name, deadline, payment);
}
public @NotNull OrderDetail checkDeadline() {
if (Instant.now().isAfter(deadline())) {
throw new PizzaBotException("Du kannst diese Bestellung nicht mehr bearbeiten, da der Bestellannahmeschluss schon vorbei ist.");
}
return this;
}
public @NotNull OrderDetail checkAdminAccess(@NotNull User user) {
if (adminId() != user.id()) {
throw new PizzaBotException("Du bist nicht berechtigt, diese Aktion durchzuführen.");
}
return this;
}
}

@ -0,0 +1,79 @@
package eu.jonahbauer.chat.bot.pizza.model.view;
import eu.jonahbauer.chat.bot.pizza.model.Receipt;
import eu.jonahbauer.chat.bot.pizza.model.table.Order;
import eu.jonahbauer.chat.bot.pizza.model.table.Pizza;
import eu.jonahbauer.chat.bot.pizza.model.table.User;
import lombok.experimental.FieldNameConstants;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.util.Locale;
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.strikethrough;
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.tabular;
@FieldNameConstants
public record OrderItemDetail(
// OrderItem
long id,
@Nullable String notes,
// Order
long orderId,
@NotNull String orderName,
@NotNull Instant orderDeadline,
@Nullable Double orderPayment,
// Event
long eventId,
// Restaurant
long restaurantId,
@NotNull String restaurantName,
// Pizza
long pizzaId,
int pizzaNumber,
@NotNull String pizzaName,
double pizzaPrice,
// User
long userId,
@NotNull String userName,
long userParts,
boolean userPayed,
long userCount
) {
public @NotNull Order order() {
return new Order(orderId, eventId, restaurantId, orderName, orderDeadline, orderPayment);
}
public @NotNull Pizza pizza() {
return new Pizza(pizzaId, restaurantId, pizzaNumber, pizzaName, "N/A", pizzaPrice);
}
public @NotNull User user() {
return new User(userId, userName);
}
public @NotNull Receipt receipt(double tip) {
return new Receipt(userPrice(), userPayed(), tip);
}
public double userPrice() {
return userParts() * pizzaPrice() / userCount();
}
public @NotNull String toPrettyString() {
String price;
if (userCount() == 1) {
price = tabular(pizzaPrice(), 0) + " €";
} else {
price = tabular(pizzaPrice(), 0) + " € / " + tabular(userCount(), 0) + " = " + tabular(userPrice(), 0) + " €";
}
if (userPayed()) price = strikethrough(price);
if (notes() != null) {
return STR."\{id()} | \{pizzaName().toUpperCase(Locale.ROOT)} \{notes()} | \{price}";
} else {
return STR."\{id()} | \{pizzaName().toUpperCase(Locale.ROOT)} | \{price}";
}
}
}

@ -0,0 +1,90 @@
package eu.jonahbauer.chat.bot.pizza.util;
import eu.jonahbauer.chat.bot.pizza.PizzaBotException;
import lombok.experimental.UtilityClass;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@UtilityClass
public class ArgumentParser {
public static @NotNull List<@NotNull String> parse(@NotNull String string) {
var list = new ArrayList<String>();
var quote = (char) 0;
var escaped = false;
var current = new StringBuilder();
var it = string.chars().iterator();
while (it.hasNext()) {
var chr = (char) it.nextInt();
if (escaped) {
if (chr != '\n') current.append(chr);
} else if (chr == '\\' && quote != '\'') {
escaped = true;
} else if (quote == 0 && chr == '"') {
quote = '"';
} else if (quote == 0 && chr == '\'') {
quote = '\'';
} else if (quote == chr) {
quote = 0;
} else if (chr == ' ' || chr == '\t') {
if (!current.isEmpty()) {
list.add(current.toString());
current.setLength(0);
}
} else if (chr == '\n') {
break;
} else {
current.append(chr);
}
}
if (escaped) {
throw new IllegalArgumentException("incomplete escape sequence");
} else if (quote != 0) {
throw new IllegalArgumentException("incomplete quote");
}
if (!current.isEmpty()) {
list.add(current.toString());
}
return Collections.unmodifiableList(list);
}
public static boolean isLong(@NotNull String string) {
try {
Long.parseLong(string);
return true;
} catch (NumberFormatException _) {
return false;
}
}
public static long toLong(@NotNull String string) {
try {
return Long.parseLong(string);
} catch (NumberFormatException ex) {
throw new PizzaBotException(string + " ist keine gültige Zahl.");
}
}
public static boolean isDouble(@NotNull String string) {
try {
Double.parseDouble(string);
return true;
} catch (NumberFormatException _) {
return false;
}
}
public static double toDouble(@NotNull String string) {
try {
return Double.parseDouble(string);
} catch (NumberFormatException ex) {
throw new PizzaBotException(string + " ist keine gültige Zahl.");
}
}
}

@ -0,0 +1,81 @@
package eu.jonahbauer.chat.bot.pizza.util;
import lombok.experimental.UtilityClass;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.TestOnly;
import java.util.List;
import java.util.Locale;
import java.util.function.Function;
import java.util.stream.Collectors;
@UtilityClass
public class FormatUtils {
public static @NotNull String tabular(long number) {
return tabular(number, 2);
}
public static @NotNull String tabular(long number, int width) {
return tabular(String.format(Locale.ROOT, "%" + (width == 0 ? "" : width) + "d", number));
}
public static @NotNull String tabular(double number) {
return tabular(number, 6);
}
public static @NotNull String tabular(double number, int width) {
return tabular(String.format(Locale.ROOT, "%" + (width == 0 ? "" : width) + ".2f", number));
}
private static @NotNull String tabular(@NotNull String string) {
var out = new StringBuilder();
for (int i = 0, length = string.length(); i < length; i++) {
var chr = string.charAt(i);
if (chr == ' ') {
out.append('\u2007'); // figure space
} else {
out.append('\u2060'); // word-joiner to prevent kerning
out.append(chr);
}
}
return out.toString();
}
@SuppressWarnings("UnnecessaryUnicodeEscape")
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');
}
return out.toString();
}
@TestOnly
@SuppressWarnings("UnnecessaryUnicodeEscape")
public static @NotNull String strip(@NotNull String string) {
var out = new StringBuilder();
for (int i = 0, length = string.length(); i < length; i++) {
var chr = string.charAt(i);
if (chr != '\u0336' && chr != '\u0335' && chr != '\u2007' && chr != '\u2060') {
out.append(chr);
}
}
return out.toString();
}
public static @NotNull String join(@NotNull List<@NotNull String> strings) {
return switch (strings.size()) {
case 0 -> "";
case 1 -> strings.getFirst();
default -> String.join(", ", strings.subList(0, strings.size() - 1)) + " und " + strings.getLast();
};
}
public static <T> @NotNull String join(@NotNull List<@NotNull T> items, @NotNull Function<? super T, String> toString) {
return join(items.stream().map(toString).toList());
}
public static <T> @NotNull String join(@NotNull String separator, @NotNull List<@NotNull T> items, @NotNull Function<? super T, String> toString) {
return items.stream().map(toString).collect(Collectors.joining(separator));
}
}

@ -0,0 +1,9 @@
package eu.jonahbauer.chat.bot.pizza.util;
import org.jetbrains.annotations.NotNull;
public record Pair<F, S>(F first, S second) {
public static <F, S> @NotNull Pair<F, S> of(F first, S second) {
return new Pair<>(first, second);
}
}

@ -0,0 +1,16 @@
import eu.jonahbauer.chat.bot.api.ChatBot;
import eu.jonahbauer.chat.bot.pizza.PizzaBot;
module eu.jonahbauer.chat.bot.pizza {
exports eu.jonahbauer.chat.bot.pizza.model.table to eu.jonahbauer.chat.bot.database;
exports eu.jonahbauer.chat.bot.pizza.model.view to eu.jonahbauer.chat.bot.database;
exports eu.jonahbauer.chat.bot.pizza.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 PizzaBot;
}

@ -0,0 +1,115 @@
CREATE TABLE "user"
(
"id" INTEGER PRIMARY KEY,
"username" VARCHAR(255) NOT NULL,
CONSTRAINT "uq_username" UNIQUE ("username")
);
CREATE TABLE "restaurant"
(
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"name" VARCHAR(255) NOT NULL,
"city" VARCHAR(255) NOT NULL,
"phone" VARCHAR(255) NULL DEFAULT NULL,
"notes" VARCHAR(255) NULL DEFAULT NULL
);
CREATE TABLE "pizza"
(
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"restaurant_id" INTEGER NOT NULL,
"number" INTEGER NOT NULL,
"name" VARCHAR(255) NOT NULL,
"ingredients" VARCHAR(255) NOT NULL,
"price" VARCHAR(255) NOT NULL,
CONSTRAINT "fk_pizza_restaurant" FOREIGN KEY ("restaurant_id") REFERENCES "restaurant" ("id") ON DELETE CASCADE,
CONSTRAINT "uq_pizza_number" UNIQUE ("restaurant_id", "number")
);
CREATE TABLE "event"
(
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"name" VARCHAR(255) NOT NULL,
"admin_id" INTEGER NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT "fk_event_admin" FOREIGN KEY ("admin_id") REFERENCES "user" ("id")
);
CREATE UNIQUE INDEX "uq_event_active" ON "event" ("active")
WHERE "active" = TRUE;
CREATE TABLE "order"
(
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"event_id" INTEGER NOT NULL,
"restaurant_id" INTEGER NOT NULL,
"name" VARCHAR(255) NOT NULL,
"deadline" TIMESTAMP NOT NULL,
"payment" DOUBLE NULL,
CONSTRAINT "fk_order_event" FOREIGN KEY ("event_id") REFERENCES "event" ("id"),
CONSTRAINT "fk_order_restaurant" FOREIGN KEY ("restaurant_id") REFERENCES "restaurant" ("id")
);
CREATE TABLE "order_item"
(
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"order_id" INTEGER NOT NULL,
"pizza_id" INTEGER NOT NULL,
"notes" VARCHAR(255),
CONSTRAINT "fk_order_item_order" FOREIGN KEY ("order_id") REFERENCES "order" ("id") ON DELETE CASCADE,
CONSTRAINT "fk_order_item_pizza" FOREIGN KEY ("pizza_id") REFERENCES "pizza" ("id")
);
CREATE TABLE "order_item_to_user"
(
"order_item_id" INTEGER NOT NULL,
"user_id" INTEGER NOT NULL,
"parts" INTEGER NOT NULL DEFAULT 1,
"payed" BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY ("order_item_id", "user_id"),
CONSTRAINT "fk_order_item_to_user_order_item" FOREIGN KEY ("order_item_id") REFERENCES "order_item" ("id") ON DELETE CASCADE,
CONSTRAINT "fk_order_item_to_user_user_id" FOREIGN KEY ("user_id") REFERENCES "user" ("id")
);
CREATE VIEW "order_item_detail"
AS
SELECT "order_item"."id",
"order_item"."notes",
"order"."id" AS "order_id",
"order"."name" AS "order_name",
"order"."deadline" AS "order_deadline",
"order"."payment" AS "order_payment",
"order"."event_id" AS "event_id",
"restaurant"."id" AS "restaurant_id",
"restaurant"."name" AS "restaurant_name",
"pizza"."id" AS "pizza_id",
"pizza"."number" AS "pizza_number",
"pizza"."name" AS "pizza_name",
"pizza"."price" AS "pizza_price",
"order_item_to_user"."user_id" AS "user_id",
"order_item_to_user"."payed" AS "user_payed",
"order_item_to_user"."parts" AS "user_parts",
"user"."username" AS "user_name",
(SELECT SUM("parts") FROM "order_item_to_user" WHERE "order_item_id" = "order_item"."id") AS "user_count"
FROM "order"
JOIN "restaurant" ON "order"."restaurant_id" = "restaurant"."id"
JOIN "order_item" ON "order"."id" = "order_item"."order_id"
JOIN "pizza" ON "order_item"."pizza_id" = "pizza"."id"
JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id"
JOIN "user" ON "order_item_to_user"."user_id" = "user"."id";
CREATE VIEW "order_detail"
AS
SELECT "order"."id",
"order"."name",
"order"."deadline",
"order"."payment",
"event"."id" AS "event_id",
"event"."name" AS "event_name",
"event"."admin_id",
"event"."active",
"restaurant"."id" AS "restaurant_id",
"restaurant"."name" AS "restaurant_name"
FROM "event"
JOIN "order" ON "event"."id" = "order"."event_id"
JOIN "restaurant" ON "order"."restaurant_id" = "restaurant"."id";

@ -0,0 +1,38 @@
INSERT INTO "restaurant"("name", "city", "phone", "notes")
VALUES ('Hell''s Pizza', 'Sonthofen', '08321 7881930', '2023');
WITH temp(id) AS (SELECT last_insert_rowid())
INSERT INTO "pizza"("restaurant_id", "number", "name", "ingredients", "price")
SELECT * FROM temp CROSS JOIN (
VALUES (10, 'Pizzabrot', 'mit Tomaten & Knoblauch', 6.50),
(11, 'Margherita', 'mit Tomaten, Mozzarella & Basilikum', 9.50),
(12, 'Salami', 'mit Tomaten, Mozzarella & Salami (gegen Aufpreis mit Rindersalami)', 10.00),
(13, 'Prosciutto', 'mit Tomaten, Mozzarella & Schinken', 10.00),
(14, 'Salsiccia', 'mit Tomaten, Mozzarella & scharfer Salami', 9.50),
(15, 'Funghi', 'mit Tomaten, Mozzarella & Champignons', 9.50),
(16, 'Romana', 'mit Tomaten, Mozzarella, Salami & Champignons', 10.50),
(17, 'Hawaii', 'mit Tomaten, Mozzarella, Schinken & Ananas', 10.50),
(18, 'Regina', 'mit Tomaten, Mozzarella, Schinken & Champignons', 10.50),
(19, 'Toscana', 'mit Tomaten, Mozzarella, Salami, Champignons & Peperoni', 11.00),
(20, 'Tonno', 'mit Tomaten, Mozzarella, Thunfisch, Zwiebeln & Oliven', 11.00),
(21, 'Quattro Formaggi', 'mit Tomaten & vier Käsesorten', 10.50),
(22, 'Calzone', 'mit Tomaten, Mozzarella, Schinken & Champignons', 10.50),
(24, 'Diavolo', 'mit Tomaten, Mozzarella, scharfer Salami, Peperoni & Oliven', 11.00),
(25, 'Rucoletta', 'mit Tomaten, Mozzarella, Rucola, Tomatenstücken & Parmesan', 11.00),
(26, 'Quattro Stagioni', 'mit Tomaten, Mozzarella, Schinken, Salami, Champignons, Artischocken, Sardellen & Oliven', 11.00),
(27, 'Vegana', 'mit Tomaten & verschiedenem Gemüse', 10.50),
(28, 'Siciliana', 'mit Tomaten, Mozzarella, Peperoni, Kapern, Sardellen, Oliven & Knoblauch', 10.00),
(29, 'Rustica', 'mit Tomaten, Mozzarella, Speck, Champignons, Gorgonzola & Knoblauch', 11.00),
(30, 'Caprese', 'mit Tomaten, Mozzarella, Tomatenscheiben & Basilikum', 11.00),
(31, 'Calabria', 'mit Tomaten, Mozzarella, scharfer Salami, Zwiebeln & original Schafskäse', 12.00),
(32, 'Verdura', 'mit Tomaten, Mozzarella, gegrilltem Gemüse & Knoblauch', 12.00),
(33, 'Mare', 'mit Tomaten, Meeresfrüchten & Knoblauch', 13.00),
(35, 'Mafiosa', 'mit Tomaten, Mozzarella, Speck, scharfer Salami, Champignons, Spinat & Peperoni', 12.00),
(36, 'Reale', 'mit Tomaten, Mozzarella, original Parmaschinken, Rucola & Parmesan', 13.90),
(38, 'Mediterranea', 'mit Tomatenscheiben, Mozzarella, Thunfisch, Kapern & Rucola', 12.00),
(39, 'Tricolore', 'mit Büffel-Mozzarella, Tomatenscheiben & Rucola-Pesto', 13.00),
(40, 'Alfio Spezial', 'mit Mozzarella, Tomatenstücken, Salsiccia, Zwiebeln, Parmesan, Oliven & Basilikum', 12.00),
(41, 'Allgäuer', 'mit Tomaten, Mozzarella, Speck, Bergkäse, Röstzwiebeln & Knoblauch', 11.50),
(42, 'Buon Gustaio', 'mit Tomaten, Mozzarella, Spinat, Gorgonzola & Knoblauch', 11.00),
(44, 'Mista', 'mit Tomaten, Mozzarella, Schinken, Salami, Pilzen & Thunfisch', 11.00)
);

@ -0,0 +1,46 @@
-- Hell's Pizza Sonthofen
-- Stand: Oktober 2023
INSERT INTO "restaurant"("name", "city", "phone", "notes")
VALUES ('Hell''s Pizza', 'Sonthofen', '08321 7881930', '2024');
WITH temp(id) AS (SELECT last_insert_rowid())
INSERT INTO "pizza"("restaurant_id", "number", "name", "ingredients", "price")
SELECT * FROM temp CROSS JOIN (
VALUES (10, 'Pizzabrot', 'mit Tomaten & Knoblauch', 7.00),
(11, 'Margherita', 'mit Tomaten, Mozzarella & Basilikum', 10.00),
(12, 'Salami', 'mit Tomaten, Mozzarella & Salami (gegen Aufpreis mit Rindersalami)', 10.50),
(13, 'Prosciutto', 'mit Tomaten, Mozzarella & Schinken', 10.50),
(14, 'Salsiccia', 'mit Tomaten, Mozzarella & scharfer Salami', 10.50),
(15, 'Funghi', 'mit Tomaten, Mozzarella & Champignons', 10.00),
(16, 'Romana', 'mit Tomaten, Mozzarella, Salami & Champignons', 11.00),
(17, 'Hawaii', 'mit Tomaten, Mozzarella, Schinken & Ananas', 11.00),
(18, 'Regina', 'mit Tomaten, Mozzarella, Schinken & Champignons', 11.00),
(19, 'Toscana', 'mit Tomaten, Mozzarella, Salami, Champignons & Peperoni', 11.50),
(20, 'Tonno', 'mit Tomaten, Mozzarella, Thunfisch, Zwiebeln & Oliven', 11.50),
(21, 'Quattro Formaggi', 'mit Tomaten & vier Käsesorten', 11.00),
(22, 'Calzone', 'mit Tomaten, Mozzarella, Schinken & Champignons', 11.00),
(23, 'Capriccio', 'mit Tomaten, Mozzarella, Schinken, Champignons, Artischocken, Eier', 13.00),
(24, 'Diavolo', 'mit Tomaten, Mozzarella, scharfer Salami, Peperoni & Oliven', 11.50),
(25, 'Rucoletta', 'mit Tomaten, Mozzarella, Rucola, Tomatenstücken & Parmesan', 12.00),
(26, 'Quattro Stagioni', 'mit Tomaten, Mozzarella, Schinken, Salami, Champignons, Artischocken, Sardellen & Oliven', 12.90),
(27, 'Vegana', 'mit Tomaten & verschiedenem Gemüse', 11.00),
(28, 'Siciliana', 'mit Tomaten, Mozzarella, Peperoni, Kapern, Sardellen, Oliven & Knoblauch', 10.50),
(29, 'Rustica', 'mit Tomaten, Mozzarella, Speck, Champignons, Gorgonzola & Knoblauch', 11.50),
(30, 'Caprese', 'mit Tomaten, Mozzarella, Tomatenscheiben & Basilikum', 11.50),
(31, 'Calabria', 'mit Tomaten, Mozzarella, scharfer Salami, Zwiebeln & original Schafskäse', 12.00),
(32, 'Verdura', 'mit Tomaten, Mozzarella, gegrilltem Gemüse & Knoblauch', 12.00),
(33, 'Mare', 'mit Tomaten, Meeresfrüchten & Knoblauch', 13.50),
(34, 'Contadina', 'mit Büffelmozzarella, Parmaschinken Julienne, Champignons & Rucola', 13.90),
(35, 'Mafiosa', 'mit Tomaten, Mozzarella, Speck, scharfer Salami, Champignons, Spinat & Peperoni', 12.90),
(36, 'Reale', 'mit Tomaten, Mozzarella, original Parmaschinken, Rucola & Parmesan', 14.90),
(38, 'Mediterranea', 'mit Tomatenscheiben, Mozzarella, Thunfisch, Kapern & Rucola', 12.90),
(39, 'Tricolore', 'mit Büffel-Mozzarella, Tomatenscheiben & Rucola-Pesto', 13.90),
(40, 'Alfio Spezial', 'mit Mozzarella, Tomatenstücken, Salsiccia, Zwiebeln, Parmesan, Oliven & Basilikum', 12.90),
(41, 'Allgäuer', 'mit Tomaten, Mozzarella, Speck, Bergkäse, Röstzwiebeln & Knoblauch DAS ORIGINAL', 12.90),
(42, 'Buon Gustaio', 'mit Tomaten, Mozzarella, Spinat, Gorgonzola & Knoblauch', 11.90),
(44, 'Mista', 'mit Tomaten, Mozzarella, Schinken, Salami, Pilzen & Thunfisch', 11.90),
(48, 'Cudduruni', 'Tomatenstücken, Zwiebeln, Parmesan, Oliven, Basilikum, Chili-Olivenöl NEU', 13.90),
(49, 'Bufalina', 'mit Tomaten, Büffelmozzarella, frischer Basilikum und Olivenöl NEU', 14.90)
);

@ -0,0 +1,629 @@
package eu.jonahbauer.chat.bot.pizza;
import eu.jonahbauer.chat.bot.api.Message.Post;
import eu.jonahbauer.chat.bot.pizza.model.*;
import eu.jonahbauer.chat.bot.pizza.model.table.*;
import eu.jonahbauer.chat.bot.test.MockChat;
import eu.jonahbauer.chat.bot.database.Database;
import eu.jonahbauer.chat.bot.test.ChatBotFactoryAccess;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.*;
import static java.util.FormatProcessor.FMT;
import static org.junit.jupiter.api.Assertions.*;
class PizzaBotTest {
@TempDir
static Path temp;
Database database;
MockChat chat;
PizzaBot bot;
User admin;
User user;
User user2;
Event event;
Order order0; // alte bestellung (bestellannahmeschluss vorbei)
Order order1; // alte bestellung
Order order; // laufende bestellung
Pizza salami;
Pizza margherita;
@BeforeEach
void init() throws IOException, SQLException {
var file = Files.createTempFile(temp, "database", ".db");
database = new Database("jdbc:sqlite:" + file.toAbsolutePath());
run("init.sql");
run("restaurants/sonthofen_2023.sql");
chat = new MockChat();
bot = ChatBotFactoryAccess.create(() -> new PizzaBot(Collections.singleton("marek"), database));
admin = database.save(new User(0, "Admin"));
user = database.save(new User(1337, "User1"));
user2 = database.save(new User(42, "User2"));
event = database.save(new Event(0, "Veranstaltung", admin.id(), true));
order0 = database.save(new Order(0, event.id(), 1, "28.03.2024", Instant.ofEpochMilli(System.currentTimeMillis() - 3_600_000), 90.0));
order1 = database.save(new Order(0, event.id(), 1, "29.03.2024", Instant.ofEpochMilli(System.currentTimeMillis() + 3_600_000), null));
order = database.save(new Order(0, event.id(), 1, "30.03.2024", Instant.ofEpochMilli(System.currentTimeMillis() + 7_200_000), null));
salami = database.findWhere(Pizza.class, Pizza.Fields.name, "Salami").orElseThrow();
margherita = database.findWhere(Pizza.class, Pizza.Fields.name, "Margherita").orElseThrow();
}
@AfterEach
void print() {
for (var message : chat.getMessages()) {
System.out.println("\n" + message.name() + "\n" + message.message());
}
}
private void run(@NotNull String script) throws SQLException, IOException {
try (var conn = database.getConnection()) {
var runner = new ScriptRunner(conn, false, true);
runner.setLogWriter(null);
try (var stream = Objects.requireNonNull(PizzaBot.class.getResourceAsStream(script))) {
try (var reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
runner.runScript(reader);
}
}
}
}
private void deadline() throws SQLException {
database.delete(order);
database.delete(order1);
order = order0;
}
private void post(@NotNull User user, @NotNull String message) {
var post = new Post(0, user.username(), message, "marek", LocalDateTime.now(), 0L, user.id(), user.username(), "ffffff", 0);
bot.onMessage(chat, post);
}
@Nested
class OrderPizza {
@Test
void orderByPizzaName() throws SQLException {
post(user, "!marek order SALAMI");
var items = database.findAll(OrderItem.class);
assertEquals(1, items.size());
var item = items.getFirst();
assertNull(item.notes());
assertEquals(order.id(), item.orderId());
assertEquals(salami.id(), item.pizzaId());
var mappings = database.findAll(OrderItemToUser.class);
assertEquals(List.of(new OrderItemToUser(item.id(), user.id(), false)), mappings);
}
@Test
void orderByPizzaNameWithNotes() throws SQLException {
post(user, "!marek order SALAMI Hello World");
var items = database.findAll(OrderItem.class);
assertEquals(1, items.size());
var item = items.getFirst();
assertEquals("Hello World", item.notes());
assertEquals(order.id(), item.orderId());
assertEquals(salami.id(), item.pizzaId());
}
@Test
void orderByPizzaNameFailsAfterDeadline() throws SQLException {
deadline();
post(user, "!marek order SALAMI");
var items = database.findAll(OrderItem.class);
assertEquals(0, items.size());
}
@Test
void orderByPizzaNumber() throws SQLException {
post(user, "!marek order 13");
var items = database.findAll(OrderItem.class);
assertEquals(1, items.size());
var item = items.getFirst();
assertNull(item.notes());
assertEquals(order.id(), item.orderId());
var pizza = database.findById(Pizza.class, item.pizzaId());
assertTrue(pizza.isPresent());
assertEquals("Prosciutto", pizza.get().name());
}
@Test
void orderAddById() throws SQLException {
var item = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(admin, STR."!marek order add \{item.id()} \{user2.username()}");
assertEquals(
List.of(
new OrderItemToUser(item.id(), user.id(), false),
new OrderItemToUser(item.id(), user2.id(), false)
),
database.findAll(OrderItemToUser.class)
);
}
@Test
void orderAddByName() throws SQLException {
var item = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(admin, STR."!marek order add \{user.username()} \{user2.username()}");
assertEquals(
List.of(
new OrderItemToUser(item.id(), user.id(), false),
new OrderItemToUser(item.id(), user2.id(), false)
),
database.findAll(OrderItemToUser.class)
);
}
@Test
void orderAddByNameFailsWhenNotUnique() throws SQLException {
var item1 = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item1.id(), user.id(), false));
var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item2.id(), user.id(), false));
post(admin, STR."!marek order add \{user.username()} \{user2.username()}");
assertEquals(
List.of(
new OrderItemToUser(item1.id(), user.id(), false),
new OrderItemToUser(item2.id(), user.id(), false)
),
database.findAll(OrderItemToUser.class)
);
}
@Test
void orderAddByIdFailsWhenAlreadyPayed() throws SQLException {
var item = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
database.insert(new OrderItemToUser(item.id(), user2.id(), true));
post(admin, STR."!marek order add \{item.id()} \{admin.username()}");
assertEquals(
List.of(
new OrderItemToUser(item.id(), user.id(), false),
new OrderItemToUser(item.id(), user2.id(), true)
),
database.findAll(OrderItemToUser.class)
);
}
}
@Nested
class Revoke {
@Test
void orderRevoke() throws SQLException {
var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item1.id(), user.id(), false));
var item2 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item2.id(), user2.id(), false));
post(user, "!marek order revoke");
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item2), items);
}
@Test
void orderRevokeFailsAfterDeadline() throws SQLException {
deadline();
var item = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(user, "!marek order revoke");
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item), items);
}
@Test
void orderRevokeFailsWithMultipleItems() throws SQLException {
var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item1.id(), user.id(), false));
var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item2.id(), user.id(), false));
post(user, "!marek order revoke");
var items = database.findAll(OrderItem.class);
assertEquals(2, items.size());
}
@Test
void orderRevokeFailsWhenItemBelongsToOtherOrder() throws SQLException {
var item = database.save(new OrderItem(0, order1.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(user, "!marek order revoke");
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item), items);
}
@Test
void orderRevokeByNameWithMultipleItems() throws SQLException {
var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item1.id(), user.id(), false));
var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item2.id(), user.id(), false));
post(user, "!marek order revoke SALAMI");
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item1), items);
}
@Test
void orderRevokeByNameFailsWhenItemBelongsToOtherUser() throws SQLException {
var item = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(user2, "!marek order revoke MARGHERITA");
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item), items);
}
@Test
void orderRevokeByNameFailsWhenItemBelongsToOtherOrder() throws SQLException {
var item = database.save(new OrderItem(0, order1.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(user, "!marek order revoke MARGHERITA");
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item), items);
}
@Test
void orderRevokeById() throws SQLException {
var item = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(user, "!marek order revoke " + item.id());
var items = database.findAll(OrderItem.class);
assertEquals(List.of(), items);
}
@Test
void orderRevokeByIdFailsWhenItemBelongsToOtherUser() throws SQLException {
var item = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(user2, "!marek order revoke " + item.id());
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item), items);
}
@Test
void orderRevokeByIdFailsWhenItemBelongsToOtherOrder() throws SQLException {
var item = database.save(new OrderItem(0, order1.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item.id(), user.id(), false));
post(user, "!marek order revoke " + item.id());
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item), items);
}
@Test
void orderRevokeAll() throws SQLException {
var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
database.insert(new OrderItemToUser(item1.id(), user.id(), false));
var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item2.id(), user.id(), false));
var item3 = database.save(new OrderItem(0, order.id(), salami.id(), null));
database.insert(new OrderItemToUser(item3.id(), user2.id(), false));
post(user, "!marek order revoke --all");
var items = database.findAll(OrderItem.class);
assertEquals(List.of(item3), items);
}
}
@Nested
class Payment {
OrderItem item;
OrderItemToUser itemToUser;
OrderItemToUser itemToUser2;
@BeforeEach
void init() throws SQLException {
item = database.save(new OrderItem(0, order.id(), margherita.id(), null));
itemToUser = database.save(new OrderItemToUser(item.id(), user.id(), false));
itemToUser2 = database.save(new OrderItemToUser(item.id(), user2.id(), false));
}
@Test
void paymentConfirmById() throws SQLException {
post(admin, "!marek payment confirm " + item.id());
var mappings = database.findAll(OrderItemToUser.class);
assertEquals(List.of(itemToUser.withPayed(true), itemToUser2.withPayed(true)), mappings);
}
@Test
void paymentConfirmByIdFailsWhenNotAdmin() throws SQLException {
post(user, "!marek payment confirm " + item.id());
var mappings = database.findAll(OrderItemToUser.class);
assertEquals(List.of(itemToUser, itemToUser2), mappings);
}
@Test
void paymentConfirmByIdAndUser() throws SQLException {
post(admin, "!marek payment confirm " + item.id() + " " + user.username());
var mappings = database.findAll(OrderItemToUser.class);
assertEquals(List.of(itemToUser.withPayed(true), itemToUser2), mappings);
}
@Test
void paymentConfirmByIdAndUserFailsWhenNotAdmin() throws SQLException {
post(user, "!marek payment confirm " + item.id() + " " + user.username());
var mappings = database.findAll(OrderItemToUser.class);
assertEquals(List.of(itemToUser, itemToUser2), mappings);
}
@Test
void paymentConfirmByUser() throws SQLException {
var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null));
var item2ToUser = database.save(new OrderItemToUser(item2.id(), user.id(), false));
var item2ToUser2 = database.save(new OrderItemToUser(item2.id(), user2.id(), false));
post(admin, "!marek payment confirm --all " + user.username());
var mappings = database.findAll(OrderItemToUser.class);
assertEquals(
List.of(itemToUser.withPayed(true), itemToUser2, item2ToUser.withPayed(true), item2ToUser2),
mappings
);
}
}
@Nested
class Pay {
@Test
void pay() throws SQLException {
post(admin, "!marek pay 42.5");
var order = database.findById(Order.class, PizzaBotTest.this.order.id());
assertTrue(order.isPresent());
assertEquals(42.5, order.get().payment());
}
@Test
void payFailsWhenNotAdmin() throws SQLException {
post(user, "!marek pay 42.5");
var order = database.findById(Order.class, PizzaBotTest.this.order.id());
assertTrue(order.isPresent());
assertEquals(PizzaBotTest.this.order, order.get());
}
@Test
void payWithOrderName() throws SQLException {
post(admin, "!marek pay " + order0.name() + " 42.5");
var order0 = database.findById(Order.class, PizzaBotTest.this.order0.id());
assertTrue(order0.isPresent());
assertEquals(42.5, order0.get().payment());
var order = database.findById(Order.class, PizzaBotTest.this.order.id());
assertTrue(order.isPresent());
assertNull(order.get().payment());
}
}
@Nested
class CheckAndSummary {
OrderItem item1;
OrderItemToUser item1ToUser;
OrderItem item2;
OrderItemToUser item2ToUser;
OrderItem item3;
OrderItemToUser item3ToUser;
OrderItem item4;
OrderItemToUser item4ToUser2;
OrderItem item5;
OrderItemToUser item5ToUser;
OrderItemToUser item5ToUser2;
@BeforeEach
void init() throws SQLException {
item1 = database.save(new OrderItem(0, order0.id(), margherita.id(), null));
item1ToUser = database.save(new OrderItemToUser(item1.id(), user.id(), false));
item2 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
item2ToUser = database.save(new OrderItemToUser(item2.id(), user.id(), false));
item3 = database.save(new OrderItem(0, order.id(), salami.id(), null));
item3ToUser = database.save(new OrderItemToUser(item3.id(), user.id(), false));
item4 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
item4ToUser2 = database.save(new OrderItemToUser(item4.id(), user2.id(), false));
item5 = database.save(new OrderItem(0, order.id(), margherita.id(), null));
item5ToUser = database.save(new OrderItemToUser(item5.id(), user.id(), false));
item5ToUser2 = database.save(new OrderItemToUser(item5.id(), user2.id(), false));
}
@Test
void check() {
post(admin, "!marek check");
var message = chat.getMessages().getFirst();
var expected = FMT."""
Rechnung für %s\{order.name()}
alle Angaben ohne Gewähr
%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()));
}
@Test
void checkTip() throws SQLException {
database.update(order.withPayment(40d));
post(admin, "!marek check");
var message = chat.getMessages().getFirst();
var expected = FMT."""
Rechnung für %s\{order.name()} (mit Trinkgeld)
alle Angaben ohne Gewähr
%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()));
}
@Test
void checkPayed() throws SQLException {
database.executeUpdate("UPDATE order_item_to_user SET payed = TRUE WHERE user_id = " + user.id());
post(admin, "!marek check");
var message = chat.getMessages().getFirst();
var expected = FMT."""
Rechnung für %s\{order.name()}
alle Angaben ohne Gewähr
%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]);
}
@Test
void checkPartialPayed() throws SQLException {
database.executeUpdate("UPDATE order_item_to_user SET payed = TRUE WHERE order_item_id = " + item5.id());
var user1Total = 1.5 * margherita.price() + salami.price();
var user1Remaining = margherita.price() + salami.price();
var user2Total = 1.5 * margherita.price();
var user2Remaining = margherita.price();
post(admin, "!marek check");
var message = chat.getMessages().getFirst();
var expected = FMT."""
Rechnung für %s\{order.name()}
alle Angaben ohne Gewähr
%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(
STR."\{user.username()}: \{strikethrough(tabular(user1Total, 0)+ " ")} \{tabular(user1Remaining, 0)} €",
message.message().split("\n")[3]
);
assertEquals(
STR."\{user2.username()}: \{strikethrough(tabular(user2Total, 0)+ " ")} \{tabular(user2Remaining, 0)} €",
message.message().split("\n")[4]
);
}
@Test
void checkAllTip() throws SQLException {
database.update(order.withPayment(40d));
post(admin, "!marek check --all");
var message = chat.getMessages().getFirst();
var expected = FMT."""
Rechnung für %s\{event.name()} (mit Trinkgeld)
alle Angaben ohne Gewähr
%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()));
}
@Test
void summary() {
post(admin, "!marek summary");
var message = chat.getMessages().getFirst();
var expected = FMT."""
Übersicht %s\{order.name()}
3x \{margherita.name().toUpperCase(Locale.ROOT)} (\{user.username()}, \{user.username()}+\{user2.username()}, \{user2.username()})
1x \{salami.name().toUpperCase(Locale.ROOT)} (\{user.username()})
""".trim();
assertEquals(expected, strip(message.message()));
}
@Test
void summaryAll() {
post(admin, "!marek summary --all");
var message = chat.getMessages().getFirst();
var expected = FMT."""
Übersicht %s\{event.name()}
4x \{margherita.name().toUpperCase(Locale.ROOT)} (2x \{user.username()}, \{user.username()}+\{user2.username()}, \{user2.username()})
1x \{salami.name().toUpperCase(Locale.ROOT)} (\{user.username()})
""".trim();
assertEquals(expected, strip(message.message()));
}
}
}

@ -0,0 +1,248 @@
package eu.jonahbauer.chat.bot.pizza;
/*
* Slightly modified version of the com.ibatis.common.jdbc.ScriptRunner class
* from the iBATIS Apache project. Only removed dependency on Resource class
* and a constructor
*/
/*
* Copyright 2004 Clinton Begin
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import java.io.IOException;
import java.io.LineNumberReader;
import java.io.PrintWriter;
import java.io.Reader;
import java.sql.*;
/**
* Tool to run database scripts
*/
@SuppressWarnings("all")
public class ScriptRunner {
private static final String DEFAULT_DELIMITER = ";";
private Connection connection;
private boolean stopOnError;
private boolean autoCommit;
private PrintWriter logWriter = new PrintWriter(System.out);
private PrintWriter errorLogWriter = new PrintWriter(System.err);
private String delimiter = DEFAULT_DELIMITER;
private boolean fullLineDelimiter = false;
/**
* Default constructor
*/
public ScriptRunner(Connection connection, boolean autoCommit,
boolean stopOnError) {
this.connection = connection;
this.autoCommit = autoCommit;
this.stopOnError = stopOnError;
}
public void setDelimiter(String delimiter, boolean fullLineDelimiter) {
this.delimiter = delimiter;
this.fullLineDelimiter = fullLineDelimiter;
}
/**
* Setter for logWriter property
*
* @param logWriter
* - the new value of the logWriter property
*/
public void setLogWriter(PrintWriter logWriter) {
this.logWriter = logWriter;
}
/**
* Setter for errorLogWriter property
*
* @param errorLogWriter
* - the new value of the errorLogWriter property
*/
public void setErrorLogWriter(PrintWriter errorLogWriter) {
this.errorLogWriter = errorLogWriter;
}
/**
* Runs an SQL script (read in using the Reader parameter)
*
* @param reader
* - the source of the script
*/
public void runScript(Reader reader) throws IOException, SQLException {
try {
boolean originalAutoCommit = connection.getAutoCommit();
try {
if (originalAutoCommit != this.autoCommit) {
connection.setAutoCommit(this.autoCommit);
}
runScript(connection, reader);
} finally {
connection.setAutoCommit(originalAutoCommit);
}
} catch (IOException e) {
throw e;
} catch (SQLException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("Error running script. Cause: " + e, e);
}
}
/**
* Runs an SQL script (read in using the Reader parameter) using the
* connection passed in
*
* @param conn
* - the connection to use for the script
* @param reader
* - the source of the script
* @throws SQLException
* if any SQL errors occur
* @throws IOException
* if there is an error reading from the Reader
*/
private void runScript(Connection conn, Reader reader) throws IOException,
SQLException {
StringBuffer command = null;
try {
LineNumberReader lineReader = new LineNumberReader(reader);
String line = null;
while ((line = lineReader.readLine()) != null) {
if (command == null) {
command = new StringBuffer();
}
String trimmedLine = line.trim();
if (trimmedLine.startsWith("--")) {
println(trimmedLine);
} else if (trimmedLine.length() < 1
|| trimmedLine.startsWith("//")) {
// Do nothing
} else if (trimmedLine.length() < 1
|| trimmedLine.startsWith("--")) {
// Do nothing
} else if (!fullLineDelimiter
&& trimmedLine.endsWith(getDelimiter())
|| fullLineDelimiter
&& trimmedLine.equals(getDelimiter())) {
command.append(line.substring(0, line
.lastIndexOf(getDelimiter())));
command.append(" ");
Statement statement = conn.createStatement();
println(command);
boolean hasResults = false;
if (stopOnError) {
hasResults = statement.execute(command.toString());
} else {
try {
statement.execute(command.toString());
} catch (SQLException e) {
e.fillInStackTrace();
printlnError("Error executing: " + command);
printlnError(e);
}
}
if (autoCommit && !conn.getAutoCommit()) {
conn.commit();
}
ResultSet rs = statement.getResultSet();
if (hasResults && rs != null) {
ResultSetMetaData md = rs.getMetaData();
int cols = md.getColumnCount();
for (int i = 0; i < cols; i++) {
String name = md.getColumnLabel(i);
print(name + "\t");
}
println("");
while (rs.next()) {
for (int i = 0; i < cols; i++) {
String value = rs.getString(i);
print(value + "\t");
}
println("");
}
}
command = null;
try {
statement.close();
} catch (Exception e) {
// Ignore to workaround a bug in Jakarta DBCP
}
Thread.yield();
} else {
command.append(line);
command.append(" ");
}
}
if (!autoCommit) {
conn.commit();
}
} catch (SQLException e) {
e.fillInStackTrace();
printlnError("Error executing: " + command);
printlnError(e);
throw e;
} catch (IOException e) {
e.fillInStackTrace();
printlnError("Error executing: " + command);
printlnError(e);
throw e;
} finally {
conn.rollback();
flush();
}
}
private String getDelimiter() {
return delimiter;
}
private void print(Object o) {
if (logWriter != null) {
System.out.print(o);
}
}
private void println(Object o) {
if (logWriter != null) {
logWriter.println(o);
}
}
private void printlnError(Object o) {
if (errorLogWriter != null) {
errorLogWriter.println(o);
}
}
private void flush() {
if (logWriter != null) {
logWriter.flush();
}
if (errorLogWriter != null) {
errorLogWriter.flush();
}
}
}

@ -12,6 +12,9 @@ include("management")
include("bots:ping-bot")
project(":bots:ping-bot").name = "ping-bot"
include("bots:pizza-bot")
project(":bots:pizza-bot").name = "pizza-bot"
dependencyResolutionManagement {
repositories {
mavenCentral()

Loading…
Cancel
Save