Оптимизируй код моего дискорд-бота на Kotlin. Раздели события Google Play от событий языковых файлов модов Minecraft. Также сделай, чтобы отправка сообщений о них производилась в разные каналы, а не в один и тот же. Также сделай кэш сообщений, чтобы бот не отправлял каждый раз одни и те же сообщения при запуске, а проверял на наличие уже отправленных.

Bot.kt

@file:Suppress("UNUSED_VARIABLE")

package ru.deflecta.auroomy

import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import net.dv8tion.jda.api.JDABuilder
import net.dv8tion.jda.api.entities.channel.concrete.NewsChannel
import net.dv8tion.jda.api.events.session.ReadyEvent
import net.dv8tion.jda.api.hooks.ListenerAdapter
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import net.dv8tion.jda.api.utils.FileUpload
import java.io.*
import java.util.zip.ZipInputStream
import kotlin.system.exitProcess
import java.util.Timer
import java.util.TimerTask

fun main() {
    val configContent = File("config.json").readText()
    val config = Json { ignoreUnknownKeys = true }.decodeFromString<Config>(configContent)

    val jda = JDABuilder.createDefault(config.discordToken)
        .addEventListeners(BotListener(config))
        .build()
}

class BotListener(private val config: Config) : ListenerAdapter() {
    private val timer = Timer()
    private val publisher = GooglePlayPublisher()
    private val client = HttpClient(CIO) {
        install(ContentNegotiation) {
            json(Json { ignoreUnknownKeys = true })
        }
    }
    private val langFiles = mutableMapOf<String, ByteArray>()
    private val mutex = Mutex()

    override fun onReady(event: ReadyEvent) {
        println("Бот запущен!")

        val channel = event.jda.getNewsChannelById(config.channelId)
            ?: run {
                println("Канал с ID ${config.channelId} не найден!")
                exitProcess(1)
            }

        // Инициализация: загрузка текущих языковых файлов модов
        CoroutineScope(Dispatchers.IO).launch {
            for (mod in config.mods) {
                val languageFile = getLanguageFile(mod, client, config.curseForgeApiKey)
                if (languageFile != null) {
                    mutex.withLock {
                        langFiles[mod.name] = languageFile
                    }
                    // Отправка начального языкового файла
                    withContext(Dispatchers.IO) {
                        sendLanguageFile(channel, "Начальное состояние языкового файла для мода **${mod.name}**.", languageFile, "${mod.name}_ru.json")
                    }
                } else {
                    withContext(Dispatchers.IO) {
                        channel.sendMessage("Не удалось найти языковой файл для мода **${mod.name}**.").queue()
                    }
                }
            }
        }

        // Периодическая задача проверки обновлений
        timer.scheduleAtFixedRate(object : TimerTask() {
            override fun run() {
                checkForUpdates(channel)
            }
        }, 0, 5 * 60 * 1000) // Каждые 5 минут
    }

    private suspend fun sendLanguageFile(channel: NewsChannel, content: String, fileBytes: ByteArray, fileName: String) {
        val fileUpload = FileUpload.fromData(fileBytes, fileName)
        channel.sendMessage(content)
            .addFiles(fileUpload)
            .queue()
    }

    private fun checkForUpdates(channel: NewsChannel?) {
        if (channel == null) {
            println("Канал не найден!")
            return
        }

        // Проверка обновлений Google Play
        val latestRelease = publisher.getLatestRelease()
        latestRelease?.let { release ->
            val messageId = ReleaseCache.getMessageId(release.versionCode)
            if (messageId == null) {
                // Новая версия - отправляем сообщение
                channel.sendMessageEmbeds(buildEmbed(release))
                    .setActionRow(
                        net.dv8tion.jda.api.interactions.components.buttons.Button.link(
                            "https://play.google.com/store/apps/details?id=ru.deflecta.fnwb",
                            "Скачать"
                        )
                    )
                    .queue { message ->
                        // Сохраняем ID сообщения для будущего обновления
                        ReleaseCache.saveMessageId(release.versionCode, message.id)
                    }
            } else {
                // Проверяем, изменился ли changelog
                val cachedChangelog = ReleaseCache.getChangelog(release.versionCode)
                if (cachedChangelog != release.releaseNotes) {
                    // Обновляем сообщение
                    channel.editMessageEmbedsById(messageId, buildEmbed(release)).queue()
                    // Обновляем кеш
                    ReleaseCache.updateChangelog(release.versionCode, release.releaseNotes)
                }
            }
        }

        // Проверка обновлений модов
        CoroutineScope(Dispatchers.IO).launch {
            for (mod in config.mods) {
                try {
                    val newLangFile = getLanguageFile(mod, client, config.curseForgeApiKey)
                    if (newLangFile != null) {
                        var shouldNotify = false
                        var previousFile: ByteArray? = null
                        mutex.withLock {
                            previousFile = langFiles[mod.name]
                            if (previousFile == null || !previousFile!!.contentEquals(newLangFile)) {
                                langFiles[mod.name] = newLangFile
                                shouldNotify = true
                            }
                        }
                        if (shouldNotify) {
                            withContext(Dispatchers.IO) {
                                sendLanguageFile(channel, "Обновление языкового файла для мода **${mod.name}**.", newLangFile, "${mod.name}_ru.json")
                            }
                        }
                    }
                } catch (e: Exception) {
                    println("Ошибка при проверке мода ${mod.name}: ${e.message}")
                }
            }
        }
    }

