Источник

Предыстория

Я не профессиональный it-разработчик. Пройдя два года назад онлайн курсы по программированию на java я, в свободное от основной профессиональной деятельности время, создаю так называемые pet‑projects — небольшие телеграмм‑боты, которыми, в том числе, активно пользуюсь сама.

Написав в 2023 году своего второго бота — картинную галерею (функционал которого был достаточно прост — выдавать пользователю по запросу примеры картин художников, экспонирующиеся в крупных российских музеях), во мне зародилась мысль трансформировать его в викторину, где пользователь должен угадать художника той или иной картины.

Полтора года глаза боялись, но в конце 2024 руки взялись-таки за его написание.

Техническое задание

Функционал викторины классический:

  • телеграмм‑бот в случайном порядке должен присылать пользователю картины известных художников, которые сохранены в его базе,

  • под картиной предлагаются кнопки с вариантами ответа,

  • при выборе правильного ответа — присылается сообщение с результатом, названием картины и новая картина с вариантами ответов,

  • если ответ неверный, пользователя об этом оповещают и предлагают либо выбрать другой вариант, либо нажать на соответствующую кнопку для получения нового вопроса‑картины,

  • если какая‑то картина высылается повторно — варианты ответов должны выдаваться в иной последовательности, чем предлагались ранее,

  • ведется статистика правильных ответов с возможностью ее обнуления,

  • в перспективе — расширение тематики викторин.

Начало реализации

Хранение вопросов и вариантов ответов к ним, работу с базой этих вопросов я реализовала через json:

1. Создала json-файл с массивом вопросов, где сам вопрос — это наименование файла с картиной (в формате.jpg, сами файлы хранятся в отдельной ресурсной папке), а варианты ответов на вопросы — отдельный массив.

Пример выдержки из json-файла:

[
  {
    "id": "1a",
    "question": "Аврезе Мария Лина, Девочка в голубом.jpg",
    "options": [
      "Аврезе Мария Лина",
      "Ренуар Огюст",
      "Лорансен Мари"
    ],
    "answer": "Аврезе Мария Лина",
    "description": "Аврезе Мария Лина, Девочка в голубом."
  },  
{…},
{
    "id": "400a",
    "question": "Сислей Альфред, Фруктовый сад весной.jpg",
    "options": [
      "Сислей Альфред",
      "Писсаро Камиль",
      "Моне Клод"
    ],
    "answer": "Сислей Альфред",
    "description": "Сислей Альфред, Фруктовый сад весной."
  }
]

2. Создала java-объект с полями, соответствующими полям объекта json-файла:

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class Question {
    private String id;
    private String question;
    private String[] options;
    private String answer;
    private String description;
}

3. Используя библиотеку jackson прописала парсинг значений из json-файла для получения объектов:

public class ParserJson {

//получение списка объектов с вопросами и узлов в них из Json-файла
    static ObjectMapper mapper = new ObjectMapper();

    @SneakyThrows
    public static List<Question> fileToList(String fileName) {
        List<Question> questionList = new ArrayList<>();
        String jsonString = jsonToString(fileName);
        JsonNode jsonArray = mapper.readTree(jsonString);

// заполнение данными объекта java (класс Question)
// из json-файла и получение списка объектов java
        for (JsonNode element : jsonArray) {
            Question question = mapper.treeToValue(element, Question.class);
            questionList.add(question);
        }
        return questionList;
    }

// получение данных в виде строк из Json-файла
    @SneakyThrows
    public static String jsonToString(String fileName) {
        return new String(Files.readAllBytes(Paths.get(fileName)));
    }
}

Поскольку картин-вопросов у меня было 400, реализация выбора вопроса из коллекции вопросов рандомным способом мне представлялась достаточной. Для этого я написала метод:

// получение случайного объекта из коллекции объектов json
    public static Question getRandomObject(String jsonFileName) {
            List<Question> questionList = ParserJson.fileToList(jsonFileName);
            Random random = new Random();
            return questionList.get(random.nextInt(questionList.size()));
    }

Предложение вариантов ответа реализовано через InlineKeyboardMarkup:

public class ArtKeyboard {

