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

KTB-21 Провести миграцию на Kotlinx.Serialization

📝 Описание

Необходимо ознакомиться с библиотекой Kotlinx.Serialization для работы с JSON в языке Kotlin. Это мощный инструмент для сериализации и десериализации данных, который позволяет эффективно и удобно работать с JSON в Kotlin и Android приложениях. Библиотека поддерживает различные форматы данных и может использоваться для преобразования объектов в JSON и обратно.

В рамках проекта необходимо внедрить Kotlinx.Serialization для обработки JSON-ответов от Telegram API.

// видео


🗒 Задание

  1. Имплементировать библиотеку Kotlinx.Serialization для работы с JSON в проекте. Если используешь DSL для плагинов в Gradle, то достаточно добавить только строку с плагином.
  2. Провести миграцию бота на использование Kotlinx.Serialization, заменив существующие методы обработки JSON.
  3. Добавить к вариантам ответа кнопку для выхода в меню

❌ Ошибки

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

Довольно часто json остается внутри Telegram.kt. Но правильнее будет весь парсинг делать внутри TelegramBotService. Вот варианты комментариев:

Давай `json` сделаем полем внутри `TelegramBotService()` и не будем передавать через методы. Ведь парсинг `json`-а это по сути задача сервиса, нам не важно в каком виде приходят данные от Telegram API.
Давай делать `json.decodeFromString(responseString)` внутри `TelegramBotService`, пусть `getUpdates()` сразу отдает `Response`.
Этот `json` можно перенести в `TelegramBotService` и не прокидывать в его методы при каждом вызове `json`. То есть убрать параметр `json` из `sendMenu` и т.д.

Часто кнопки размещают в строку, тогда они не вмещаются. Или не делают выход в меню:

Попробуй добавить слово длиннее 10 символов, например "Трансформация". У Telegram есть проблема с кнопками - невмещающийся контент сокращается. Давай разместим в столбик. 
Не понятно, как выйти из режима обучения, если только не через `/start`. Давай добавим кнопку "В меню".
- Все методы, которые связаны с работой API телеграма, давай вынесем в класс `TelegramBotService`: `getUpdates`, `sendMessage`, `getResponse`, `sendQuestion`, `sendMenu`.
- Токен будем передавать в конструктор `TelegramBotService`. Парсить ответы будем внутри `TelegramBotService`, наружу передаем только строки и экземпляры классов. 
- `Json` не передаем в методы `TelegramBotService`, пользуемся json, созданным внутри `TelegramBotService`. 
- `Update`, `Response`, `Message`, `Chat`, `SendMessageRequest`, `ReplyMarkup`, `InlineKeyboard`, `CallbackQuery` - относятся к выполнению запросов к API телеграмма. Они помогают нам как парсить ответы от сервера, так и отправлять запросы. Их тоже следует вынести в `src/main/kotlin/LearnWordsTrainer/TelegramBotService.kt`.

Иногда поля делаются нулябельными без надобности. Например:

fun sendQuestion(chatId: Long, question: Question?): String {
    val urlSendMessage = "$TELEGRAM_BASE_URL/bot$botToken/sendMessage"

    // KTB-21
    val requestBody = SendMessageRequest(
        chatId = chatId,
        text = question!!.correctAnswer.original,
        replyMarkup = ReplyMarkup(
            question!!.variants.mapIndexed { index, word ->
                listOf(InlineKeyboard(text = word.translate, callbackData = "$CALLBACK_DATA_ANSWER_PREFIX$index"))
            }
            + listOf(
            // ...
        )
    )
    // ...
}

Коментарий может быть таким:

Старайся избегать использования `!!`.

Давай подумаем, почему у нас `variants` может быть нулябельным? Потому что `question` передается нулябельным. А можно сделать его не нулябельным? Да, ведь при нулевом `question` весь метод не имеет смысла. Давай уберем нулябельность и сопутствующие операторы, а проверку на нуль делегируем коду выше, который вызывает `sendQuestion()`.

⌨️ Решение

@Serializable
data class Update(
    @SerialName("update_id")
    val updateId: Long,
    val message: Message? = null,
    @SerialName("callback_query")
    val callbackQuery: CallbackQuery? = null,
)

@Serializable
data class Response(
    val result: List<Update>,
)

@Serializable
data class Message(
    val text: String,
    val chat: Chat,
)

@Serializable
data class CallbackQuery(
    val data: String? = null,
    val message: Message? = null,
)

@Serializable
data class Chat(
    val id: Long,
)

@Serializable
data class SendMessageRequest(
    @SerialName("chat_id")
    val chatId: Long,
    val text: String,
    @SerialName("reply_markup")
    val replyMarkup: ReplyMarkup? = null,
)

@Serializable
data class ReplyMarkup(
    @SerialName("inline_keyboard")
    val inlineKeyboard: List<List<InlineKeyboard>>,
)

@Serializable
data class InlineKeyboard(
    @SerialName("callback_data")
    val callbackData: String,
    val text: String,
)
fun main(args: Array<String>) {

    val botToken = args[0]
    var updateId = 0
    val telegramBotService = TelegramBotService(botToken)
    val trainer = LearnWordsTrainer()

    while (true) {
        Thread.sleep(2000)

        // KTB-21
        val response: Response = telegramBotService.getUpdates(updateId)
        val firstUpdate = response.result.firstOrNull() ?: continue
        updateId = (firstUpdate.updateId + 1).toInt()
        println(firstUpdate)

        /* Убрано в KTB-21
            //  ?: continue обязательно, чтобы пропускать, если нет updateId. тк должен присутствовать в каждом ответе
            val updateIdInt = regexFindUpdateId.find(updates)?.groups?.get(1)?.value?.toIntOrNull() ?: continue
            val text = regexFindText.find(updates)?.groups?.get(1)?.value
            //  ?: continue обязательно аналогично
            val chatId = regexFindChatId.find(updates)?.groups?.get(1)?.value?.toIntOrNull() ?: continue
            val data = regexFindData.find(updates)?.groups?.get(1)?.value
            updateId = updateIdInt + 1
        */

        // KTB-21
        val text = firstUpdate.message?.text
        val chatId = firstUpdate.message?.chat?.id
            ?: firstUpdate.callbackQuery?.message?.chat?.id
            ?: continue
        val data = firstUpdate.callbackQuery?.data

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

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

/* Перенесено в отдельный класс TelegramBotService в KTB-15
fun getUpdates(botToken: String, updateId: Int): String {
    val urlGetUpdates = "https://api.telegram.org/bot$botToken/getUpdates?offset=$updateId"
    val client: HttpClient = HttpClient.newHttpClient()
    val httpRequest: HttpRequest = HttpRequest.newBuilder().uri(URI.create(urlGetUpdates)).build()
    val response: HttpResponse<String> = client.send(httpRequest, HttpResponse.BodyHandlers.ofString())
    return response.body()
}
*/
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse

const val TELEGRAM_BASE_URL = "https://api.telegram.org"
const val CLICKED_LEARN_WORDS: String = "learn_words_clicked"
const val CLICKED_STATISTICS: String = "statistics_clicked"
const val CALLBACK_DATA_ANSWER_PREFIX = "answer_"
const val MENU_CLICKED = "menu_clicked"

class TelegramBotService(
    private val botToken: String,
) {
    private val client: HttpClient = HttpClient.newHttpClient()
    private val json = Json {
        ignoreUnknownKeys = true
    }

    fun getUpdates(updateId: Int): Response {
        val urlGetUpdates = "$TELEGRAM_BASE_URL/bot$botToken/getUpdates?offset=$updateId"
        val client: HttpClient = HttpClient.newHttpClient()
        val httpRequest: HttpRequest = HttpRequest.newBuilder().uri(URI.create(urlGetUpdates)).build()
        val response: HttpResponse<String> = client.send(httpRequest, HttpResponse.BodyHandlers.ofString())
        val responseString = response.body()
        return json.decodeFromString(responseString)
    }

    fun sendMessage(chatId: Long, text: String): String {

        /* Query заменено на объект в KTB-21
        // энкодинг нужен, потому что параметр передается через query GET-запроса
        val encodedText = URLEncoder.encode(text, StandardCharsets.UTF_8.toString())
        val urlSendMessage = "$TELEGRAM_BASE_URL/bot$botToken/sendMessage?chat_id=$chatId&text=$encodedText"
        val httpRequest: HttpRequest = HttpRequest.newBuilder().uri(URI.create(urlSendMessage)).build()

            /* Рефакторинг в KTB-16 (общий клиент и возврат body у метода)
            val client: HttpClient = HttpClient.newHttpClient()
            client.send(httpRequest, HttpResponse.BodyHandlers.ofString())
            */

        val response = client.send(httpRequest, HttpResponse.BodyHandlers.ofString())
        return response.body()
        */

        // KTB-21
        val urlSendMessage = "$TELEGRAM_BASE_URL/bot$botToken/sendMessage"
        val requestBody = SendMessageRequest(
            chatId = chatId,
            text = text,
        )
        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 sendMenu(chatId: Long): String {
        val urlSendMessage = "$TELEGRAM_BASE_URL/bot$botToken/sendMessage"
        /* Заменено на объекты в KTB-21
        val sendMenuBody = """
            {
                "chat_id": $chatId,
                "text": "Основное меню",
                "reply_markup": {
                    "inline_keyboard": [
                        [
                            {
                                "text": "Learn words",
                                "callback_data": "$CLICKED_LEARN_WORDS"
                            },
                            {
                                "text": "Statistics",
                                "callback_data": "$CLICKED_STATISTICS"
                            }
                        ]
                    ]
                }
            }
        """.trimIndent()
        */

        // KTB-21
        val requestBody = SendMessageRequest(
            chatId = chatId,
            text = "Основное меню",
            replyMarkup = ReplyMarkup(
                listOf(
                    listOf(
                        InlineKeyboard(text = "Изучать слова", callbackData = CLICKED_LEARN_WORDS),
                        InlineKeyboard(text = "Статистика", callbackData = CLICKED_STATISTICS),
                    )
                )
            )
        )
        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 sendQuestion(chatId: Long, question: Question): String {
        val urlSendMessage = "$TELEGRAM_BASE_URL/bot$botToken/sendMessage"
        /* Заменено на объекты в KTB-21
        val keyboardLayout = question.variants
            .mapIndexed { index: Int, word: Word -> "{ \"text\": \"${word.translate}\", \"callback_data\": \"$CALLBACK_DATA_ANSWER_PREFIX$index\" }" }
            .joinToString(separator = ",")

        val sendMenuBody = """
        {
          "chat_id": $chatId,
          "text": "${question.correctAnswer.original}",
          "reply_markup": {
            "inline_keyboard": [[ $keyboardLayout ]]
          }
        }
        """.trimIndent()
        */

        // KTB-21
        val requestBody = SendMessageRequest(
            chatId = chatId,
            text = question.correctAnswer.original,
            replyMarkup = ReplyMarkup(
                question.variants.mapIndexed { index, word ->
                    listOf(InlineKeyboard(text = word.translate, callbackData = "$CALLBACK_DATA_ANSWER_PREFIX$index"))
                }
                + listOf(
                    listOf(
                        InlineKeyboard(
                            text = "Меню",
                            callbackData = MENU_CLICKED
                        )
                    )
                )
            )
        )

        val requestBodyString = json.encodeToString(requestBody)
        val request = HttpRequest.newBuilder(URI.create(urlSendMessage))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(requestBodyString))
            .build()
        val response = client.send(request, HttpResponse.BodyHandlers.ofString())
        return response.body()
    }

}