CZĘŚĆ 1 Aloha! W poprzednim artykule stworzyliśmy prostego bota, który powita nas na każdym wydarzeniu. Napisaliśmy już tysiące linii kodu, czas dodać do naszego bota bardziej złożoną funkcjonalność. Dzisiaj spróbujemy napisać prostego bota, aby w wolnym czasie móc doskonalić swoją wiedzę z Java Core przed rozmowami kwalifikacyjnymi (o dziwo, nie znalazłem ani jednego działającego bota tego rodzaju). Aby to zrobić, wykonamy następujące czynności:
- podłącz zewnętrzną bazę danych Postgres do Heroku;
- Napiszmy nasze pierwsze skrypty inicjujące i zapełniające bazę danych;
- podłączmy Spring Boot Data JPA do pracy z bazą danych;
- Realizujemy różne scenariusze zachowania bota.
- zarejestruj się na Heroku ;
- Przejdź do naszego panelu -> Nowy -> Utwórz nową aplikację i utwórz nową aplikację;
- Wchodzimy do nowo utworzonej aplikacji, przeraża nas ilość przycisków, ale skupiamy się na panelu „Zainstalowane dodatki” – obok niego znajduje się przycisk Konfiguruj dodatki, klikamy w niego;
- W wyszukiwarce wpisz „Heroku Postgres”, wybierz plan „Hobby Dev - Free” -> Prześlij formularz zamówienia;
- Otwórz nowo uzyskaną bazę danych -> Ustawienia -> Wyświetl dane uwierzytelniające. W tej zakładce będą znajdować się nasze klucze umożliwiające dostęp do bazy danych. Pamiętamy ich lokalizację - będą nam potrzebne do podłączenia bazy danych, tak jak DataSource w IDEA.
- Lombok to biblioteka, dzięki której znacząco zmniejszymy ilość różnego kodu. Dzięki niemu możemy automatycznie tworzyć konstruktory, setery, gettery i wiele więcej.
- Spring Data JPA to framework do pracy z bazami danych (choć brzmi to zbyt prosto). Opis możliwości Spring Data JPA obejmowałby serię artykułów, a podana przez nas zależność obejmuje także Hibernate i wiele więcej, więc pomińmy szczegóły i po prostu spróbujmy dzisiaj napisać coś przy użyciu Spring JPA.
- PostgreSQL - ściągamy bibliotekę, aby uzyskać sterownik, który będzie współpracował z naszą bazą danych.
DROP TABLE IF EXISTS java_quiz;
DROP TABLE IF EXISTS users;
CREATE SEQUENCE global_seq START WITH 100000;
CREATE TABLE users
(
id INTEGER PRIMARY KEY DEFAULT nextval('global_seq'),
chat_id INTEGER UNIQUE NOT NULL,
name VARCHAR NOT NULL,
score INTEGER DEFAULT 0 NOT NULL,
high_score INTEGER DEFAULT 0 NOT NULL,
bot_state VARCHAR NOT NULL
);
CREATE TABLE java_quiz
(
id INTEGER PRIMARY KEY DEFAULT nextval('global_seq'),
question VARCHAR NOT NULL,
answer_correct VARCHAR NOT NULL,
option1 VARCHAR NOT NULL,
option2 VARCHAR NOT NULL,
option3 VARCHAR NOT NULL
);
Co robi nasz skrypt? Pierwsze dwie linie usuwają tabele, jeśli są obecne, w celu ich odtworzenia. Trzecia linia tworzy sekwencję, która będzie używana do tworzenia unikalnych wpisów id w naszej bazie danych. Następnie tworzymy dwie tabele: dla użytkowników i dla pytań. Użytkownik będzie miał unikalny identyfikator, identyfikator czatu telegramowego, nazwę, liczbę punktów (bieżącą i maksymalną), a także aktualny status bota. Pytania będą miały również unikalny identyfikator, a także pola odpowiadające za opcje pytań i odpowiedzi. Powstały skrypt możemy wykonać, klikając go prawym przyciskiem myszy i wybierając „Wykonaj skrypt SQL”. Szczególną uwagę należy zwrócić na pozycję „Interfejs Cmd-Line” - tutaj będziemy potrzebować świeżo zainstalowanego PostgreSQL. Konfigurując to pole, wybierz „Nowy interfejs Cmd-Line” i podaj ścieżkę do pliku psql.exe. W rezultacie ustawienia powinny wyglądać mniej więcej tak: Wykonujemy skrypt i jeśli nigdzie nie popełniliśmy błędu, efekt naszej pracy będzie następujący: <h3>Utwórz model</h3>Teraz czas aby powrócić do pisania kodu Java. Aby skrócić artykuł, pominę opis adnotacji użytych do napisania zajęć, abyście mogli się z nimi zapoznać. Stwórzmy pakiet modelowy, w którym będziemy mieli trzy klasy:
- AbstractBaseEntity to klasa opisująca dowolny obiekt, który może mieć identyfikator (ta klasa stanowi duże uproszczenie tego, co możesz zobaczyć na stażu):
package com.whiskels.telegram.model; import lombok.Getter; import lombok.Setter; import javax.persistence.*; // Аннотация, которая говорит нам, что это суперкласс для всех Entity // https://vladmihalcea.com/how-to-inherit-properties-from-a-base-class-entity-using-mappedsuperclass-with-jpa-and-hibernate/ @MappedSuperclass // http://stackoverflow.com/questions/594597/hibernate-annotations-which-is-better-field-or-property-access @Access(AccessType.FIELD) // Аннотации Lombok для автогенерации сеттеров и геттеров на все поля @Getter @Setter public abstract class AbstractBaseEntity { // Аннотации, описывающие механизм генерации id - разберитесь в dokumentации каждой! @Id @SequenceGenerator(name = "global_seq", sequenceName = "global_seq", allocationSize = 1, initialValue = START_SEQ) @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "global_seq") // See https://hibernate.atlassian.net/browse/HHH-3718 and https://hibernate.atlassian.net/browse/HHH-12034 // Proxy initialization when accessing its identifier managed now by JPA_PROXY_COMPLIANCE setting protected Integer id; protected AbstractBaseEntity() { } }
- Użytkownik :
package com.whiskels.telegram.model; import com.whiskels.telegram.bot.State; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.hibernate.annotations.BatchSize; import javax.persistence.*; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import java.util.Set; import static javax.persistence.FetchType.EAGER; @Entity @Table(name = "users", uniqueConstraints = {@UniqueConstraint(columnNames = "chat_id", name = "users_unique_chatid_idx")}) @Getter @Setter @NoArgsConstructor @AllArgsConstructor public class User extends AbstractBaseEntity { @Column(name = "chat_id", unique = true, nullable = false) @NotNull private Integer chatId; @Column(name = "name", unique = true, nullable = false) @NotBlank private String name; @Column(name = "score", nullable = false) @NotNull private Integer score; @Column(name = "high_score", nullable = false) @NotNull private Integer highScore; @Column(name = "bot_state", nullable = false) @NotBlank private State botState; // Конструктор нужен для создания нового пользователя (а может и нет? :)) public User(int chatId) { this.chatId = chatId; this.name = String.valueOf(chatId); this.score = 0; this.highScore = 0; this.botState = State.START; } }
- Klasa pytań :
package com.whiskels.telegram.model; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.Table; import javax.validation.constraints.NotBlank; @Entity @Table(name = "java_quiz") @Getter @Setter @NoArgsConstructor @AllArgsConstructor public class Question extends AbstractBaseEntity { @Column(name = "question", nullable = false) @NotBlank private String question; @Column(name = "answer_correct", nullable = false) @NotBlank private String correctAnswer; @Column(name = "option2", nullable = false) @NotBlank private String optionOne; @Column(name = "option1", nullable = false) @NotBlank private String optionTwo; @Column(name = "option3", nullable = false) @NotBlank private String optionThree; @Override public String toString() { return "Question{" + "question='" + question + '\'' + ", correctAnswer='" + correctAnswer + '\'' + ", optionOne='" + optionOne + '\'' + ", optionTwo='" + optionTwo + '\'' + ", optionThree='" + optionThree + '\'' + '}'; } }
- Repozytorium użytkowników Jpa:
package com.whiskels.telegram.repository; import com.whiskels.telegram.model.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @Repository @Transactional(readOnly = true) public interface JpaUserRepository extends JpaRepository<user, integer=""> { // По названию метода Spring сам поймет, что мы хотим получить пользователя по переданному chatId Optional<user> getByChatId(int chatId); } </user></user,>
- Repozytorium JpaQuestion:
package com.whiskels.telegram.repository; import com.whiskels.telegram.model.Question; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @Repository @Transactional(readOnly = true) public interface JpaQuestionRepository extends JpaRepository<question, integer=""> { // А здесь мы написали SQL Query, которая будет выбирать 1 случайный вопрос из таблицы вопросов @Query(nativeQuery = true, value = "SELECT * FROM java_quiz ORDER BY random() LIMIT 1") Question getRandomQuestion(); } </question,>
package com.whiskels.telegram.bot;
public enum State {
NONE,
START,
ENTER_NAME,
PLAYING_QUIZ,
}
Następnie utworzymy pakiet bot/handler, w którym zadeklarujemy interfejs modułu obsługi:
package com.whiskels.telegram.bot.handler;
import com.whiskels.telegram.bot.State;
import com.whiskels.telegram.model.User;
import org.telegram.telegrambots.meta.api.methods.PartialBotApiMethod;
import java.io.Serializable;
import java.util.List;
public interface Handler {
// основной метод, который будет обрабатывать действия пользователя
List<partialbotapimethod<? extends="" serializable="">> handle(User user, String message);
// метод, который позволяет узнать, можем ли мы обработать текущий State у пользователя
State operatedBotState();
// метод, который позволяет узнать, Jakие команды CallBackQuery мы можем обработать в этом классе
List<string> operatedCallBackQuery();
}
</string></partialbotapimethod<?>
Handlery utworzymy nieco później, ale na razie oddelegujmy przetwarzanie zdarzeń do nowej klasy UpdateReceiver , którą utworzymy w katalogu głównym pakietu bota: UWAGA! Tu i dalej będą metody, które będą wyświetlane jako Lista> handle(args); w rzeczywistości wyglądają tak, ale formater kodu je zepsuł:
package com.whiskels.telegram.bot;
import com.whiskels.telegram.bot.handler.Handler;
import com.whiskels.telegram.model.User;
import com.whiskels.telegram.repository.JpaUserRepository;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.meta.api.methods.PartialBotApiMethod;
import org.telegram.telegrambots.meta.api.objects.CallbackQuery;
import org.telegram.telegrambots.meta.api.objects.Message;
import org.telegram.telegrambots.meta.api.objects.Update;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
@Component
public class UpdateReceiver {
// Храним доступные хендлеры в списке (подсмотрел у Miroha)
private final List<handler> handlers;
// Имеем доступ в базу пользователей
private final JpaUserRepository userRepository;
public UpdateReceiver(List<handler> handlers, JpaUserRepository userRepository) {
this.handlers = handlers;
this.userRepository = userRepository;
}
// Обрабатываем полученный Update
public List<partialbotapimethod<? extends="" serializable="">> handle(Update update) {
// try-catch, чтобы при несуществующей команде просто возвращать пустой список
try {
// Проверяем, если Update - сообщение с текстом
if (isMessageWithText(update)) {
// Получаем Message из Update
final Message message = update.getMessage();
// Получаем айди чата с пользователем
final int chatId = message.getFrom().getId();
// Просим у репозитория пользователя. Если такого пользователя нет - создаем нового и возвращаем его.
// Как раз на случай нового пользователя мы и сделали конструктор с одним параметром в классе User
final User user = userRepository.getByChatId(chatId)
.orElseGet(() -> userRepository.save(new User(chatId)));
// Ищем нужный обработчик и возвращаем результат его работы
return getHandlerByState(user.getBotState()).handle(user, message.getText());
} else if (update.hasCallbackQuery()) {
final CallbackQuery callbackQuery = update.getCallbackQuery();
final int chatId = callbackQuery.getFrom().getId();
final User user = userRepository.getByChatId(chatId)
.orElseGet(() -> userRepository.save(new User(chatId)));
return getHandlerByCallBackQuery(callbackQuery.getData()).handle(user, callbackQuery.getData());
}
throw new UnsupportedOperationException();
} catch (UnsupportedOperationException e) {
return Collections.emptyList();
}
}
private Handler getHandlerByState(State state) {
return handlers.stream()
.filter(h -> h.operatedBotState() != null)
.filter(h -> h.operatedBotState().equals(state))
.findAny()
.orElseThrow(UnsupportedOperationException::new);
}
private Handler getHandlerByCallBackQuery(String query) {
return handlers.stream()
.filter(h -> h.operatedCallBackQuery().stream()
.anyMatch(query::startsWith))
.findAny()
.orElseThrow(UnsupportedOperationException::new);
}
private boolean isMessageWithText(Update update) {
return !update.hasCallbackQuery() && update.hasMessage() && update.getMessage().hasText();
}
}
</partialbotapimethod<?></handler></handler>
I delegujemy mu przetwarzanie w klasie Bot:
package com.whiskels.telegram.bot;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.bots.TelegramLongPollingBot;
import org.telegram.telegrambots.meta.api.methods.PartialBotApiMethod;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import java.io.Serializable;
import java.util.List;
@Slf4j
@Component
public class Bot extends TelegramLongPollingBot {
@Value("${bot.name}")
@Getter
private String botUsername;
@Value("${bot.token}")
@Getter
private String botToken;
private final UpdateReceiver updateReceiver;
public Bot(UpdateReceiver updateReceiver) {
this.updateReceiver = updateReceiver;
}
@Override
public void onUpdateReceived(Update update) {
List<partialbotapimethod<? extends="" serializable="">> messagesToSend = updateReceiver.handle(update);
if (messagesToSend != null && !messagesToSend.isEmpty()) {
messagesToSend.forEach(response -> {
if (response instanceof SendMessage) {
executeWithExceptionCheck((SendMessage) response);
}
});
}
}
public void executeWithExceptionCheck(SendMessage sendMessage) {
try {
execute(sendMessage);
} catch (TelegramApiException e) {
log.error("oops");
}
}
}
</partialbotapimethod<?>
Teraz nasz bot deleguje przetwarzanie zdarzeń do klasy UpdateReceiver , ale nie mamy jeszcze żadnych procedur obsługi. Stwórzmy je! ZASTRZEŻENIE! Bardzo chciałem podzielić się możliwościami napisania takiego bota, więc dalszy kod (jak w zasadzie kod UpdateReceiver) można bardzo dobrze refaktorować przy użyciu różnych wzorców. Ale się uczymy i naszym celem jest minimalnie wykonalny bot, więc w ramach kolejnej pracy domowej możesz refaktoryzować wszystko, co widziałeś :) Utwórz pakiet util, a w nim - klasę TelegramUtil :
package com.whiskels.telegram.util;
import com.whiskels.telegram.model.User;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton;
public class TelegramUtil {
public static SendMessage createMessageTemplate(User user) {
return createMessageTemplate(String.valueOf(user.getChatId()));
}
// Создаем шаблон SendMessage с включенным Markdown
public static SendMessage createMessageTemplate(String chatId) {
return new SendMessage()
.setChatId(chatId)
.enableMarkdown(true);
}
// Создаем кнопку
public static InlineKeyboardButton createInlineKeyboardButton(String text, String command) {
return new InlineKeyboardButton()
.setText(text)
.setCallbackData(command);
}
}
Napiszemy cztery programy obsługi: HelpHandler, QuizHandler, RegistrationHandler, StartHandler. StartHandler:
package com.whiskels.telegram.bot.handler;
import com.whiskels.telegram.bot.State;
import com.whiskels.telegram.model.User;
import com.whiskels.telegram.repository.JpaUserRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.meta.api.methods.PartialBotApiMethod;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import java.io.Serializable;
import java.util.Collections;
import java.util.List;
import static com.whiskels.telegram.util.TelegramUtil.createMessageTemplate;
@Component
public class StartHandler implements Handler {
@Value("${bot.name}")
private String botUsername;
private final JpaUserRepository userRepository;
public StartHandler(JpaUserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public List<partialbotapimethod<? extends="" serializable="">> handle(User user, String message) {
// Приветствуем пользователя
SendMessage welcomeMessage = createMessageTemplate(user)
.setText(String.format(
"Hola! I'm *%s*%nI am here to help you learn Java", botUsername
));
// Просим назваться
SendMessage registrationMessage = createMessageTemplate(user)
.setText("In order to start our journey tell me your name");
// Меняем пользователю статус на - "ожидание ввода имени"
user.setBotState(State.ENTER_NAME);
userRepository.save(user);
return List.of(welcomeMessage, registrationMessage);
}
@Override
public State operatedBotState() {
return State.START;
}
@Override
public List<string> operatedCallBackQuery() {
return Collections.emptyList();
}
}
</string></partialbotapimethod<?>
Osoba zajmująca się rejestracją:
package com.whiskels.telegram.bot.handler;
import com.whiskels.telegram.bot.State;
import com.whiskels.telegram.model.User;
import com.whiskels.telegram.repository.JpaUserRepository;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.meta.api.methods.PartialBotApiMethod;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton;
import java.io.Serializable;
import java.util.List;
import static com.whiskels.telegram.bot.handler.QuizHandler.QUIZ_START;
import static com.whiskels.telegram.util.TelegramUtil.createInlineKeyboardButton;
import static com.whiskels.telegram.util.TelegramUtil.createMessageTemplate;
@Component
public class RegistrationHandler implements Handler {
//Храним поддерживаемые CallBackQuery в виде констант
public static final String NAME_ACCEPT = "/enter_name_accept";
public static final String NAME_CHANGE = "/enter_name";
public static final String NAME_CHANGE_CANCEL = "/enter_name_cancel";
private final JpaUserRepository userRepository;
public RegistrationHandler(JpaUserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public List<partialbotapimethod<? extends="" serializable="">> handle(User user, String message) {
// Проверяем тип полученного события
if (message.equalsIgnoreCase(NAME_ACCEPT) || message.equalsIgnoreCase(NAME_CHANGE_CANCEL)) {
return accept(user);
} else if (message.equalsIgnoreCase(NAME_CHANGE)) {
return changeName(user);
}
return checkName(user, message);
}
private List<partialbotapimethod<? extends="" serializable="">> accept(User user) {
// Если пользователь принял Nazwa - меняем статус и сохраняем
user.setBotState(State.NONE);
userRepository.save(user);
// Создаем кнопку для начала игры
InlineKeyboardMarkup inlineKeyboardMarkup = new InlineKeyboardMarkup();
List<inlinekeyboardbutton> inlineKeyboardButtonsRowOne = List.of(
createInlineKeyboardButton("Start quiz", QUIZ_START));
inlineKeyboardMarkup.setKeyboard(List.of(inlineKeyboardButtonsRowOne));
return List.of(createMessageTemplate(user).setText(String.format(
"Your name is saved as: %s", user.getName()))
.setReplyMarkup(inlineKeyboardMarkup));
}
private List<partialbotapimethod<? extends="" serializable="">> checkName(User user, String message) {
// При проверке имени мы превентивно сохраняем пользователю новое Nazwa в базе
// идея для рефакторинга - добавить временное хранение имени
user.setName(message);
userRepository.save(user);
// Czyn кнопку для применения изменений
InlineKeyboardMarkup inlineKeyboardMarkup = new InlineKeyboardMarkup();
List<inlinekeyboardbutton> inlineKeyboardButtonsRowOne = List.of(
createInlineKeyboardButton("Accept", NAME_ACCEPT));
inlineKeyboardMarkup.setKeyboard(List.of(inlineKeyboardButtonsRowOne));
return List.of(createMessageTemplate(user)
.setText(String.format("You have entered: %s%nIf this is correct - press the button", user.getName()))
.setReplyMarkup(inlineKeyboardMarkup));
}
private List<partialbotapimethod<? extends="" serializable="">> changeName(User user) {
// При wniosekе изменения имени мы меняем State
user.setBotState(State.ENTER_NAME);
userRepository.save(user);
// Создаем кнопку для отмены операции
InlineKeyboardMarkup inlineKeyboardMarkup = new InlineKeyboardMarkup();
List<inlinekeyboardbutton> inlineKeyboardButtonsRowOne = List.of(
createInlineKeyboardButton("Cancel", NAME_CHANGE_CANCEL));
inlineKeyboardMarkup.setKeyboard(List.of(inlineKeyboardButtonsRowOne));
return List.of(createMessageTemplate(user).setText(String.format(
"Your current name is: %s%nEnter new name or press the button to continue", user.getName()))
.setReplyMarkup(inlineKeyboardMarkup));
}
@Override
public State operatedBotState() {
return State.ENTER_NAME;
}
@Override
public List<string> operatedCallBackQuery() {
return List.of(NAME_ACCEPT, NAME_CHANGE, NAME_CHANGE_CANCEL);
}
}
</string></inlinekeyboardbutton></partialbotapimethod<?></inlinekeyboardbutton></partialbotapimethod<?></inlinekeyboardbutton></partialbotapimethod<?></partialbotapimethod<?>
Osoba zajmująca się pomocą:
package com.whiskels.telegram.bot.handler;
import com.whiskels.telegram.bot.State;
import com.whiskels.telegram.model.User;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.meta.api.methods.PartialBotApiMethod;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup;
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static com.whiskels.telegram.bot.handler.RegistrationHandler.NAME_CHANGE;
import static com.whiskels.telegram.util.TelegramUtil.createInlineKeyboardButton;
import static com.whiskels.telegram.util.TelegramUtil.createMessageTemplate;
@Component
public class HelpHandler implements Handler {
@Override
public List<partialbotapimethod<? extends="" serializable="">> handle(User user, String message) {
// Создаем кнопку для смены имени
InlineKeyboardMarkup inlineKeyboardMarkup = new InlineKeyboardMarkup();
List<inlinekeyboardbutton> inlineKeyboardButtonsRowOne = List.of(
createInlineKeyboardButton("Change name", NAME_CHANGE));
inlineKeyboardMarkup.setKeyboard(List.of(inlineKeyboardButtonsRowOne));
return List.of(createMessageTemplate(user).setText(String.format("" +
"You've asked for help %s? Here it comes!", user.getName()))
.setReplyMarkup(inlineKeyboardMarkup));
}
@Override
public State operatedBotState() {
return State.NONE;
}
@Override
public List<string> operatedCallBackQuery() {
return Collections.emptyList();
}
}
</string></inlinekeyboardbutton></partialbotapimethod<?>
QuizHandler (najgorszy
GO TO FULL VERSION