    public SendMessage createKeyboard(long chatId, String opt1, 
                                      String opt2, String opt3) {

        SendMessage sendMessage = new SendMessage();
        sendMessage.setChatId(chatId);
        sendMessage.setText("У последней картины, что прислал бот, выбери вариант, кто ее написал:");

        InlineKeyboardButton button1 = InlineKeyboardButton
                .builder()
                .text(opt1)
                .callbackData(opt1)
                .build();
        List<InlineKeyboardButton> row1 = new ArrayList<>();
        row1.add(button1);

        InlineKeyboardButton button2 = InlineKeyboardButton
                .builder()
                .text(opt2)
                .callbackData(opt2)
                .build();
        List<InlineKeyboardButton> row2 = new ArrayList<>();
        row2.add(button2);

        InlineKeyboardButton button3 = InlineKeyboardButton
                .builder()
                .text(opt3)
                .callbackData(opt3)
                .build();
        List<InlineKeyboardButton> row3 = new ArrayList<>();
        row3.add(button3);

        List<List<InlineKeyboardButton>> keyboard = new ArrayList<>();
        keyboard.add(row1);
        keyboard.add(row2);
        keyboard.add(row3);


        sendMessage.setReplyMarkup(
                InlineKeyboardMarkup
                        .builder()
                        .keyboard(keyboard)
                        .build());

        return sendMessage;
    }
}

Для получения значений полей объекта я использовала рефлексию. Чтобы реализовать вариативный порядок формирования вариантов ответов (в случае, если какая-то картина высылается повторно, чтобы варианты ответов выдавались в иной последовательности, чем предлагались ранее), использовала перемешивание элементов внутри массива:

// получение поля-массива объекта java (класс Question)
    @SneakyThrows
    public static String[] getArrayOptions(Question question, String fieldName) {
        Field field = question.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        return (String[]) field.get(question);
    }

// метод перемешивания элементов массива
    public static String[] shuffleArr(String[] array) {
        Random random = new Random();
        for (int i = array.length - 1; i >= 0; i--) {
            int index = random.nextInt(i + 1);
            String temp = array[index];
            array[index] = array[i];
            array[i] = temp;
        }
        return array;
    }

// получение поля-массива объекта java (класс Question) с перемешанными внутри значениями
    @SneakyThrows
    public static String[] getRandomArrayOptions(Question question, String fieldName) 
        return shuffleArr(getArrayOptions(question, fieldName));
    }

Это то основное, что было написано довольно быстро.

После этого я прописала код класса-наследника TelegramLongPollingBot, где происходила обработка запросов пользователя, являющихся командами, текстовыми сообщениями или ответами Inline-клавиатуры. В этом же классе я проинициализировала получаемый объект и все его поля. Запустила бот и, на стадии моно‑тестирования, все работало без нареканий: бот выдавал картинку, под ней — Inline‑кнопки с фамилиями художников, при нажатии на которые происходила корректная обратная реакция — определялись верные и неверные ответы.

Возникшие трудности

Первая трудность, которая возникла еще на стадии самотестирования бота, было то, что рандомный метод выбора вопроса из коллекции вопросов работал странно: из 400 объектов его словно замыкало на 10–20 и он навязчиво высылал картины в этом диапазоне, а порой выдавал одну картину 2 и более раз подряд.

Вторая трудность уже проявилась, когда я развернула бот на хостинге и предоставила его для использования широкой аудитории — бот работал со всеми пользователями как с одним. То есть, значения полей (правильный ответ, описание и пр.) были актуальными всегда для вопроса, который бот выслал последим независимо от того, что это за пользователь. Иными словами, получаю, например, я очередной вопрос от бота. Если до того, как я на него отвечу, какому-то другому пользователю бот тоже вышлет вопрос, то что бы я неотвечала — любой мой ответ уже будет неверным, т. к. в памяти бота правильный ответ привязан к тому вопросу, который он в принципе отправил последним, а не последним именно мне.

Изобретение собственного «велосипеда» с квадратными колесами.

Перебрав массу различных вариантов в пределах имеющихся знаний и информации в Интернет, я нашла следующее решение вышеуказанным трудностям.

Чтобы привязать бота к работе с запросами конкретного пользователя, при первом запуске программы каждым пользователем в ресурсной папке создается файл, в имени которого содержится id-пользователя и маркер «q» (определяющий, что это файл для вопросов). Этот же файл используется в дальнейшем для обеспечения уникальности присылаемых вопросов (без повторов, пока не будут отправлены все вопросы, имеющиеся в памяти бота).

