Перейти к содержанию

KTB-22 Реализовать многопользовательский режим

📝 Описание

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

Такое недопонимание может привести к существенному увеличению затрат на разработку проекта и дополнительным временным издержкам.

Реализация индивидуального хранения статистики

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

🗒 Задание

  1. Реализовать многопользовательский режим.
  2. Добавить возможность сброса статистики для каждого пользователя.

❌ Ошибки

✏️ Комментарии

Если для методов с большим количеством параметров пишут их в одну строку. Например:

fun handleUpdate( update: Update, telegramBotService: TelegramBotService, trainers: HashMap<Long, LearnWordsTrainer>) {
// ...

Рекомендуем:

Давай если больше одного параметра будем ставить переносы. Так структурнее и глазу проще ориентироваться:

fun handleUpdate(
    update: Update,
    telegramBotService: TelegramBotService,
    trainers: HashMap<Long, LearnWordsTrainer>,
) {

Еще часто бывают споры насчет “концевой запятой” (это та которая после последнего параметра), я рекомендую ее ставить.

Редко, но бывает что handleUpdate() размещают внутри main() :

В целом, в продакшене крайне редко используют локальные функции, они затрудняют чтения кода основной функции (в которой используются).
Давай вынесем ее за пределы `main()`

Иногда не понимают сразу и создают LearnWordsTrainer() не передав туда новое имя файла. Например так:

val trainer = trainers.getOrPut(chatId) { LearnWordsTrainer() } // !!! Ошибка

Рекомендуем:

Тут ты не привязываешь `LearnWordsTrainer()` к конкретному пользователю. Прогресс прохождения один на всех пользователей. Убедится можешь создав в Телеграмме группу и добавить в нее бота. Вроде бы в видео говорится про имя файла и chatId

Иногда в репозиторий попадают файлы словарей, которые генерируются для чатов (имя например 1234512345.txt) , а не только “эталонный” словарь words.txt.

Это "рабочие файлы". То есть, появляются в ходе работы бота для конкретных чатов. Их не стоит класть в репозиторий Давай оставим только words.txt, причем со значениями 0 для `correctAnswersCount`.

⌨️ Решение

import kotlinx.serialization.encodeToString

// ...

const val RESET_CLICKED = "reset_clicked"

class TelegramBotService(
    private val botToken: String,
) {
    private val client: HttpClient = HttpClient.newHttpClient()

      // ...

    fun sendMenu(chatId: Long): String {
        val urlSendMessage = "$TELEGRAM_BASE_URL/bot$botToken/sendMessage"

        val requestBody = SendMessageRequest(
            chatId = chatId,
            text = "Основное меню",
            replyMarkup = ReplyMarkup(
                listOf(
                    listOf(
                        InlineKeyboard(text = "Изучать слова", callbackData = CLICKED_LEARN_WORDS),
                        InlineKeyboard(text = "Статистика", callbackData = CLICKED_STATISTICS),
                    ),
                    listOf(
                        InlineKeyboard(text = "Сбросить прогресс", callbackData = RESET_CLICKED),
                    )
                )
            )
        )
        val requestBodyString = json.encodeToString(requestBody)
        val httpRequest: HttpRequest = HttpRequest.newBuilder().uri(URI.create(urlSendMessage))
            .header("Content-type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(requestBodyString))
            .build()
        val response = client.send(httpRequest, HttpResponse.BodyHandlers.ofString())
        return response.body()
    }

    // ...
}
fun main(args: Array<String>) {

    val botToken = args[0]
    var updateId = 0
    val telegramBotService = TelegramBotService(botToken)
    val trainers = HashMap<Long, LearnWordsTrainer>()

    while (true) {
        Thread.sleep(2000)

        val response: Response = telegramBotService.getUpdates(updateId)
        if (response.result.isEmpty()) continue
        val sortedUpdates = response.result.sortedBy { it.updateId }
        sortedUpdates.forEach {
            handleUpdate(it, telegramBotService, trainers)
        }
        updateId = (sortedUpdates.last().updateId + 1).toInt()
        println(sortedUpdates)
    }
}

fun handleUpdate(
    update: Update,
    telegramBotService: TelegramBotService,
    trainers: HashMap<Long, LearnWordsTrainer>,
) {
    val text = update.message?.text
    val chatId = update.message?.chat?.id
        ?: update.callbackQuery?.message?.chat?.id
        ?: return
    val data = update.callbackQuery?.data

    val trainer = trainers.getOrPut(chatId) { LearnWordsTrainer("$chatId.txt") }

    if (text.equals("/start", true)) {
        telegramBotService.sendMenu(chatId)
    }

    if (data?.equals(CLICKED_STATISTICS) == true) {
        val statistics = trainer.getStatistics()
        val message = "Выучено ${statistics.learnedCount} из ${statistics.totalCount} слов | ${statistics.percent}%"
        telegramBotService.sendMessage(chatId, message)
    }

    if (data?.equals(CLICKED_LEARN_WORDS) == true) {
        checkNextQuestionAndSend(trainer, telegramBotService, chatId)
    }

    if (data?.startsWith(CALLBACK_DATA_ANSWER_PREFIX) == true) {
        val userAnswerIndex = data.substringAfter(CALLBACK_DATA_ANSWER_PREFIX).toInt()
        if (trainer.checkAnswer(userAnswerIndex)) {
            telegramBotService.sendMessage(chatId, "Правильно!")
        } else {
            telegramBotService.sendMessage(
                chatId,
                "Неправильно! ${trainer.question?.correctAnswer?.original} – это ${trainer.question?.correctAnswer?.translate}"
            )
        }
        checkNextQuestionAndSend(trainer, telegramBotService, chatId)
    }

    if (data == MENU_CLICKED) {
        telegramBotService.sendMenu(chatId)
    }

    if (data == RESET_CLICKED) {
        trainer.resetProgress()
        telegramBotService.sendMessage(chatId, "Прогресс сброшен")
    }
}

fun checkNextQuestionAndSend(trainer: LearnWordsTrainer, service: TelegramBotService, chatId: Long) {
    val question = trainer.getNextQuestion()
    if (question == null) {
        service.sendMessage(chatId, "Вы выучили все слова в базе")
    } else {
        service.sendQuestion(chatId, question)
    }
}
import java.io.File

class Statistics(
    val totalCount: Int,
    val learnedCount: Int,
    val percent: Int,
)

data class Question(
    val variants: List<Word>,
    val correctAnswer: Word,
)

data class Word(
    val original: String,
    val translate: String,
    var correctAnswersCount: Int = 0,
)

class LearnWordsTrainer(
    private val fileName: String = "words.txt",
    private val learnedAnswerCount: Int = 3,
    private val countOfQuestionWords: Int = 4,
) {

    var question: Question? = null
    private val dictionary = loadDictionary()

    fun getStatistics(): Statistics {
        val totalCount = dictionary.size
        val learnedCount = dictionary.filter { it.correctAnswersCount >= learnedAnswerCount }.size
        val percent = learnedCount * 100 / totalCount
        return Statistics(totalCount, learnedCount, percent)
    }

    fun getNextQuestion(): Question? {
        val notLearnedList = dictionary.filter { it.correctAnswersCount < learnedAnswerCount }
        if (notLearnedList.isEmpty()) return null

        // KTB-10 bug fix
        val questionWords = if (notLearnedList.size < countOfQuestionWords) {
            val learnedList = dictionary.filter { it.correctAnswersCount >= learnedAnswerCount }.shuffled()
            notLearnedList.shuffled()
                .take(countOfQuestionWords) + learnedList
                .take(countOfQuestionWords - notLearnedList.size)
        } else {
            notLearnedList.shuffled().take(countOfQuestionWords)
        }.shuffled()

        /* Перенесено выше в KTB-10
        val questionWords = notLearnedList.shuffled().take(countOfQuestionWords)
        */

        val correctAnswer = questionWords.random()
        question = Question(
            variants = questionWords,
            correctAnswer = correctAnswer,
        )
        return question
    }

    fun checkAnswer(userAnswerIndex: Int?): Boolean {
        return question?.let {
            val correctAnswerId = it.variants.indexOf(it.correctAnswer)
            if (correctAnswerId == userAnswerIndex) {
                it.correctAnswer.correctAnswersCount++
                saveDictionary()
                true
            } else {
                false
            }
        } ?: false
    }

    private fun loadDictionary(): List<Word> {

        try {
            val dictionary = mutableListOf<Word>()
            val wordsFile = File(fileName)
            if (!wordsFile.exists()) {
                File("words.txt").copyTo(wordsFile)
            }
            for (string in wordsFile.readLines()) {
                val split = string.split("|")
                val word = Word(
                    original = split[0],
                    translate = split[1],
                    correctAnswersCount = split[2].toIntOrNull() ?: 0
                )
                dictionary.add(word)
            }
            return dictionary
        } catch (e: IndexOutOfBoundsException) {
            throw IllegalStateException("Некорректный файл: $e")
        }
    }

    private fun saveDictionary() {
        val wordsFile = File(fileName)
        wordsFile.writeText("")
        for (word in dictionary) {
            wordsFile.appendText("${word.original}|${word.translate}|${word.correctAnswersCount}\n")
        }
    }

    fun resetProgress() {
        dictionary.forEach { it.correctAnswersCount = 0 }
        saveDictionary()
    }

}
// ..

fun main() {

    val trainer = try {
        LearnWordsTrainer(learnedAnswerCount = 3, countOfQuestionWords = 4)
    } catch (e: Exception) {
        println("Невозможно загрузить словарь")
        return
    }

        // ...
}