    private fun buildEmbed(release: ReleaseInfo): net.dv8tion.jda.api.entities.MessageEmbed {
        val releaseType = when (release.releaseType) {
            "internal" -> "Внутреннее тестирование"
            "alpha" -> "Альфа"
            "beta" -> "Бета"
            "production" -> "Релиз"
            else -> release.releaseType
        }
        val embedBuilder = net.dv8tion.jda.api.EmbedBuilder()
            .setTitle("Вышла новая версия для тестировщиков")
            .setDescription("Протестируйте её!")
            .addField("Версия", release.versionName, true)
            .addField("Тип выпуска", releaseType, true)
            .addField("Список изменений", release.releaseNotes, false)
            .setThumbnail("https://lh3.googleusercontent.com/g-SDH_bNyQg5IZgsO0Ft1enmz72Cm-e94nOb-wLu3JtSISKmQk-sO_2atrv8K8TAkg")
            .setColor(0x50FA7B)
        return embedBuilder.build()
    }

    private suspend fun getLanguageFile(mod: ModConfig, client: HttpClient, apiKey: String): ByteArray? {
        // Попытка найти мод на Modrinth по ID
        if (!mod.modrinthId.isNullOrBlank()) {
            val modrinthFile = getModrinthLanguageFile(mod.modrinthId, client)
            if (modrinthFile != null) return modrinthFile
        }

        // Если не нашли на Modrinth, ищем на CurseForge по ID
        if (!mod.curseForgeId.isNullOrBlank()) {
            val curseForgeFile = getCurseForgeLanguageFile(mod.curseForgeId, client, apiKey)
            if (curseForgeFile != null) return curseForgeFile
        }

        // Поиск по названию на Modrinth
        val modrinthFileByName = searchModrinthByName(mod.name, client)
        if (modrinthFileByName != null) return modrinthFileByName

        // Поиск по названию на CurseForge
        val curseForgeFileByName = searchCurseForgeByName(mod.name, client, apiKey)
        if (curseForgeFileByName != null) return curseForgeFileByName

        return null
    }

    private suspend fun getModrinthLanguageFile(modrinthId: String, client: HttpClient): ByteArray? {
        // Получение версий мода
        val versions: List<ModrinthVersion> = client.get("https://api.modrinth.com/v2/project/$modrinthId/version").body()
        if (versions.isEmpty()) return null

        // Предполагаем, что первая версия самая новая
        val latestVersion = versions.first()
        val fileInfo = latestVersion.files.find { it.primary } ?: latestVersion.files.firstOrNull() ?: return null

        // Скачивание JAR-файла
        val jarBytes: ByteArray = client.get(fileInfo.url).body()

        // Извлечение языкового файла
        return extractLanguageFile(jarBytes)
    }

    private suspend fun searchModrinthByName(name: String, client: HttpClient): ByteArray? {
        val searchResults: List<ModrinthProject> = client.get("https://api.modrinth.com/v2/search") {
            parameter("query", name)
            parameter("limit", 1)
            parameter("index", "projects")
        }.body()

        if (searchResults.isEmpty()) return null

        return getModrinthLanguageFile(searchResults[0].id, client)
    }

    private suspend fun getCurseForgeLanguageFile(curseForgeId: String, client: HttpClient, apiKey: String): ByteArray? {
        // Получение списка файлов мода
        val filesResponse: CurseForgeFileResponse = client.get("https://api.curseforge.com/v1/mods/$curseForgeId/files") {
            header("x-api-key", apiKey)
        }.body()

        if (filesResponse.data.isEmpty()) return null

        // Предполагаем, что первый файл самый последний
        val downloadUrl = filesResponse.data.first().downloadUrl
        if (downloadUrl.isBlank()) return null

        // Скачивание JAR-файла
        val jarBytes: ByteArray = client.get(downloadUrl).body()

        // Извлечение языкового файла
        return extractLanguageFile(jarBytes)
    }