public static Path getPathUserFile(String pathUserFile, String mark, long userId) {
        return Paths.get(pathUserFile + mark + userId + ".txt");
    }

// Создание файла, конвертация String в byte-массив и запись в файл (даже если файл существует)
    @SneakyThrows
    public static void writeToFile(String pathUserFile, String mark, Long userId, String indicator) {

            Files.write(
                    getPathUserFile(pathUserFile, mark, userId),
                    indicator.getBytes(),
                    StandardOpenOption.CREATE,
                    StandardOpenOption.APPEND
            );
    }

Когда пользователь начинает викторину, в указанный файл записывается id-вопроса, отправленного ботом. В дальнейшем перед тем, как отправить пользователю новый вопрос, бот формирует список вопросов json-файла, сравнивает его со списком вопросов из файла пользователя и формирует список вопросов, id которых нет в файле пользователя. И вот уже из этого списка-разницы рандомно высылает пользователю новый вопрос, id которого тут же записывается в файл пользователя последней строкой.

Когда все вопросы из json-файла заданы, файл пользователя обнуляется и начинается все сначала.

public class GetObject {

// получение случайного объекта из коллекции объектов json
    public static Question getRandomObject(String jsonFileName) {
            List<Question> questionList = ParserJson.fileToList(jsonFileName);
            Random random = new Random();
            return questionList.get(random.nextInt(questionList.size()));
    }

// получение случайного уникального (неповторяющегося) объекта из коллекции объектов json
    @SneakyThrows
    public static Question getUniqueRandomObject(String jsonFileName, String pathUserFile, String mark, Long userId) {

// список объектов из json-файла
        List<Question> questionList = ParserJson.fileToList(jsonFileName);

// список строк с id-вопросов из файла пользователя
        List<String> gameQuestionList = Files.readAllLines(FileManager.getPathUserFile(pathUserFile, mark, userId));

// создание списка объектов только с теми вопросами, которых еще не было (фильтрация по id)
        List<Question> uniqueQuestionList = questionList
                .stream()
                .filter(q -> !gameQuestionList.contains(q.getId()))
                .collect(Collectors.toList());

        Question uniqueQuestion;

// если список объектов с вопросами, которых еще не было, не пустой, выбираем из него случайный объект
        if (uniqueQuestionList.size() == 0) {
            FileManager.cleanFile(pathUserFile, mark, userId);
            uniqueQuestion = getRandomObject(jsonFileName);

// если все вопросы уже были - очищаем файл и при этом выдаем случайный объект из списка объектов json-файла
        } else {
            uniqueQuestion = uniqueQuestionList
                    .stream()
                    .skip(new Random().nextInt(uniqueQuestionList.size()))
                    .findFirst()
                    .orElse(null);
        }
        return uniqueQuestion;
    }

Указанный «велосипед» с квадратными колесами обеспечил отправку вопросов без повторов в пределах имеющегося перечня и решил первую трудность.  

Для решения второй трудности используется тот же файл с id пользователя и маркером «q» в названии. Для того, чтобы корректно обработать вызов Inline-кнопки, нажатой конкретным пользователем, необходимо верно идентифицировать, какой последний вопрос был ему задан. Для этого мы получаем последнюю строку с id-вопроса из файла пользователя и по извлеченному значению получаем вопрос из json-файла.

// получение последней строки с № id из файла пользователя с вопросами
    @SneakyThrows
    public static String getLastLineFromUserFile(String pathUserFile, 
                                                 String mark, long userId) {

        LineIterator lineIterator = FileUtils.lineIterator(
                new File(FileManager.getPathUserFile(pathUserFile, mark, userId).toUri()), "UTF-8");
        String lastLine = "";
        while (lineIterator.hasNext()) {
            lastLine = lineIterator.nextLine();
        }
        return lastLine;
    }

// получение объекта с параментрами id, взятого из последней строки файла пользователя с вопросами
    public static Question getQuestionOnLastLineFromUserFile(String jsonFileName,
                                                             String pathUserFile, String mark, long userId) {

        List<Question> questionList = ParserJson.fileToList(jsonFileName);
        String id = getLastLineFromUserFile(pathUserFile, mark, userId);

        return questionList.stream()
                .filter(q -> q.getId().equals(id))
                .findAny()
                .orElse(null);
    }
}

