USER
Сделай, чтобы бот при отправке сообщений о том, что у мода обновились языковые файлы, показывал различия между старыми языковыми файлами и новыми. И при первом запуске, когда бот в первый раз отсылает стопку сообщений о последних версиях модов с их последними языковыми файлами, он тоже так же должен первоначально сравнить их языковые файлы с предыдущей версией мода. То есть, возможно он должен будет скачивать две версии мода за раз.
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.entities.MessageEmbed
import net.dv8tion.jda.api.events.session.ReadyEvent
import net.dv8tion.jda.api.hooks.ListenerAdapter
import net.dv8tion.jda.api.utils.FileUpload
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 kotlinx.serialization.encodeToString
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.requests.GatewayIntent
import java.io.*
import java.util.zip.ZipInputStream
import kotlin.system.exitProcess
import java.util.Timer
import java.util.TimerTask
import java.io.File
// Глобальный CoroutineScope
val globalScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun main() {
val configContent = File("config.json").readText()
val config = Json { ignoreUnknownKeys = true }.decodeFromString<Config>(configContent)
val jda = JDABuilder.createDefault(config.discordToken, GatewayIntent.GUILD_MESSAGES, GatewayIntent.MESSAGE_CONTENT)
.addEventListeners(BotListener(config))
.build()
// startApiServer(config)
}
class BotListener(private val config: Config) : ListenerAdapter() {
private val googlePlayTimer = Timer()
private val modsTimer = Timer()
private val publisher = GooglePlayPublisher()
private val langFiles = mutableMapOf<String, ModLanguageData>()
private val mutex = Mutex()
private val messageCache = MessageCache()
private lateinit var googlePlayChannel: NewsChannel
private lateinit var modsChannel: NewsChannel
private lateinit var russianFeedChannel: TextChannel
private lateinit var englishFeedChannel: TextChannel
private val imBoTimer = Timer()
// Создаем сет для хранения ID отправленных постов
private val sentImBoPosts = mutableSetOf<Long>()
private val imBoPostsFile = "imBoposts.json"
override fun onReady(event: ReadyEvent) {
println("Бот запущен!")
// Получаем каналы
googlePlayChannel = event.jda.getNewsChannelById(config.googlePlayChannelId)
?: run {
println("Канал Google Play с ID ${config.googlePlayChannelId} не найден!")
exitProcess(1)
}
modsChannel = event.jda.getNewsChannelById(config.modsChannelId)
?: run {
println("Канал модов с ID ${config.modsChannelId} не найден!")
exitProcess(1)
}
// Инициализация: загрузка текущих языковых файлов модов
globalScope.launch {
for (mod in config.mods) {
try {
val newLanguageData = getLanguageFiles(mod, client, config.curseForgeApiKey)
if (newLanguageData != null) {
var shouldNotify = false
var previousData: ModLanguageData? = null
var ruChanged = false
var enChanged = false
mutex.withLock {
previousData = langFiles[mod.name]
if (previousData == null) {
langFiles[mod.name] = newLanguageData
shouldNotify = true
ruChanged = newLanguageData.ruRu != null
enChanged = newLanguageData.enUs != null
} else {
ruChanged = !newLanguageData.ruRu.contentEquals(previousData!!.ruRu)
enChanged = !newLanguageData.enUs.contentEquals(previousData!!.enUs)
if (ruChanged || enChanged) {
langFiles[mod.name] = newLanguageData
shouldNotify = true
}
}
}
if (shouldNotify) {
val cacheKey = "mod_${mod.name}_${newLanguageData.modVersion}"
val dataForCache =
(newLanguageData.ruRu ?: ByteArray(0)) + (newLanguageData.enUs ?: ByteArray(0))
if (!messageCache.isAlreadySent(cacheKey, dataForCache)) {
withContext(Dispatchers.IO) {
sendLanguageUpdateEmbed(
modsChannel,
mod.name,
newLanguageData,
ruChanged,
enChanged,
cacheKey,
dataForCache
)
}
}
}
}
} catch (e: Exception) {
println("Ошибка при проверке мода ${mod.name}: ${e.message}")
}
}
}
// Остальные таймеры инициализации, если они не зависят от imBo, можно оставить вне корутины
// Периодическая задача проверки обновлений Google Play
googlePlayTimer.scheduleAtFixedRate(
object : TimerTask() {
override fun run() {
checkGooglePlayUpdates()
}
}, 0, 5 * 60 * 1000 // Каждые 5 минут
)
// Периодическая задача проверки обновлений модов
modsTimer.scheduleAtFixedRate(
object : TimerTask() {
override fun run() {
checkModsUpdates()
}
}, 0, 10 * 60 * 1000 // Каждые 10 минут
)
// Инициализируем каналы
russianFeedChannel = event.jda.getTextChannelById(config.russianFeedChannelId)
?: throw IllegalStateException("Канал с ID ${config.russianFeedChannelId} не найден")
englishFeedChannel = event.jda.getTextChannelById(config.englishFeedChannelId)
?: throw IllegalStateException("Канал с ID ${config.englishFeedChannelId} не найден")
// Запускаем корутину
globalScope.launch {
// Загружаем отправленные посты
loadSentImBoPosts()
// Запускаем таймер для проверки новых артов после завершения загрузки
imBoTimer.scheduleAtFixedRate(
object : TimerTask() {
override fun run() {
checkImBoUpdates()
}
},
0,
10 * 60 * 1000 // Каждые 10 минут
)
}
// Регистрация слэш-команды `/tontaro` на уровне гильдии
event.jda.getGuildById("878019629925158932")?.let { guild ->
guild.updateCommands().addCommands(
Commands.slash("tontaro", "Найти случайный рисунок от Tontaro")
// Здесь можно добавить опции, если необходимо
).queue()
}
event.jda.updateCommands().queue()
val guildId = "878019629925158932" // Замените на ID вашей гильдии
event.jda.getGuildById(guildId)?.let { guild ->
guild.updateCommands().addCommands(
Commands.slash("tontaro", "Search random Tontaro art"),
Commands.slash("тонтаро", "Найти случайный арт Тонтаро")
).queue(
{ println("Команды успешно зарегистрированы в гильдии ${guild.name}") },
{ error -> println("Ошибка при регистрации команд: ${error.message}") }
)
} ?: println("Гильдия с ID $guildId не найдена")
}
private suspend fun loadSentImBoPosts() {
val file = File(imBoPostsFile)
if (file.exists()) {
val jsonString = file.readText()
val loadedPosts = Json.decodeFromString<Set<Long>>(jsonString)
sentImBoPosts.addAll(loadedPosts)
println("Загружено ${sentImBoPosts.size} отправленных постов из файла.")
} else {
println("Файл с сохраненными постами не найден. Инициализируем список отправленных постов.")
try {
val recentPosts = fetchRecentImBoPosts()
for (post in recentPosts) {
sentImBoPosts.add(post.id)
}
// Сохраняем инициализированные посты в файл
saveSentImBoPosts()
println("Инициализировано ${sentImBoPosts.size} постов из imBo.")
} catch (e: Exception) {
println("Ошибка при инициализации imBo постов: ${e.message}")
}
}
}
private fun saveSentImBoPosts() {
try {
val jsonString = Json.encodeToString(sentImBoPosts)
File(imBoPostsFile).writeText(jsonString)
println("Сохранено ${sentImBoPosts.size} отправленных постов в файл.")
} catch (e: Exception) {
println("Ошибка при сохранении постов imBo: ${e.message}")
}
}
private suspend fun fetchRecentImBoPosts(): List<ImBoPost> {
return retryIO {
val response: ImBoResponse = client.get("https://imBo.net/posts.json") {
header("User-Agent", config.imBoUserAgent)
parameter("tags", "tontaro -rating:explicit order:id_desc")
parameter("limit", 50)
}.body()
response.posts
}
}
private fun checkImBoUpdates() {
globalScope.launch {
try {
val newPosts = fetchNewImBoPosts()
for (post in newPosts) {
val imageUrl = post.file.url
if (imageUrl != null) {
val embed = net.dv8tion.jda.api.EmbedBuilder()
// .setTitle("Новый арт от Tontaro")
.setImage(imageUrl)
// .setUrl("https://imBo.net/posts/${post.id}")
// .setFooter("Автор: Tontaro")
.setColor(0x50FA7B)
.build()
// Отправляем в русскоязычный канал
russianFeedChannel.sendMessageEmbeds(embed).queue()
// Отправляем в англоязычный канал
englishFeedChannel.sendMessageEmbeds(embed).queue()
// Добавляем ID поста в набор отправленных
sentImBoPosts.add(post.id)
}
}
// После обработки новых постов сохраняем их в файл
saveSentImBoPosts()
} catch (e: Exception) {
println("Ошибка при проверке обновлений imBo: ${e.message}")
}
}
}
private suspend fun fetchNewImBoPosts(): List<ImBoPost> {
val response: ImBoResponse = client.get("https://imBo.net/posts.json") {
header("User-Agent", config.imBoUserAgent)
parameter("tags", "tontaro -rating:explicit order:id_asc")
parameter("limit", 50)
}.body()
// Фильтруем только новые посты
return response.posts.filter { post ->
!sentImBoPosts.contains(post.id) && (post.rating == "s" || post.rating == "q")
}
}
private fun checkGooglePlayUpdates() {
val latestRelease = publisher.getLatestRelease()
latestRelease?.let { release ->
val cacheKey = "googleplay_${release.versionCode}"
val releaseData = release.releaseNotes.toByteArray()
val cachedData = messageCache.getMessageData(cacheKey)
val messageId = messageCache.getMessageId(cacheKey)
if (cachedData == null || messageId == null) {
// Сообщение не было отправлено ранее или нет информации о messageId
sendGooglePlayUpdate(release, cacheKey, releaseData)
} else if (!cachedData.contentEquals(releaseData)) {
// Данные изменились, пытаемся обновить сообщение
val messageExists = checkMessageExists(googlePlayChannel, messageId)
if (messageExists) {
// Обновляем существующее сообщение
googlePlayChannel.editMessageEmbedsById(messageId, buildEmbed(release))
.setActionRow(
net.dv8tion.jda.api.interactions.components.buttons.Button.link(
"https://play.google.com/store/apps/details?id=ru.deflecta.fnwb",
"Скачать"
)
)
.queue()
// Обновляем кеш
messageCache.saveMessage(cacheKey, releaseData, messageId)
} else {
// Сообщение удалено, отправляем заново
sendGooglePlayUpdate(release, cacheKey, releaseData)
}
} else {
// Данные не изменились, но проверим, существует ли сообщение
val messageExists = checkMessageExists(googlePlayChannel, messageId)
if (!messageExists) {
// Сообщение было удалено, отправляем заново
sendGooglePlayUpdate(release, cacheKey, releaseData)
}
}
}
}
private fun sendGooglePlayUpdate(release: ReleaseInfo, cacheKey: String, releaseData: ByteArray) {
googlePlayChannel.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 ->
messageCache.saveMessage(cacheKey, releaseData, message.id)
}
}
private fun checkModsUpdates() {
globalScope.launch {
for (mod in config.mods) {
try {
val newLanguageData = getLanguageFiles(mod, client, config.curseForgeApiKey)
if (newLanguageData != null) {
var shouldNotify = false
var previousData: ModLanguageData? = null
var ruChanged = false
var enChanged = false
mutex.withLock {
previousData = langFiles[mod.name]
if (previousData == null) {
langFiles[mod.name] = newLanguageData
shouldNotify = true
ruChanged = newLanguageData.ruRu != null
enChanged = newLanguageData.enUs != null
} else {
ruChanged = !newLanguageData.ruRu.contentEquals(previousData!!.ruRu)
enChanged = !newLanguageData.enUs.contentEquals(previousData!!.enUs)
if (ruChanged || enChanged) {
langFiles[mod.name] = newLanguageData
shouldNotify = true
}
}
}
if (shouldNotify) {
val cacheKey = "mod_${mod.name}_${newLanguageData.modVersion}"
val dataForCache =
(newLanguageData.ruRu ?: ByteArray(0)) + (newLanguageData.enUs ?: ByteArray(0))
val cachedData = messageCache.getMessageData(cacheKey)
val messageId = messageCache.getMessageId(cacheKey)
if (cachedData == null || messageId == null) {
// Сообщение не было отправлено ранее или нет информации о messageId
sendLanguageUpdateEmbed(
modsChannel,
mod.name,
newLanguageData,
ruChanged,
enChanged,
cacheKey,
dataForCache
)
} else if (!cachedData.contentEquals(dataForCache)) {
// Данные изменились, пытаемся обновить сообщение
val messageExists = checkMessageExists(modsChannel, messageId)
if (messageExists) {
// Обновляем существующее сообщение
val embedBuilder =
buildLanguageEmbed(mod.name, newLanguageData, ruChanged, enChanged)
val attachments = buildAttachments(mod.name, newLanguageData)
modsChannel.editMessageEmbedsById(messageId, embedBuilder.build())
.setFiles(attachments)
.queue()
// Обновляем кеш
messageCache.saveMessage(cacheKey, dataForCache, messageId)
} else {
// Сообщение удалено, отправляем заново
sendLanguageUpdateEmbed(
modsChannel,
mod.name,
newLanguageData,
ruChanged,
enChanged,
cacheKey,
dataForCache
)
}
} else {
// Данные не изменились, проверяем, существует ли сообщение
val messageExists = checkMessageExists(modsChannel, messageId)
if (!messageExists) {
// Сообщение было удалено, отправляем заново
sendLanguageUpdateEmbed(
modsChannel,
mod.name,
newLanguageData,
ruChanged,
enChanged,
cacheKey,
dataForCache
)
}
}
}
}
} catch (e: Exception) {
println("Ошибка при проверке мода ${mod.name}: ${e.message}")
}
}
}
}
private fun buildLanguageEmbed(
modName: String,
languageData: ModLanguageData,
ruChanged: Boolean,
enChanged: Boolean
): net.dv8tion.jda.api.EmbedBuilder {
val embedBuilder = net.dv8tion.jda.api.EmbedBuilder()
.setTitle("Мод $modName обновился!")
.setDescription("Возможно вы захотите обновить свои переводы.")
.addField("Версия мода", languageData.modVersion ?: "Неизвестно", true)
.setColor(0x50FA7B)
val changesDescription = when {
ruChanged && enChanged -> "Изменились оба языковых файла: `ru_ru` и `en_us`."
ruChanged -> "Изменился только `ru_ru`."
enChanged -> "Изменился только `en_us`."
else -> "Нет изменений в языковых файлах."
}
embedBuilder.addField("Какие из файлов изменились", changesDescription, false)
return embedBuilder
}
private fun buildAttachments(modName: String, languageData: ModLanguageData): List<FileUpload> {
val attachments = mutableListOf<FileUpload>()
languageData.ruRu?.let {
attachments.add(FileUpload.fromData(it, "${modName}_ru_ru.json"))
}
languageData.enUs?.let {
attachments.add(FileUpload.fromData(it, "${modName}_en_us.json"))
}
return attachments
}
private fun checkMessageExists(channel: NewsChannel, messageId: String): Boolean {
return try {
val message = channel.retrieveMessageById(messageId).complete()
message != null
} catch (e: Exception) {
// Сообщение не найдено или нет доступа
false
}
}
private fun sendLanguageUpdateEmbed(
channel: NewsChannel,
modName: String,
languageData: ModLanguageData,
ruChanged: Boolean,
enChanged: Boolean,
cacheKey: String,
dataForCache: ByteArray
) {
ensureMessage(
channel = channel,
cacheKey = cacheKey,
dataForCache = dataForCache,
messageCache = messageCache, // Передаем ваш объект messageCache
buildEmbed = {
val embedBuilder = net.dv8tion.jda.api.EmbedBuilder()
.setTitle("Мод $modName обновился!")
.setDescription("Возможно вы захотите обновить свои переводы.")
.addField("Версия мода", languageData.modVersion ?: "Неизвестно", true)
.setColor(0x50FA7B)
val changesDescription = when {
ruChanged && enChanged -> "Изменились оба языковых файла: `ru_ru` и `en_us`."
ruChanged -> "Изменился только `ru_ru`."
enChanged -> "Изменился только `en_us`."
else -> "Нет изменений в языковых файлах."
}
embedBuilder.addField("Какие из файлов изменились", changesDescription, false)
embedBuilder.build()
},
buildAttachments = {
val attachments = mutableListOf<FileUpload>()
languageData.ruRu?.let {
attachments.add(FileUpload.fromData(it, "${modName}_ru_ru.json"))
}
languageData.enUs?.let {
attachments.add(FileUpload.fromData(it, "${modName}_en_us.json"))
}
attachments
}
)
}
private fun buildEmbed(release: ReleaseInfo): 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 getLanguageFiles(mod: ModConfig, client: HttpClient, apiKey: String): ModLanguageData? {
// Попытка найти мод на Modrinth
try {
if (!mod.modrinthId.isNullOrBlank()) {
val modrinthData = getModrinthLanguageFiles(mod.modrinthId, client)
if (modrinthData != null) return modrinthData
} else {
val modrinthDataByName = searchModrinthByName(mod.name, client)
if (modrinthDataByName != null) return modrinthDataByName
}
} catch (e: Exception) {
println("Ошибка при обращении к Modrinth для мода ${mod.name}: ${e.message}")
// Продолжаем к CurseForge
}
// Попытка найти мод на CurseForge
try {
if (!mod.curseForgeId.isNullOrBlank()) {
val curseForgeData = getCurseForgeLanguageFiles(mod.curseForgeId, client, apiKey)
if (curseForgeData != null) return curseForgeData
} else {
val curseForgeDataByName = searchCurseForgeByName(mod.name, client, apiKey)
if (curseForgeDataByName != null) return curseForgeDataByName
}
} catch (e: Exception) {
println("Ошибка при обращении к CurseForge для мода ${mod.name}: ${e.message}")
}
// Если ничего не найдено
return null
}
private suspend fun getModrinthLanguageFiles(modrinthId: String, client: HttpClient): ModLanguageData? {
// Получение версий мода
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()
// Извлечение языковых файлов
val languageData = extractLanguageFiles(jarBytes)
// Создаём новый экземпляр с обновлёнными данными
val updatedLanguageData = languageData.copy(
modVersion = latestVersion.version_number,
releaseDate = latestVersion.date_published
)
return updatedLanguageData
}
private suspend fun searchModrinthByName(name: String, client: HttpClient): ModLanguageData? {
val searchResponse: ModrinthSearchResponse = client.get("https://api.modrinth.com/v2/search") {
parameter("query", name)
parameter("limit", 1)
}.body()
if (searchResponse.hits.isEmpty()) return null
return getModrinthLanguageFiles(searchResponse.hits[0].id, client)
}
private suspend fun getCurseForgeLanguageFiles(
curseForgeId: String,
client: HttpClient,
apiKey: String
): ModLanguageData? {
// Получение списка файлов мода
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 fileData = filesResponse.data.firstOrNull { !it.downloadUrl.isNullOrBlank() } ?: return null
val downloadUrl = fileData.downloadUrl!!
// Скачивание JAR-файла
val jarBytes: ByteArray = client.get(downloadUrl).body()
// Извлечение языковых файлов
val languageData = extractLanguageFiles(jarBytes)
// Создаём новый экземпляр с обновлёнными данными
val updatedLanguageData = languageData.copy(
modVersion = fileData.displayName,
releaseDate = fileData.fileDate
)
return updatedLanguageData
}
private suspend fun searchCurseForgeByName(
name: String,
client: HttpClient,
apiKey: String
): ModLanguageData? {
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 getCurseForgeLanguageFiles(modId.toString(), client, apiKey)
}
private fun extractLanguageFiles(jarBytes: ByteArray): ModLanguageData {
val jarInputStream = ZipInputStream(jarBytes.inputStream())
var ruRuBytes: ByteArray? = null
var enUsBytes: ByteArray? = null
var modVersion: String? = null
var releaseDate: String? = null
var entry = jarInputStream.nextEntry
while (entry != null) {
val name = entry.name.lowercase()
when {
name.matches(Regex(""".*/lang/ru_ru\.(json|lang|properties)$""")) -> {
val baos = ByteArrayOutputStream()
jarInputStream.copyTo(baos)
ruRuBytes = baos.toByteArray()
}
name.matches(Regex(""".*/lang/en_us\.(json|lang|properties)$""")) -> {
val baos = ByteArrayOutputStream()
jarInputStream.copyTo(baos)
enUsBytes = baos.toByteArray()
}
name.matches(Regex(""".*/mods\.toml$""")) -> {
// Извлекаем версию мода из mods.toml
val baos = ByteArrayOutputStream()
jarInputStream.copyTo(baos)
val modsTomlContent = baos.toString("UTF-8")
modVersion = parseModVersionFromModsToml(modsTomlContent)
}
}
jarInputStream.closeEntry()
if (ruRuBytes != null && enUsBytes != null && modVersion != null) {
break // Нашли все необходимое
}
entry = jarInputStream.nextEntry
}
jarInputStream.close()
return ModLanguageData(ruRuBytes, enUsBytes, modVersion, releaseDate)
}
private fun parseModVersionFromModsToml(modsTomlContent: String): String? {
val versionRegex = Regex("""\s*version\s*=\s*"([^"]+)"""")
val matchResult = versionRegex.find(modsTomlContent)
return matchResult?.groups?.get(1)?.value
}
override fun onSlashCommandInteraction(event: SlashCommandInteractionEvent) {
val commandName = event.name
if (commandName == "tontaro" || commandName == "тонтаро") {
// Проверяем ID канала
val channelId = event.channel.id
val allowedChannels = listOf("878021004838969376", "927956548200841286")
if (channelId !in allowedChannels) {
event.reply("Эта команда недоступна в этом канале.").setEphemeral(true).queue()
return
}
// Обработка команды
globalScope.launch {
try {
val post = fetchRandomImBoPost()
if (post != null && post.file.url != null) {
val imageUrl = post.file.url
val embed = net.dv8tion.jda.api.EmbedBuilder()
// .setTitle("Случайный арт от Tontaro")
.setImage(imageUrl)
// .setUrl("https://imBo.net/posts/${post.id}")
// .setFooter("Автор: Tontaro")
.setColor(0x50FA7B)
.build()
event.replyEmbeds(embed).queue()
} else {
event.reply("Не удалось получить арт. Попробуйте позже.").setEphemeral(true).queue()
}
} catch (e: Exception) {
println("Ошибка при выполнении команды /$commandName: ${e.message}")
event.reply("Произошла ошибка. Попробуйте позже.").setEphemeral(true).queue()
}
}
}
}
private suspend fun fetchRandomImBoPost(): ImBoPost? {
val response: ImBoResponse = client.get("https://imBo.net/posts.json") {
header("User-Agent", config.imBoUserAgent)
parameter("tags", "tontaro -rating:explicit order:random")
parameter("limit", 1)
}.body()
return response.posts.firstOrNull()
}
private suspend fun <T> retryIO(
times: Int = 3,
initialDelay: Long = 100,
maxDelay: Long = 1000,
factor: Double = 2.0,
block: suspend () -> T
): T {
var currentDelay = initialDelay
repeat(times - 1) {
try {
return block()
} catch (e: Exception) {
println("Неудачная попытка: ${e.message}")
delay(currentDelay)
currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
}
}
return block()
}
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
// Увеличиваем тайм-ауты
engine {
requestTimeout = 10000 // 10 секунд
}
}
}
// Общие функции
private fun ensureMessage(
channel: NewsChannel,
cacheKey: String,
dataForCache: ByteArray,
messageCache: MessageCache,
buildEmbed: () -> MessageEmbed,
buildAttachments: () -> List<FileUpload> = { emptyList() }
) {
val cachedData = messageCache.getMessageData(cacheKey)
val messageId = messageCache.getMessageId(cacheKey)
if (cachedData == null || messageId == null || !cachedData.contentEquals(dataForCache)) {
sendOrUpdateMessage(channel, cacheKey, dataForCache, messageCache, buildEmbed, buildAttachments)
} else {
if (!doesMessageExist(channel, messageId)) {
sendOrUpdateMessage(channel, cacheKey, dataForCache, messageCache, buildEmbed, buildAttachments)
}
}
}
private fun sendOrUpdateMessage(
channel: NewsChannel,
cacheKey: String,
dataForCache: ByteArray,
messageCache: MessageCache,
buildEmbed: () -> MessageEmbed,
buildAttachments: () -> List<FileUpload>
) {
channel.sendMessageEmbeds(buildEmbed())
.addFiles(buildAttachments())
.queue { message ->
messageCache.saveMessage(cacheKey, dataForCache, message.id)
}
}
private fun doesMessageExist(channel: NewsChannel, messageId: String): Boolean {
return try {
val message = channel.retrieveMessageById(messageId).complete()
message != null
} catch (e: Exception) {
// Сообщение не найдено или ошибка доступа
false
}
}
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
)
MessageCache.kt
package ru.deflecta.auroomy
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import kotlinx.serialization.*
import kotlinx.serialization.json.*
import java.util.Base64
@Serializable
data class CachedMessage(val key: String, val data: String, val messageId: String)
class MessageCache {
private val cacheFile = File("messageCache.json")
private val cacheMap = ConcurrentHashMap<String, CachedMessage>()
init {
if (cacheFile.exists()) {
val jsonString = cacheFile.readText()
val cachedMessages = Json.decodeFromString<List<CachedMessage>>(jsonString)
for (cachedMessage in cachedMessages) {
cacheMap[cachedMessage.key] = cachedMessage
}
}
}
fun isAlreadySent(key: String, data: ByteArray): Boolean {
val cachedMessage = cacheMap[key]
if (cachedMessage == null) return false
val cachedData = Base64.getDecoder().decode(cachedMessage.data)
return cachedData.contentEquals(data)
}
fun saveMessage(key: String, data: ByteArray, messageId: String) {
val dataString = Base64.getEncoder().encodeToString(data)
cacheMap[key] = CachedMessage(key, dataString, messageId)
saveToFile()
}
fun getMessageData(key: String): ByteArray? {
val cachedMessage = cacheMap[key] ?: return null
return Base64.getDecoder().decode(cachedMessage.data)
}
fun getMessageId(key: String): String? {
return cacheMap[key]?.messageId
}
private fun saveToFile() {
val cachedMessages = cacheMap.values.toList()
val jsonString = Json.encodeToString(cachedMessages)
cacheFile.writeText(jsonString)
}
}
Models.kt
package ru.deflecta.auroomy
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Config(
val discordToken: String,
val googlePlayChannelId: String,
val modsChannelId: String,
val curseForgeApiKey: String,
val imBoUserAgent: String,
val russianFeedChannelId: String,
val englishFeedChannelId: String,
val mods: List<ModConfig>
)
@Serializable
data class ModConfig(
val modrinthId: String? = null,
val curseForgeId: String? = null,
val name: String
)
@Serializable
data class ModrinthProject(
@SerialName("project_id") val id: String,
val slug: String,
val title: String
// Добавьте другие поля по необходимости
)
@Serializable
data class ModrinthVersion(
val id: String,
val version_number: String,
val date_published: String, // ISO 8601 format
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?,
val displayName: String,
val fileDate: String // ISO 8601 format
)
@Serializable
data class CurseForgeSearchResponse(
val data: List<CurseForgeProject>
)
@Serializable
data class CurseForgeProject(
val id: Int,
val name: String
)
@Serializable
data class ModrinthSearchResponse(
val hits: List<ModrinthProject>,
val offset: Int,
val limit: Int,
val total_hits: Int
)
@Serializable
data class ModLanguageData(
val ruRu: ByteArray?,
val enUs: ByteArray?,
val modVersion: String? = null,
val releaseDate: String? = null
)
@Serializable
data class ImBoPost(
val id: Long,
val file: ImBoFile,
val tags: ImBoTags,
val rating: String,
val created_at: String // можно добавить при необходимости
)
@Serializable
data class ImBoFile(
val url: String?
)
@Serializable
data class ImBoTags(
val artist: List<String>
)
@Serializable
data class ImBoResponse(
val posts: List<ImBoPost>
)
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
}
}ASSISTANT
Чтобы реализовать функциональность отображения различий между старыми и новыми языковыми файлами модов, необходимо внести несколько изменений в ваш текущий код. Основные шаги включают:
1. **Хранение предыдущих версий языковых файлов.**
2. **Сравнение старых и новых файлов для выявления изменений.**
3. **Форматирование и представление различий в сообщениях Discord.**
4. **Обработка первого запуска бота с загрузкой двух версий модов.**
Ниже подробно описаны необходимые изменения и добавления:
---
## 1. Обновление `ModLanguageData` для хранения парсированных данных
Измените класс `ModLanguageData`, чтобы хранить не только сырые байты, но и парсенные карты ключ-значение для более удобного сравнения.
```kotlin
@Serializable
data class ModLanguageData(
val ruRu: ByteArray?,
val enUs: ByteArray?,
val ruRuMap: Map<String, String>? = null,
val enUsMap: Map<String, String>? = null,
val modVersion: String? = null,
val releaseDate: String? = null
)
```
---
## 2. Парсинг языковых файлов
Добавьте функцию для парсинга языковых файлов из `ByteArray` в `Map<String, String>`. Предполагается, что файлы находятся в формате JSON.
```kotlin
private fun parseLanguageFile(bytes: ByteArray): Map<String, String> {
val jsonString = bytes.toString(Charsets.UTF_8)
return try {
Json.decodeFromString(jsonString)
} catch (e: Exception) {
println("Ошибка при парсинге языкового файла: ${e.message}")
emptyMap()
}
}
```
---
## 3. Функция для вычисления различий между картами
Реализуйте функцию, которая принимает старую и новую карты и возвращает строковое представление различий.
```kotlin
private fun computeDiff(oldMap: Map<String, String>, newMap: Map<String, String>): String {
val added = newMap.keys - oldMap.keys
val removed = oldMap.keys - newMap.keys
val changed = newMap.keys.intersect(oldMap.keys).filter { oldMap[it] != newMap[it] }
val sb = StringBuilder()
if (added.isNotEmpty()) {
sb.append("**Добавленные ключи:**\n")
added.forEach { key ->
sb.append("- `$key`: `${newMap[key]}`\n")
}
}
if (removed.isNotEmpty()) {
sb.append("**Удалённые ключи:**\n")
removed.forEach { key ->
sb.append("- `$key`: `${oldMap[key]}`\n")
}
}
if (changed.isNotEmpty()) {
sb.append("**Изменённые ключи:**\n")
changed.forEach { key ->
sb.append("- `$key`: `${oldMap[key]}` → `${newMap[key]}`\n")
}
}
if (sb.isEmpty()) {
sb.append("Нет различий.")
}
return sb.toString()
}
```
---
## 4. Изменение функции `getLanguageFiles` для возврата парсенных данных
После загрузки языковых файлов извлеките парсенные карты и сохраните их в `ModLanguageData`.
```kotlin
private suspend fun extractLanguageFiles(jarBytes: ByteArray): ModLanguageData {
val jarInputStream = ZipInputStream(jarBytes.inputStream())
var ruRuBytes: ByteArray? = null
var enUsBytes: ByteArray? = null
var modVersion: String? = null
var releaseDate: String? = null
var entry = jarInputStream.nextEntry
while (entry != null) {
val name = entry.name.lowercase()
when {
name.matches(Regex(""".*/lang/ru_ru\.(json|lang|properties)$""")) -> {
val baos = ByteArrayOutputStream()
jarInputStream.copyTo(baos)
ruRuBytes = baos.toByteArray()
}
name.matches(Regex(""".*/lang/en_us\.(json|lang|properties)$""")) -> {
val baos = ByteArrayOutputStream()
jarInputStream.copyTo(baos)
enUsBytes = baos.toByteArray()
}
name.matches(Regex(""".*/mods\.toml$""")) -> {
val baos = ByteArrayOutputStream()
jarInputStream.copyTo(baos)
val modsTomlContent = baos.toString("UTF-8")
modVersion = parseModVersionFromModsToml(modsTomlContent)
}
}
jarInputStream.closeEntry()
if (ruRuBytes != null && enUsBytes != null && modVersion != null) {
break
}
entry = jarInputStream.nextEntry
}
jarInputStream.close()
val ruRuMap = ruRuBytes?.let { parseLanguageFile(it) }
val enUsMap = enUsBytes?.let { parseLanguageFile(it) }
return ModLanguageData(ruRuBytes, enUsBytes, ruRuMap, enUsMap, modVersion, releaseDate)
}
```
---
## 5. Обновление логики проверки и уведомления об обновлениях
Измените функции `onReady` и `checkModsUpdates`, чтобы использовать новые возможности сравнения и отображения различий.
### Изменение в `onReady`:
```kotlin
override fun onReady(event: ReadyEvent) {
println("Бот запущен!")
// Получаем каналы и другой инициализационный код...
globalScope.launch {
for (mod in config.mods) {
try {
val newLanguageData = getLanguageFiles(mod, client, config.curseForgeApiKey)
if (newLanguageData != null) {
var shouldNotify = false
var previousData: ModLanguageData? = null
var ruChanged = false
var enChanged = false
var ruDiff: String? = null
var enDiff: String? = null
mutex.withLock {
previousData = langFiles[mod.name]
if (previousData == null) {
// Первая инициализация: загрузка текущих данных и предыдущей версии
val previousLanguageData = getPreviousLanguageData(mod, client, config.curseForgeApiKey)
if (previousLanguageData != null) {
langFiles[mod.name] = previousLanguageData
langFiles[mod.name] = newLanguageData
shouldNotify = true
ruChanged = newLanguageData.ruRuMap != null
enChanged = newLanguageData.enUsMap != null
// Вычисление различий
if (previousLanguageData.ruRuMap != null && newLanguageData.ruRuMap != null) {
ruDiff = computeDiff(previousLanguageData.ruRuMap, newLanguageData.ruRuMap)
}
if (previousLanguageData.enUsMap != null && newLanguageData.enUsMap != null) {
enDiff = computeDiff(previousLanguageData.enUsMap, newLanguageData.enUsMap)
}
}
} else {
// Обновление существующих данных
ruChanged = newLanguageData.ruRuMap != previousData.ruRuMap
enChanged = newLanguageData.enUsMap != previousData.enUsMap
if (ruChanged || enChanged) {
// Вычисление различий
if (ruChanged && previousData.ruRuMap != null && newLanguageData.ruRuMap != null) {
ruDiff = computeDiff(previousData.ruRuMap, newLanguageData.ruRuMap)
}
if (enChanged && previousData.enUsMap != null && newLanguageData.enUsMap != null) {
enDiff = computeDiff(previousData.enUsMap, newLanguageData.enUsMap)
}
langFiles[mod.name] = newLanguageData
shouldNotify = true
}
}
}
if (shouldNotify && ruDiff != null || enDiff != null) {
val cacheKey = "mod_${mod.name}_${newLanguageData.modVersion}"
val dataForCache =
(newLanguageData.ruRu ?: ByteArray(0)) + (newLanguageData.enUs ?: ByteArray(0))
if (!messageCache.isAlreadySent(cacheKey, dataForCache)) {
withContext(Dispatchers.IO) {
sendLanguageUpdateEmbed(
modsChannel,
mod.name,
newLanguageData,
ruChanged,
enChanged,
ruDiff,
enDiff,
cacheKey,
dataForCache
)
}
}
}
}
} catch (e: Exception) {
println("Ошибка при проверке мода ${mod.name}: ${e.message}")
}
}
}
// Остальная часть функции onReady...
}
```
### Реализация функции `getPreviousLanguageData`:
Эта функция загружает предыдущую версию языковых файлов мода для сравнения.
```kotlin
private suspend fun getPreviousLanguageData(mod: ModConfig, client: HttpClient, apiKey: String): ModLanguageData? {
// Получение всех версий мода
val versions = getAllModVersions(mod, client, apiKey)
if (versions.size < 2) return null // Недостаточно версий для сравнения
val previousVersion = versions[1] // Вторая версия - предыдущая
// Получение языковых файлов для предыдущей версии
return getLanguageFilesForVersion(mod, previousVersion, client, apiKey)
}
```
### Реализация `getAllModVersions` и `getLanguageFilesForVersion`:
Необходимо реализовать функции для получения всех версий мода и загрузки языковых файлов конкретной версии.
```kotlin
private suspend fun getAllModVersions(mod: ModConfig, client: HttpClient, apiKey: String): List<ModrinthVersion> {
// Пример для Modrinth
if (!mod.modrinthId.isNullOrBlank()) {
return client.get("https://api.modrinth.com/v2/project/${mod.modrinthId}/version").body()
}
// Пример для CurseForge
// Реализуйте аналогично для CurseForge, возвращая список версий
return emptyList()
}
private suspend fun getLanguageFilesForVersion(
mod: ModConfig,
version: ModrinthVersion, // Или соответствующий тип для CurseForge
client: HttpClient,
apiKey: String
): ModLanguageData? {
// Логика для получения языковых файлов для конкретной версии
// Аналогична функции getModrinthLanguageFiles или getCurseForgeLanguageFiles
// Возвращает ModLanguageData
return null // Реализуйте по аналогии
}
```
---
## 6. Обновление функции `sendLanguageUpdateEmbed` для включения различий
Измените функцию `sendLanguageUpdateEmbed`, чтобы она принимала строки с различиями и включала их в сообщение.
```kotlin
private fun sendLanguageUpdateEmbed(
channel: NewsChannel,
modName: String,
languageData: ModLanguageData,
ruChanged: Boolean,
enChanged: Boolean,
ruDiff: String?,
enDiff: String?,
cacheKey: String,
dataForCache: ByteArray
) {
val embedBuilder = net.dv8tion.jda.api.EmbedBuilder()
.setTitle("Мод $modName обновился!")
.setDescription("Возможно вы захотите обновить свои переводы.")
.addField("Версия мода", languageData.modVersion ?: "Неизвестно", true)
.setColor(0x50FA7B)
val changesDescription = when {
ruChanged && enChanged -> "Изменились оба языковых файла: `ru_ru` и `en_us`."
ruChanged -> "Изменился только `ru_ru`."
enChanged -> "Изменился только `en_us`."
else -> "Нет изменений в языковых файлах."
}
embedBuilder.addField("Какие из файлов изменились", changesDescription, false)
if (ruChanged && ruDiff != null) {
embedBuilder.addField("Различия в `ru_ru`:", "```diff\n$ruDiff\n```", false)
}
if (enChanged && enDiff != null) {
embedBuilder.addField("Различия в `en_us`:", "```diff\n$enDiff\n```", false)
}
channel.sendMessageEmbeds(embedBuilder.build())
.addFiles(buildAttachments(modName, languageData))
.queue { message ->
messageCache.saveMessage(cacheKey, dataForCache, message.id)
}
}
```
---
## 7. Обновление функции `checkModsUpdates` для использования новой логики
Измените функцию `checkModsUpdates`, аналогично изменениям в `onReady`, чтобы обрабатывать диффы при последующих обновлениях.
```kotlin
private fun checkModsUpdates() {
globalScope.launch {
for (mod in config.mods) {
try {
val newLanguageData = getLanguageFiles(mod, client, config.curseForgeApiKey)
if (newLanguageData != null) {
var shouldNotify = false
var previousData: ModLanguageData? = null
var ruChanged = false
var enChanged = false
var ruDiff: String? = null
var enDiff: String? = null
mutex.withLock {
previousData = langFiles[mod.name]
if (previousData == null) {
// Возможно, пропустить уведомление, если нет предыдущих данных
langFiles[mod.name] = newLanguageData
continue
}
ruChanged = newLanguageData.ruRuMap != previousData.ruRuMap
enChanged = newLanguageData.enUsMap != previousData.enUsMap
if (ruChanged || enChanged) {
if (ruChanged && previousData.ruRuMap != null && newLanguageData.ruRuMap != null) {
ruDiff = computeDiff(previousData.ruRuMap, newLanguageData.ruRuMap)
}
if (enChanged && previousData.enUsMap != null && newLanguageData.enUsMap != null) {
enDiff = computeDiff(previousData.enUsMap, newLanguageData.enUsMap)
}
langFiles[mod.name] = newLanguageData
shouldNotify = true
}
}
if (shouldNotify && (ruDiff != null || enDiff != null)) {
val cacheKey = "mod_${mod.name}_${newLanguageData.modVersion}"
val dataForCache =
(newLanguageData.ruRu ?: ByteArray(0)) + (newLanguageData.enUs ?: ByteArray(0))
val cachedData = messageCache.getMessageData(cacheKey)
val messageId = messageCache.getMessageId(cacheKey)
if (cachedData == null || messageId == null) {
// Сообщение не было отправлено ранее
sendLanguageUpdateEmbed(
modsChannel,
mod.name,
newLanguageData,
ruChanged,
enChanged,
ruDiff,
enDiff,
cacheKey,
dataForCache
)
} else {
// Проверка существования сообщения и обновление
val messageExists = checkMessageExists(modsChannel, messageId)
if (messageExists) {
// Обновляем существующее сообщение
val embedBuilder = net.dv8tion.jda.api.EmbedBuilder()
.setTitle("Мод $modName обновился!")
.setDescription("Возможно вы захотите обновить свои переводы.")
.addField("Версия мода", languageData.modVersion ?: "Неизвестно", true)
.addField("Какие из файлов изменились", changesDescription, false)
if (ruChanged && ruDiff != null) {
embedBuilder.addField("Различия в `ru_ru`:", "```diff\n$ruDiff\n```", false)
}
if (enChanged && enDiff != null) {
embedBuilder.addField("Различия в `en_us`:", "```diff\n$enDiff\n```", false)
}
modsChannel.editMessageEmbedsById(messageId, embedBuilder.build())
.setFiles(buildAttachments(mod.name, newLanguageData))
.queue()
// Обновляем кеш
messageCache.saveMessage(cacheKey, dataForCache, messageId)
} else {
// Сообщение удалено, отправляем заново
sendLanguageUpdateEmbed(
modsChannel,
mod.name,
newLanguageData,
ruChanged,
enChanged,
ruDiff,
enDiff,
cacheKey,
dataForCache
)
}
}
}
}
} catch (e: Exception) {
println("Ошибка при проверке мода ${mod.name}: ${e.message}")
}
}
}
}
```
---
## 8. Загрузка двух версий модов при первом запуске
В функции `onReady` уже включена логика для загрузки двух версий модов при первом запуске. Убедитесь, что функции `getPreviousLanguageData` и `getLanguageFilesForVersion` правильно реализованы для обеих платформ (Modrinth и CurseForge).
---
## 9. Оптимизация и обработка ошибок
Убедитесь, что все новые функции обрабатывают возможные исключения и корректно работают с различными форматами языковых файлов. При необходимости расширьте парсинг для поддержки других форматов (`.lang`, `.properties`).
---
## Полный пример обновленного `BotListener`
Ниже представлен обновленный класс `BotListener` с учтенными изменениями:
```kotlin
class BotListener(private val config: Config) : ListenerAdapter() {
// ... существующие свойства ...
override fun onReady(event: ReadyEvent) {
println("Бот запущен!")
// Получаем каналы
googlePlayChannel = event.jda.getNewsChannelById(config.googlePlayChannelId)
?: run {
println("Канал Google Play с ID ${config.googlePlayChannelId} не найден!")
exitProcess(1)
}
modsChannel = event.jda.getNewsChannelById(config.modsChannelId)
?: run {
println("Канал модов с ID ${config.modsChannelId} не найден!")
exitProcess(1)
}
// Инициализация: загрузка текущих языковых файлов модов
globalScope.launch {
for (mod in config.mods) {
try {
val newLanguageData = getLanguageFiles(mod, client, config.curseForgeApiKey)
if (newLanguageData != null) {
var shouldNotify = false
var previousData: ModLanguageData? = null
var ruChanged = false
var enChanged = false
var ruDiff: String? = null
var enDiff: String? = null
mutex.withLock {
previousData = langFiles[mod.name]
if (previousData == null) {
// Первая инициализация: загрузка текущих данных и предыдущей версии
val previousLanguageData = getPreviousLanguageData(mod, client, config.curseForgeApiKey)
if (previousLanguageData != null) {
langFiles[mod.name] = previousLanguageData
langFiles[mod.name] = newLanguageData
shouldNotify = true
ruChanged = newLanguageData.ruRuMap != null
enChanged = newLanguageData.enUsMap != null
// Вычисление различий
if (previousLanguageData.ruRuMap != null && newLanguageData.ruRuMap != null) {
ruDiff = computeDiff(previousLanguageData.ruRuMap, newLanguageData.ruRuMap)
}
if (previousLanguageData.enUsMap != null && newLanguageData.enUsMap != null) {
enDiff = computeDiff(previousLanguageData.enUsMap, newLanguageData.enUsMap)
}
}
} else {
// Обновление существующих данных
ruChanged = newLanguageData.ruRuMap != previousData.ruRuMap
enChanged = newLanguageData.enUsMap != previousData.enUsMap
if (ruChanged || enChanged) {
// Вычисление различий
if (ruChanged && previousData.ruRuMap != null && newLanguageData.ruRuMap != null) {
ruDiff = computeDiff(previousData.ruRuMap, newLanguageData.ruRuMap)
}
if (enChanged && previousData.enUsMap != null && newLanguageData.enUsMap != null) {
enDiff = computeDiff(previousData.enUsMap, newLanguageData.enUsMap)
}
langFiles[mod.name] = newLanguageData
shouldNotify = true
}
}
}
if (shouldNotify && (ruDiff != null || enDiff != null)) {
val cacheKey = "mod_${mod.name}_${newLanguageData.modVersion}"
val dataForCache =
(newLanguageData.ruRu ?: ByteArray(0)) + (newLanguageData.enUs ?: ByteArray(0))
if (!messageCache.isAlreadySent(cacheKey, dataForCache)) {
withContext(Dispatchers.IO) {
sendLanguageUpdateEmbed(
modsChannel,
mod.name,
newLanguageData,
ruChanged,
enChanged,
ruDiff,
enDiff,
cacheKey,
dataForCache
)
}
}
}
}
} catch (e: Exception) {
println("Ошибка при проверке мода ${mod.name}: ${e.message}")
}
}
}
// Остальная инициализация и регистрация команд...
}
// ... остальные функции, включая getPreviousLanguageData, computeDiff, parseLanguageFile и т.д. ...
}
```
---
## 10. Тестирование
После внесения всех изменений тщательно протестируйте бота:
- **Первоначальный запуск**: Убедитесь, что бот загружает две версии языковых файлов и корректно отображает различия.
- **Обновления модов**: Проверьте, что при обновлении языковых файлов бот отправляет сообщения с правильными диффами.
- **Обработка ошибок**: Убедитесь, что бот корректно обрабатывает отсутствующие файлы, неправильные форматы и сетевые ошибки.
---
## Дополнительные рекомендации
1. **Логику обработки форматов файлов**: Если языковые файлы могут быть в разных форматах (JSON, `.lang`, `.properties`), расширьте функцию парсинга для поддержки всех необходимых форматов.
2. **Использование библиотек для диффов**: Для более продвинутого форматирования различий можно использовать специализированные библиотеки для создания диффов, такие как [java-diff-utils](https://github.com/java-diff-utils/java-diff-utils).
3. **Обновление кеша**: Убедитесь, что кеширование сообщений учитывает новые поля (различия) и корректно сохраняется.
4. **Оптимизация API-запросов**: При загрузке предыдущей версии мода старайтесь оптимизировать количество API-запросов, чтобы избежать превышения лимитов.
5. **Асинхронная обработка**: Убедитесь, что все сетевые операции выполняются асинхронно и не блокируют основной поток.
---
Следуя этим шагам, вы сможете реализовать функциональность отображения различий между старыми и новыми языковыми файлами модов в вашем Discord-боте. Это повысит информативность уведомлений и улучшит опыт пользователей при отслеживании обновлений модов.