KTB-21 Провести миграцию на Kotlinx.Serialization¶
📝 Описание¶
Необходимо ознакомиться с библиотекой Kotlinx.Serialization для работы с JSON в языке Kotlin. Это мощный инструмент для сериализации и десериализации данных, который позволяет эффективно и удобно работать с JSON в Kotlin и Android приложениях. Библиотека поддерживает различные форматы данных и может использоваться для преобразования объектов в JSON и обратно.
В рамках проекта необходимо внедрить Kotlinx.Serialization для обработки JSON-ответов от Telegram API.
// видео
🗒 Задание¶
- Имплементировать библиотеку Kotlinx.Serialization для работы с JSON в проекте. Если используешь DSL для плагинов в Gradle, то достаточно добавить только строку с плагином.
- Провести миграцию бота на использование Kotlinx.Serialization, заменив существующие методы обработки JSON.
- Добавить к вариантам ответа кнопку для выхода в меню
❌ Ошибки¶
✏️ Комментарии¶
Довольно часто 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()
}
}