И уже у этого вопроса впоследствии получаем значение полей.

public class ReceiveOptAnsDesc {

    public static String receiveAnswer(Update update, String jsonFileName,
                                       String pathUserFile, String mark) {

        long userId = UpdateUtil.getUserFromUpdate(update).getId();

        Question currentQuestion = GetObject.getQuestionOnLastLineFromUserFile(jsonFileName,
                pathUserFile, mark, userId);

        return GetFieldValue.getFieldValue(currentQuestion, "answer");
    }
  

    public static String receiveDescription(Update update, String jsonFileName,
                                            String pathUserFile, String mark) {

        long userId = UpdateUtil.getUserFromUpdate(update).getId();

        Question currentQuestion = GetObject.getQuestionOnLastLineFromUserFile(jsonFileName,
                pathUserFile, mark, userId);

        return GetFieldValue.getFieldValue(currentQuestion, "description");
    }


    public static String receiveOptions(Update update, String jsonFileName,
                                            String pathUserFile, String mark) {

        long userId = UpdateUtil.getUserFromUpdate(update).getId();

        Question currentQuestion = GetObject.getQuestionOnLastLineFromUserFile(jsonFileName,
                pathUserFile, mark, userId);

        String[] options = GetFieldValue.getRandomArrayOptions(currentQuestion, "options");

        return Arrays.toString(options);
    }
}

Прописав все эти методы для идентификации вопроса и его полей по каждому отдельно пользователю, в классе-наследнике TelegramLongPollingBot я создала метод отправки вопроса пользователю

    @SneakyThrows
    public void sendArtQuest(Update update) {
        long chatId = UpdateUtil.getChatFromUpdate(update).getId();
        long userId = UpdateUtil.getUserFromUpdate(update).getId();
        SendMessage sendMessage = new SendMessage();
        sendMessage.setChatId(chatId);

// выбираем уникальный рандомный объект из json-файла с картинами
        Question newQuestion = GetObject.getUniqueRandomObject(settings.getJsonArt(),
                settings.getPathUsersArt(), "q", userId);

// определяем значение полей объекта
        String gameId = GetFieldValue.getFieldValue(newQuestion, "id");
        String gameQuestion = GetFieldValue.getFieldValue(newQuestion, "question");
        String[] randomArrayGameOptions = GetFieldValue.getRandomArrayOptions(newQuestion, "options");
        String randomGameOption1 = randomArrayGameOptions[0];
        String randomGameOption2 = randomArrayGameOptions[1];
        String randomGameOption3 = randomArrayGameOptions[2];

// записываем вопрос в файл
        FileManager.writeToFile(settings.getPathUsersArt(), "q", userId, gameId
                + System.lineSeparator());

// отправляем вопрос-картинку
        execute(Sender.sendPhoto(
                chatId,
                settings.getPathFilesPaint() + gameQuestion));

// прикрепляем к вопросу-картинке клавиатуру с вариантами ответов и функциональными кнопками
        execute(artKeyboard.createKeyboard(chatId,
                randomGameOption1, randomGameOption2, randomGameOption3));
}

и прописала обработку обновлений в методе onUpdateReceived () с вызовом метода отправки вопросов:

@SneakyThrows
    @Override
    public void onUpdateReceived(Update update) {
        if (update.hasMessage() && update.getMessage().hasText()) {
            String messageText = update.getMessage().getText();
            long chatId = UpdateUtil.getChatFromUpdate(update).getId();
            User user = UpdateUtil.getUserFromUpdate(update);

            if (messageText.equals(ART.getButtonType())) {                
                sendArtQuest(update);
            } 

// если это обратный вызов (нажатие кнопок Inline-клавиатуры)     
       } else {
            String callData = update.getCallbackQuery().getData();
            long chatId = UpdateUtil.getChatFromUpdate(update).getId();
            long userId = UpdateUtil.getUserFromUpdate(update).getId();

// если callData есть в списке вариантов ответа на заданный вопрос 
            if (ReceiveOptAnsDesc.receiveOptions(update, settings.getJsonArt(),
                    settings.getPathUsersArt(), "q").contains(callData)) {
// если ответ не соответствует правильному ответу 
                if (!callData.equals(ReceiveOptAnsDesc.receiveAnswer(update, settings.getJsonArt(),
                        settings.getPathUsersArt(), "q"))) {

// записать в файл пользователя, где ведется статистика по ответам, маркер w, указывающий на неверность ответа
                    FileManager.writeToFile(settings.getPathUsersArt(), "a", userId, "w"
                            + System.lineSeparator());

// выдать пользователю результат, где предложить либо выбрать другой ответ, либо выдать новую картину
                    execute(functionalGameKeyboard.createKeyboard(chatId, FAIL + callData + ELSE,
                            "Следующая картина", ART_NEXT));

// в противном случае (ответ по верный), записываем в файл маркер ответа 1 
//и выдаем пользователю сообщение с результатом и следом – новый вопрос
                } else {
                    FileManager.writeToFile(settings.getPathUsersArt(), "a", userId, 1
                            + System.lineSeparator());
// выдаем пользователю сообщение с результатом и описанием
                    execute(Sender.sendMessage(chatId, WIN +
                            ReceiveOptAnsDesc.receiveDescription(update, settings.getJsonArt(),
                                    settings.getPathUsersArt(), "q")));
// и сразу же отправляем новый вопрос по художникам
                    sendArtQuest(update);
                }

            } else if (callData.equals(ART_NEXT)) {
                sendArtQuest(update);
            } 
        } 

Ведение статистики правильных ответов пользователя я также реализовала через создание файла .txt, где в названии используется id пользователя и маркер («а», указывающий, что это файл для ответов). Если ответ правильный — в файл записывается цифра 1, если неправильный — срочная буква «w». При запросе статистики подсчитывается количество строк с цифрами и делится на общее количество строк в этом файле.

public class Counter {

    @SneakyThrows
    public static List<String> createUserLinesAnswers(String pathUserAnswerFile, String mark, long userId) {
        return Files.readAllLines(FileManager.getPathUserFile(pathUserAnswerFile, mark, userId));
    }

    public static long countRightUserAnswer(String pathUserAnswerFile, String mark, long userId) {
        return createUserLinesAnswers(pathUserAnswerFile, mark, userId).stream()
                .map(str -> str.charAt(0))
                .filter(Character::isDigit)
                .count();
    }

    public static long countAllUserAnswer(String pathUserAnswerFile, String mark, long userId) {
        return createUserLinesAnswers(pathUserAnswerFile, mark, userId).stream()
                .map(str -> str.charAt(0))
                .count();
    }

    public static double statistic (String pathUserAnswerFile, String mark, long userId) {
        return (((double) countRightUserAnswer(pathUserAnswerFile, mark, userId) /
                countAllUserAnswer(pathUserAnswerFile, mark, userId)) * 100);
    }
}

При необходимости, можно обнулить статистику, для чего прописала метод:

@SneakyThrows
    public static void cleanFile(String pathUserFile, String mark, Long userId) {
        FileChannel.open(
                getPathUserFile(pathUserFile, mark, userId),
                StandardOpenOption.WRITE).truncate(0).close();
    }

Итоги

Вот такой получился «велосипед». Возможно, опытные разработчики по примеру моей давней школьной учительницы математики поставят мне «неуд» за сам факт нерационального решения, но этот «велосипед» едет и, возможно, пользователь даже не замечает неровностей от его квадратных колес.

Часто меня спрашивают — зачем я так подробно в своих статьях описываю те или иные процессы? Держала бы все при себе, пусть другие новички и дилетанты самостоятельно набивают шишки и мастерят свои поделки, авось бросят эти тщетные занятия и не будут покушаться на святая‑святых — it‑сферу. Но ведь я при написании своих pet‑projects использую разъяснения альтруистов с различных ресурсов, так почему же не поделиться в ответ? — может быть кому‑то мои варианты решений и пригодятся. А в «святая‑святых», уверена, для каждого найдется место и своя ниша.

Поэтому надеюсь, этот разбор проекта, полный код которого выложен на GitHub, кому‑нибудь поможет при разработке своих программ. Ну а если гуру java сочтут возможным подсказать более рациональные и «взрослые» пути решения, я буду им очень признательна!

Комментарии (0)