    private suspend fun searchCurseForgeByName(name: String, client: HttpClient, apiKey: String): ByteArray? {
        val searchResponse: CurseForgeSearchResponse = client.get("https://api.curseforge.com/v1/mods/search") {
            header("x-api-key", apiKey)
            parameter("gameId", 432) // ID игры Minecraft
            parameter("searchFilter", name)
            parameter("pageSize", 1)
        }.body()

        if (searchResponse.data.isEmpty()) return null

        val modId = searchResponse.data.first().id
        return getCurseForgeLanguageFile(modId.toString(), client, apiKey)
    }

    private fun extractLanguageFile(jarBytes: ByteArray): ByteArray? {
        val jarInputStream = ZipInputStream(jarBytes.inputStream())
        var entry = jarInputStream.nextEntry
        while (entry != null) {
            val name = entry.name.lowercase()
            if (name.matches(Regex(""".*/lang/(ru_ru\.json|ru_ru\.lang|ru_ru\.properties)$"""))) {
                val baos = ByteArrayOutputStream()
                jarInputStream.copyTo(baos)
                jarInputStream.closeEntry()
                return baos.toByteArray()
            }
            entry = jarInputStream.nextEntry
        }
        jarInputStream.close()
        return null
    }
}

GooglePlayPublisher.kt

package ru.deflecta.auroomy

import com.google.api.client.googleapis.auth.oauth2.GoogleCredential
import com.google.api.services.androidpublisher.AndroidPublisher

class GooglePlayPublisher {
    private val applicationName = "Auroomy"
    private val packageName = "ru.deflecta.fnwb"

    private val androidPublisher: AndroidPublisher

    init {
        // Загрузите учетные данные
        val credentialsStream = this::class.java.getResourceAsStream("/service_account.json")
        val credential = GoogleCredential.fromStream(credentialsStream)
            .createScoped(listOf("https://www.googleapis.com/auth/androidpublisher"))

        androidPublisher = AndroidPublisher.Builder(
            com.google.api.client.http.javanet.NetHttpTransport(),
            com.google.api.client.json.jackson2.JacksonFactory.getDefaultInstance(),
            credential
        )
            .setApplicationName(applicationName)
            .build()
    }

    fun getLatestRelease(): ReleaseInfo? {
        val tracks = androidPublisher.Edits().tracks()
        val edit = androidPublisher.edits().insert(packageName, null).execute()
        val trackResult = tracks.list(packageName, edit.id).execute()

        val allReleases = trackResult.tracks.flatMap { track ->
            track.releases?.map { release ->
                ReleaseInfo(
                    versionCode = release.versionCodes?.maxOrNull() ?: 0,
                    versionName = release.name ?: "Unknown",
                    releaseNotes = release.releaseNotes
                        ?.filter { it.language == "ru-RU" } // Фильтруем заметки по языковому коду
                        ?.joinToString("\n") { it.text } ?: "",
                    releaseType = track.track
                )
            } ?: emptyList()
        }
// Получаем релиз с максимальным versionCode
        return allReleases.maxByOrNull { it.versionCode }
    }
}

data class ReleaseInfo(
    val versionCode: Long,
    val versionName: String,
    val releaseNotes: String,
    val releaseType: String
)

Models.kt

package ru.deflecta.auroomy

import kotlinx.serialization.Serializable

@Serializable
data class Config(
    val discordToken: String,
    val channelId: String,
    val curseForgeApiKey: String,
    val mods: List<ModConfig>
)

@Serializable
data class ModConfig(
    val modrinthId: String? = null,
    val curseForgeId: String? = null,
    val name: String
)

@Serializable
data class ModrinthProject(
    val id: String,
    val slug: String,
    val name: String
)

@Serializable
data class ModrinthVersion(
    val id: String,
    val files: List<ModrinthFile>
)

@Serializable
data class ModrinthFile(
    val url: String,
    val primary: Boolean
)

@Serializable
data class CurseForgeFileResponse(
    val data: List<CurseForgeFileData>
)

@Serializable
data class CurseForgeFileData(
    val downloadUrl: String
)

@Serializable
data class CurseForgeSearchResponse(
    val data: List<CurseForgeProject>
)

@Serializable
data class CurseForgeProject(
    val id: Int,
    val name: String
)

ReleaseCache.kt

package ru.deflecta.auroomy

object ReleaseCache {
    private val versionMessageMap = mutableMapOf<Long, String>()
    private val versionChangelogMap = mutableMapOf<Long, String>()

    fun getMessageId(versionCode: Long): String? {
        return versionMessageMap[versionCode]
    }

    fun saveMessageId(versionCode: Long, messageId: String) {
        versionMessageMap[versionCode] = messageId
    }

    fun getChangelog(versionCode: Long): String? {
        return versionChangelogMap[versionCode]
    }

    fun updateChangelog(versionCode: Long, changelog: String) {
        versionChangelogMap[versionCode] = changelog
    }
}