diff --git a/build.gradle.kts b/build.gradle.kts index f4ede46..3be5592 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,7 +36,8 @@ repositories { } val tgBotVersion = "6.0.4" -val plugDir = "MinecraftServers/spigot_1.16.5/plugins/" +val retrofitVersion = "2.7.1" +val plugDir = "MinecraftServers/spigot_1.17/plugins/" val homeDir = System.getProperty("user.home") tasks { @@ -45,6 +46,9 @@ tasks { "spigot-tg-bridge-${spigotApiVersion}-v${pluginVersion}.jar" ) } + build { + dependsOn("shadowJar") + } } tasks.register("copyArtifacts") { @@ -57,15 +61,14 @@ tasks.register("pack") { finalizedBy("copyArtifacts") } -defaultTasks("pack") - dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") compileOnly("org.spigotmc:spigot-api:$spigotApiVersion-R0.1-SNAPSHOT") - implementation( - "io.github.kotlin-telegram-bot.kotlin-telegram-bot" + - ":telegram:$tgBotVersion" - ) + implementation("com.google.code.gson:gson:2.8.7") + implementation("com.squareup.retrofit2:retrofit:$retrofitVersion") + implementation("com.squareup.retrofit2:converter-gson:$retrofitVersion") + implementation("com.squareup.okhttp3:logging-interceptor:4.2.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0") implementation("com.vdurmont:emoji-java:5.1.1") } diff --git a/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/ApiService.kt b/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/ApiService.kt new file mode 100644 index 0000000..28d9e5d --- /dev/null +++ b/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/ApiService.kt @@ -0,0 +1,96 @@ +package org.kraftwerk28.spigot_tg_bridge + +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.OkHttpClient +import com.google.gson.annotations.SerializedName as Name +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.* +import java.time.Duration +import retrofit2.Retrofit + +interface TgApiService { + data class TgResponse(val ok: Boolean, val result: T?, val description: String?) + data class WebhookOptions(val drop_pending_updates: Boolean) + + data class User( + @Name("id") val id: Long, + @Name("is_bot") val isBot: Boolean, + @Name("first_name") val firstName: String, + @Name("last_name") val lastName: String? = null, + @Name("username") val username: String? = null, + @Name("language_code") val languageCode: String? = null, + ) + + data class Chat( + val id: Long, + val type: String, + val title: String? = null, + val username: String? = null, + @Name("first_name") val firstName: String? = null, + @Name("last_name") val lastName: String? = null, + ) + + data class Message( + @Name("message_id") val messageId: Long, + val from: User? = null, + @Name("sender_chat") val senderChat: Chat? = null, + val date: Long, + val chat: Chat, + @Name("reply_to_message") val replyToMessage: Message? = null, + val text: String? = null, + ) + + data class Update( + @Name("update_id") val updateId: Long, + val message: Message? = null, + ) + + data class BotCommand(val command: String, val description: String) + data class SetMyCommands(val commands: List) + + @GET("deleteWebhook") + suspend fun deleteWebhook( + @Query("drop_pending_updates") dropPendingUpdates: Boolean + ): TgResponse + + @GET("sendMessage?parse_mode=HTML") + suspend fun sendMessage( + @Query("chat_id") chatId: Long, + @Query("text") text: String, + @Query("reply_to_message_id") replyToMessageId: Long? = null, + ): TgResponse + + @GET("getUpdates") + suspend fun getUpdates( + @Query("offset") offset: Long, + @Query("limit") limit: Int = 100, + @Query("timeout") timeout: Int = 0, + ): TgResponse> + + @GET("getMe") + suspend fun getMe(): TgResponse + + @POST("setMyCommands") + suspend fun setMyCommands( + @Body commands: SetMyCommands, + ): TgResponse + + companion object { + fun create(token: String): TgApiService { + val interceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.NONE; + } + val client = OkHttpClient + .Builder() + .addInterceptor(interceptor) + .readTimeout(Duration.ZERO) + .build(); + val r = Retrofit.Builder() + .baseUrl("https://api.telegram.org/bot$token/") + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + return r.create(TgApiService::class.java) + } + } +} diff --git a/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/EventHandler.kt b/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/EventHandler.kt index 712a9f3..44e93c9 100644 --- a/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/EventHandler.kt +++ b/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/EventHandler.kt @@ -7,6 +7,7 @@ import org.bukkit.event.player.AsyncPlayerChatEvent import org.bukkit.event.player.PlayerBedEnterEvent import org.bukkit.event.player.PlayerJoinEvent import org.bukkit.event.player.PlayerQuitEvent +import kotlin.system.measureTimeMillis class EventHandler( private val tgBot: TgBot, @@ -17,16 +18,21 @@ class EventHandler( fun onPlayerChat(event: AsyncPlayerChatEvent) { if (!config.logFromMCtoTG) return event.run { - tgBot.sendMessageToTelegram( - message, player.displayName - ) + measureTimeMillis { + tgBot.sendMessageToTelegram( + message, player.displayName + ) + } + .also { + println("Time: $it") + } } } @EventHandler fun onPlayerJoin(event: PlayerJoinEvent) { - if (!config.logJoinLeave || config.joinString == null) return - val username = fullEscape(event.player.displayName) + if (!config.logJoinLeave || config.joinString == null) return + val username = event.player.displayName.fullEscape() val text = config.joinString!!.replace("%username%", username) tgBot.sendMessageToTelegram(text) } @@ -34,7 +40,7 @@ class EventHandler( @EventHandler fun onPlayerLeave(event: PlayerQuitEvent) { if (!config.logJoinLeave || config.leaveString == null) return - val username = fullEscape(event.player.displayName) + val username = event.player.displayName.fullEscape() val text = config.leaveString!!.replace("%username%", username) tgBot.sendMessageToTelegram(text) } @@ -43,7 +49,7 @@ class EventHandler( fun onPlayerDied(event: PlayerDeathEvent) { if (!config.logDeath) return event.deathMessage?.let { - val username = fullEscape(event.entity.displayName) + val username = event.entity.displayName.fullEscape() val text = it.replace(username, "$username") tgBot.sendMessageToTelegram(text) } diff --git a/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/Plugin.kt b/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/Plugin.kt index 88af9cd..1c298f3 100644 --- a/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/Plugin.kt +++ b/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/Plugin.kt @@ -36,27 +36,27 @@ class Plugin : JavaPlugin() { override fun onDisable() { if (!config.isEnabled) return - config.serverStopMessage?.let { message -> - tgBot?.sendMessageToTelegram(message) + config.serverStopMessage?.let { + tgBot?.sendMessageToTelegram(it) } logger.info("Plugin stopped.") } - fun sendMessageToMinecraft(text: String, username: String? = null) { - var prepared = config.telegramMessageFormat - .replace(C.MESSAGE_TEXT_PLACEHOLDER, escapeEmoji(text)) - username?.let { - prepared = prepared - .replace(C.USERNAME_PLACEHOLDER, escapeEmoji(it)) - } - server.broadcastMessage(prepared) - } + fun sendMessageToMinecraft(text: String, username: String? = null) = + config.telegramMessageFormat + .replace(C.MESSAGE_TEXT_PLACEHOLDER, text.escapeEmoji()) + .run { + username?.let { username -> + replace(C.USERNAME_PLACEHOLDER, username.escapeEmoji()) + } ?: this + } + .also { server.broadcastMessage(it) } fun reload() { logger.info(C.INFO.reloading) config.reload(this) tgBot?.stop() - tgBot?.start(this, config) + tgBot = TgBot(this, config) logger.info(C.INFO.reloadComplete) } } diff --git a/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/TgBot.kt b/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/TgBot.kt index 089b91d..85533ff 100644 --- a/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/TgBot.kt +++ b/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/TgBot.kt @@ -1,96 +1,127 @@ package org.kraftwerk28.spigot_tg_bridge -import com.github.kotlintelegrambot.* -import com.github.kotlintelegrambot.dispatcher.command -import com.github.kotlintelegrambot.dispatcher.text -import com.github.kotlintelegrambot.entities.BotCommand -import com.github.kotlintelegrambot.entities.ParseMode -import com.github.kotlintelegrambot.entities.Update -import com.github.kotlintelegrambot.logging.LogLevel -import com.github.kotlintelegrambot.entities.ChatId -import okhttp3.logging.HttpLoggingInterceptor import org.kraftwerk28.spigot_tg_bridge.Constants as C +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* -class TgBot(private val plugin: Plugin, private val config: Configuration) { - - private lateinit var bot: Bot - - init { - start(plugin, config) +class TgBot( + private val plugin: Plugin, + private val config: Configuration, + private val pollTimeout: Int = 30, +) { + private val api: TgApiService + val updateChan = Channel() + val scope = CoroutineScope(Dispatchers.Default) + val pollJob: Job + val handlerJob: Job + var currentOffset: Long = -1 + var me: TgApiService.User + var commandRegex: Regex + val commandMap = config.commands.run { + mapOf( + online to ::onlineHandler, + time to ::timeHandler, + chatID to ::chatIdHandler, + ) } - fun start(plugin: Plugin, config: Configuration) { - val slashRegex = "^/+".toRegex() - val commands = config.commands + init { + api = TgApiService.create(config.botToken) + runBlocking { + me = api.getMe().result!! + // I don't put optional @username in regex since bot is + // only used in group chats + commandRegex = """^\/(\w+)(?:@${me.username})$""".toRegex() - skipUpdates() - bot = bot { - token = config.botToken - logLevel = LogLevel.None - val commandBindings = commands.let { - mapOf( - it.time to ::time, - it.online to ::online, - it.chatID to ::chatID - ) - }.filterKeys { it != null } + val commands = config.commands.run { listOf(time, online, chatID) } + .zip(C.COMMAND_DESC.run { + listOf(timeDesc, onlineDesc, chatIDDesc) + }) + .map { TgApiService.BotCommand(it.first!!, it.second) } + .let { TgApiService.SetMyCommands(it) } - dispatch { - commandBindings.forEach { (text, handler) -> - command(text!!.replace(slashRegex, "")) { - handler(update) + api.setMyCommands(commands) + } + pollJob = scope.launch { + try { + while (true) { + try { + pollUpdates() + } catch (e: Exception) { + e.printStackTrace() } } - text { onText(update) } + } catch (e: CancellationException) {} + } + handlerJob = scope.launch { + try { + while (true) { + handleUpdate() + } + } catch (e: CancellationException) {} + } + } + + suspend fun pollUpdates() { + val updatesResponse = api + .getUpdates(offset = currentOffset, timeout = pollTimeout) + updatesResponse.result?.let { updates -> + if (!updates.isEmpty()) { + updates.forEach { updateChan.send(it) } + currentOffset = updates.last().updateId + 1 } } - bot.setMyCommands(getBotCommands()) + } - config.webhookConfig?.let { _ -> - plugin.logger.info("Running in webhook mode.") - } ?: run { - bot.startPolling() + suspend fun handleUpdate() { + val update = updateChan.receive() + update.message?.text?.let { + println("Text: $it") + commandRegex.matchEntire(it)?.groupValues?.let { + commandMap[it[1]]?.let { it(update) } ?: onTextHandler(update) + } } } fun stop() { - bot.stopPolling() + runBlocking { + pollJob.cancelAndJoin() + handlerJob.cancelAndJoin() + } } - private fun time(update: Update) { + private suspend fun timeHandler(update: TgApiService.Update) { val msg = update.message!! if (!config.allowedChats.contains(msg.chat.id)) { return } if (plugin.server.worlds.isEmpty()) { - bot.sendMessage( - ChatId.fromId(msg.chat.id), + api.sendMessage( + msg.chat.id, "No worlds available", replyToMessageId = msg.messageId ) return } - val t = plugin.server.worlds.first().time - val text = when { - t <= 12000 -> C.TIMES_OF_DAY.day - t <= 13800 -> C.TIMES_OF_DAY.sunset - t <= 22200 -> C.TIMES_OF_DAY.night - t <= 24000 -> C.TIMES_OF_DAY.sunrise - else -> "" - } + " ($t)" + // TODO: handle multiple worlds + val time = plugin.server.worlds.first().time + val text = C.TIMES_OF_DAY.run { + when { + time <= 12000 -> day + time <= 13800 -> sunset + time <= 22200 -> night + time <= 24000 -> sunrise + else -> "" + } + } + " ($time)" - bot.sendMessage( - ChatId.fromId(msg.chat.id), - text, - replyToMessageId = msg.messageId, - parseMode = ParseMode.HTML - ) + api.sendMessage(msg.chat.id, text, replyToMessageId = msg.messageId) } - private fun online(update: Update) { + private suspend fun onlineHandler(update: TgApiService.Update) { val msg = update.message!! if (!config.allowedChats.contains(msg.chat.id)) { return @@ -99,62 +130,40 @@ class TgBot(private val plugin: Plugin, private val config: Configuration) { val playerList = plugin.server.onlinePlayers val playerStr = plugin.server .onlinePlayers - .mapIndexed { i, s -> "${i + 1}. ${fullEscape(s.displayName)}" } + .mapIndexed { i, s -> "${i + 1}. ${s.displayName.fullEscape()}" } .joinToString("\n") val text = if (playerList.isNotEmpty()) "${config.onlineString}:\n$playerStr" else config.nobodyOnlineString - bot.sendMessage( - ChatId.fromId(msg.chat.id), - text, - replyToMessageId = msg.messageId, - parseMode = ParseMode.HTML - ) + api.sendMessage(msg.chat.id, text, replyToMessageId = msg.messageId) } - private fun chatID(update: Update) { + private suspend fun chatIdHandler(update: TgApiService.Update) { val msg = update.message!! - val chatID = msg.chat.id + val chatId = msg.chat.id val text = """ Chat ID: - $chatID + ${chatId} paste this id to chats: section in you config.yml file so it will look like this: """.trimIndent() + - "\n\nchats:\n # other ids...\n - ${chatID}" - bot.sendMessage( - ChatId.fromId(chatID), - text, - parseMode = ParseMode.HTML, - replyToMessageId = msg.messageId - ) + "\n\nchats:\n # other ids...\n - ${chatId}" + api.sendMessage(chatId, text, replyToMessageId = msg.messageId) } fun sendMessageToTelegram(text: String, username: String? = null) { - config.allowedChats.forEach { chatID -> - username?.let { - bot.sendMessage( - ChatId.fromId(chatID), - formatMsgFromMinecraft(username, text), - parseMode = ParseMode.HTML, - ) - } ?: run { - bot.sendMessage( - ChatId.fromId(chatID), - text, - parseMode = ParseMode.HTML, - ) + val messageText = username?.let { formatMsgFromMinecraft(it, text) } ?: text + config.allowedChats.forEach { chatId -> + scope.launch { + delay(1000) + api.sendMessage(chatId, messageText) } } } - private fun onText(update: Update) { + private suspend fun onTextHandler(update: TgApiService.Update) { if (!config.logFromTGtoMC) return val msg = update.message!! - - // Suppress commands to be sent to Minecraft - if (msg.text!!.startsWith("/")) return - - plugin.sendMessageToMinecraft(msg.text!!, rawUserMention(msg.from!!)) + plugin.sendMessageToMinecraft(msg.text!!, msg.from!!.rawUserMention()) } private fun formatMsgFromMinecraft( @@ -162,21 +171,6 @@ class TgBot(private val plugin: Plugin, private val config: Configuration) { text: String ): String = config.minecraftMessageFormat - .replace("%username%", fullEscape(username)) - .replace("%message%", escapeHTML(text)) - - private fun getBotCommands(): List { - val cmdList = config.commands.run { listOfNotNull(time, online, chatID) } - val descList = C.COMMAND_DESC.run { listOf(timeDesc, onlineDesc, chatIDDesc) } - return cmdList.zip(descList).map { BotCommand(it.first, it.second) } - } - - private fun skipUpdates() { - // Creates a temporary bot w/ 0 timeout to skip updates - bot { - token = config.botToken - timeout = 0 - logLevel = LogLevel.None - }.skipUpdates() - } + .replace("%username%", username.fullEscape()) + .replace("%message%", text.escapeHtml()) } diff --git a/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/Utils.kt b/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/Utils.kt index 14f6aaf..7b01611 100644 --- a/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/Utils.kt +++ b/src/main/kotlin/org/kraftwerk28/spigot_tg_bridge/Utils.kt @@ -1,35 +1,34 @@ package org.kraftwerk28.spigot_tg_bridge -import com.github.kotlintelegrambot.Bot -import com.github.kotlintelegrambot.entities.Update -import com.github.kotlintelegrambot.entities.User import com.vdurmont.emoji.EmojiParser -fun Bot.skipUpdates(lastUpdateID: Long = 0) { - val newUpdates = getUpdates(lastUpdateID) +// fun Bot.skipUpdates(lastUpdateID: Long = 0) { +// val newUpdates = getUpdates(lastUpdateID) - if (newUpdates.isNotEmpty()) { - val lastUpd = newUpdates.last() - if (lastUpd !is Update) return - return skipUpdates(lastUpd.updateId + 1) - } -} +// if (newUpdates.isNotEmpty()) { +// val lastUpd = newUpdates.last() +// if (lastUpd !is Update) return +// return skipUpdates(lastUpd.updateId + 1) +// } +// } -fun String.escapeHtml() = - this.replace("&", "&").replace(">", ">").replace("<", "<") - -fun escapeHTML(s: String) = s +fun String.escapeHtml() = this .replace("&", "&") .replace(">", ">") .replace("<", "<") -fun escapeColorCodes(s: String) = s.replace("\u00A7.".toRegex(), "") +fun String.escapeHTML() = this + .replace("&", "&") + .replace(">", ">") + .replace("<", "<") -fun fullEscape(s: String) = escapeColorCodes(escapeHTML(s)) +fun String.escapeColorCodes() = replace("\u00A7.".toRegex(), "") -fun escapeEmoji(text: String) = EmojiParser.parseToAliases(text) +fun String.fullEscape() = escapeHTML().escapeColorCodes() -fun rawUserMention(user: User): String = - (if (user.firstName.length < 2) null else user.firstName) - ?: user.username - ?: user.lastName!! +fun String.escapeEmoji() = EmojiParser.parseToAliases(this) + +fun TgApiService.User.rawUserMention(): String = + (if (firstName.length < 2) null else firstName) + ?: username + ?: lastName!!