Move to kotlinx coroutines

This commit is contained in:
kraftwerk28 2021-06-29 18:40:58 +03:00
parent 443dfb9394
commit 41fbe85f7f
6 changed files with 257 additions and 159 deletions

View File

@ -36,7 +36,8 @@ repositories {
} }
val tgBotVersion = "6.0.4" 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") val homeDir = System.getProperty("user.home")
tasks { tasks {
@ -45,6 +46,9 @@ tasks {
"spigot-tg-bridge-${spigotApiVersion}-v${pluginVersion}.jar" "spigot-tg-bridge-${spigotApiVersion}-v${pluginVersion}.jar"
) )
} }
build {
dependsOn("shadowJar")
}
} }
tasks.register<Copy>("copyArtifacts") { tasks.register<Copy>("copyArtifacts") {
@ -57,15 +61,14 @@ tasks.register("pack") {
finalizedBy("copyArtifacts") finalizedBy("copyArtifacts")
} }
defaultTasks("pack")
dependencies { dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
compileOnly("org.spigotmc:spigot-api:$spigotApiVersion-R0.1-SNAPSHOT") compileOnly("org.spigotmc:spigot-api:$spigotApiVersion-R0.1-SNAPSHOT")
implementation( implementation("com.google.code.gson:gson:2.8.7")
"io.github.kotlin-telegram-bot.kotlin-telegram-bot" + implementation("com.squareup.retrofit2:retrofit:$retrofitVersion")
":telegram:$tgBotVersion" 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") implementation("com.vdurmont:emoji-java:5.1.1")
} }

View File

@ -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<T>(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<BotCommand>)
@GET("deleteWebhook")
suspend fun deleteWebhook(
@Query("drop_pending_updates") dropPendingUpdates: Boolean
): TgResponse<Boolean>
@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<Message>
@GET("getUpdates")
suspend fun getUpdates(
@Query("offset") offset: Long,
@Query("limit") limit: Int = 100,
@Query("timeout") timeout: Int = 0,
): TgResponse<List<Update>>
@GET("getMe")
suspend fun getMe(): TgResponse<User>
@POST("setMyCommands")
suspend fun setMyCommands(
@Body commands: SetMyCommands,
): TgResponse<Boolean>
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)
}
}
}

View File

@ -7,6 +7,7 @@ import org.bukkit.event.player.AsyncPlayerChatEvent
import org.bukkit.event.player.PlayerBedEnterEvent import org.bukkit.event.player.PlayerBedEnterEvent
import org.bukkit.event.player.PlayerJoinEvent import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerQuitEvent import org.bukkit.event.player.PlayerQuitEvent
import kotlin.system.measureTimeMillis
class EventHandler( class EventHandler(
private val tgBot: TgBot, private val tgBot: TgBot,
@ -17,16 +18,21 @@ class EventHandler(
fun onPlayerChat(event: AsyncPlayerChatEvent) { fun onPlayerChat(event: AsyncPlayerChatEvent) {
if (!config.logFromMCtoTG) return if (!config.logFromMCtoTG) return
event.run { event.run {
measureTimeMillis {
tgBot.sendMessageToTelegram( tgBot.sendMessageToTelegram(
message, player.displayName message, player.displayName
) )
} }
.also {
println("Time: $it")
}
}
} }
@EventHandler @EventHandler
fun onPlayerJoin(event: PlayerJoinEvent) { fun onPlayerJoin(event: PlayerJoinEvent) {
if (!config.logJoinLeave || config.joinString == null) return if (!config.logJoinLeave || config.joinString == null) return
val username = fullEscape(event.player.displayName) val username = event.player.displayName.fullEscape()
val text = config.joinString!!.replace("%username%", username) val text = config.joinString!!.replace("%username%", username)
tgBot.sendMessageToTelegram(text) tgBot.sendMessageToTelegram(text)
} }
@ -34,7 +40,7 @@ class EventHandler(
@EventHandler @EventHandler
fun onPlayerLeave(event: PlayerQuitEvent) { fun onPlayerLeave(event: PlayerQuitEvent) {
if (!config.logJoinLeave || config.leaveString == null) return 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) val text = config.leaveString!!.replace("%username%", username)
tgBot.sendMessageToTelegram(text) tgBot.sendMessageToTelegram(text)
} }
@ -43,7 +49,7 @@ class EventHandler(
fun onPlayerDied(event: PlayerDeathEvent) { fun onPlayerDied(event: PlayerDeathEvent) {
if (!config.logDeath) return if (!config.logDeath) return
event.deathMessage?.let { event.deathMessage?.let {
val username = fullEscape(event.entity.displayName) val username = event.entity.displayName.fullEscape()
val text = it.replace(username, "<i>$username</i>") val text = it.replace(username, "<i>$username</i>")
tgBot.sendMessageToTelegram(text) tgBot.sendMessageToTelegram(text)
} }

View File

@ -36,27 +36,27 @@ class Plugin : JavaPlugin() {
override fun onDisable() { override fun onDisable() {
if (!config.isEnabled) return if (!config.isEnabled) return
config.serverStopMessage?.let { message -> config.serverStopMessage?.let {
tgBot?.sendMessageToTelegram(message) tgBot?.sendMessageToTelegram(it)
} }
logger.info("Plugin stopped.") logger.info("Plugin stopped.")
} }
fun sendMessageToMinecraft(text: String, username: String? = null) { fun sendMessageToMinecraft(text: String, username: String? = null) =
var prepared = config.telegramMessageFormat config.telegramMessageFormat
.replace(C.MESSAGE_TEXT_PLACEHOLDER, escapeEmoji(text)) .replace(C.MESSAGE_TEXT_PLACEHOLDER, text.escapeEmoji())
username?.let { .run {
prepared = prepared username?.let { username ->
.replace(C.USERNAME_PLACEHOLDER, escapeEmoji(it)) replace(C.USERNAME_PLACEHOLDER, username.escapeEmoji())
} } ?: this
server.broadcastMessage(prepared)
} }
.also { server.broadcastMessage(it) }
fun reload() { fun reload() {
logger.info(C.INFO.reloading) logger.info(C.INFO.reloading)
config.reload(this) config.reload(this)
tgBot?.stop() tgBot?.stop()
tgBot?.start(this, config) tgBot = TgBot(this, config)
logger.info(C.INFO.reloadComplete) logger.info(C.INFO.reloadComplete)
} }
} }

View File

@ -1,96 +1,127 @@
package org.kraftwerk28.spigot_tg_bridge 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 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) { class TgBot(
private val plugin: Plugin,
private lateinit var bot: Bot private val config: Configuration,
private val pollTimeout: Int = 30,
) {
private val api: TgApiService
val updateChan = Channel<TgApiService.Update>()
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,
)
}
init { init {
start(plugin, config) 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()
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) }
api.setMyCommands(commands)
} }
pollJob = scope.launch {
fun start(plugin: Plugin, config: Configuration) { try {
val slashRegex = "^/+".toRegex() while (true) {
val commands = config.commands try {
pollUpdates()
skipUpdates() } catch (e: Exception) {
bot = bot { e.printStackTrace()
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 }
dispatch {
commandBindings.forEach { (text, handler) ->
command(text!!.replace(slashRegex, "")) {
handler(update)
} }
} }
text { onText(update) } } catch (e: CancellationException) {}
}
handlerJob = scope.launch {
try {
while (true) {
handleUpdate()
}
} catch (e: CancellationException) {}
} }
} }
bot.setMyCommands(getBotCommands())
config.webhookConfig?.let { _ -> suspend fun pollUpdates() {
plugin.logger.info("Running in webhook mode.") val updatesResponse = api
} ?: run { .getUpdates(offset = currentOffset, timeout = pollTimeout)
bot.startPolling() updatesResponse.result?.let { updates ->
if (!updates.isEmpty()) {
updates.forEach { updateChan.send(it) }
currentOffset = updates.last().updateId + 1
}
}
}
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() { 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!! val msg = update.message!!
if (!config.allowedChats.contains(msg.chat.id)) { if (!config.allowedChats.contains(msg.chat.id)) {
return return
} }
if (plugin.server.worlds.isEmpty()) { if (plugin.server.worlds.isEmpty()) {
bot.sendMessage( api.sendMessage(
ChatId.fromId(msg.chat.id), msg.chat.id,
"No worlds available", "No worlds available",
replyToMessageId = msg.messageId replyToMessageId = msg.messageId
) )
return return
} }
val t = plugin.server.worlds.first().time // TODO: handle multiple worlds
val text = when { val time = plugin.server.worlds.first().time
t <= 12000 -> C.TIMES_OF_DAY.day val text = C.TIMES_OF_DAY.run {
t <= 13800 -> C.TIMES_OF_DAY.sunset when {
t <= 22200 -> C.TIMES_OF_DAY.night time <= 12000 -> day
t <= 24000 -> C.TIMES_OF_DAY.sunrise time <= 13800 -> sunset
time <= 22200 -> night
time <= 24000 -> sunrise
else -> "" else -> ""
} + " ($t)" }
} + " ($time)"
bot.sendMessage( api.sendMessage(msg.chat.id, text, replyToMessageId = msg.messageId)
ChatId.fromId(msg.chat.id),
text,
replyToMessageId = msg.messageId,
parseMode = ParseMode.HTML
)
} }
private fun online(update: Update) { private suspend fun onlineHandler(update: TgApiService.Update) {
val msg = update.message!! val msg = update.message!!
if (!config.allowedChats.contains(msg.chat.id)) { if (!config.allowedChats.contains(msg.chat.id)) {
return return
@ -99,62 +130,40 @@ class TgBot(private val plugin: Plugin, private val config: Configuration) {
val playerList = plugin.server.onlinePlayers val playerList = plugin.server.onlinePlayers
val playerStr = plugin.server val playerStr = plugin.server
.onlinePlayers .onlinePlayers
.mapIndexed { i, s -> "${i + 1}. ${fullEscape(s.displayName)}" } .mapIndexed { i, s -> "${i + 1}. ${s.displayName.fullEscape()}" }
.joinToString("\n") .joinToString("\n")
val text = val text =
if (playerList.isNotEmpty()) "${config.onlineString}:\n$playerStr" if (playerList.isNotEmpty()) "${config.onlineString}:\n$playerStr"
else config.nobodyOnlineString else config.nobodyOnlineString
bot.sendMessage( api.sendMessage(msg.chat.id, text, replyToMessageId = msg.messageId)
ChatId.fromId(msg.chat.id),
text,
replyToMessageId = msg.messageId,
parseMode = ParseMode.HTML
)
} }
private fun chatID(update: Update) { private suspend fun chatIdHandler(update: TgApiService.Update) {
val msg = update.message!! val msg = update.message!!
val chatID = msg.chat.id val chatId = msg.chat.id
val text = """ val text = """
Chat ID: Chat ID:
<code>$chatID</code> <code>${chatId}</code>
paste this id to <code>chats:</code> section in you config.yml file so it will look like this: paste this id to <code>chats:</code> section in you config.yml file so it will look like this:
""".trimIndent() + """.trimIndent() +
"\n\n<code>chats:\n # other ids...\n - ${chatID}</code>" "\n\n<code>chats:\n # other ids...\n - ${chatId}</code>"
bot.sendMessage( api.sendMessage(chatId, text, replyToMessageId = msg.messageId)
ChatId.fromId(chatID),
text,
parseMode = ParseMode.HTML,
replyToMessageId = msg.messageId
)
} }
fun sendMessageToTelegram(text: String, username: String? = null) { fun sendMessageToTelegram(text: String, username: String? = null) {
config.allowedChats.forEach { chatID -> val messageText = username?.let { formatMsgFromMinecraft(it, text) } ?: text
username?.let { config.allowedChats.forEach { chatId ->
bot.sendMessage( scope.launch {
ChatId.fromId(chatID), delay(1000)
formatMsgFromMinecraft(username, text), api.sendMessage(chatId, messageText)
parseMode = ParseMode.HTML,
)
} ?: run {
bot.sendMessage(
ChatId.fromId(chatID),
text,
parseMode = ParseMode.HTML,
)
} }
} }
} }
private fun onText(update: Update) { private suspend fun onTextHandler(update: TgApiService.Update) {
if (!config.logFromTGtoMC) return if (!config.logFromTGtoMC) return
val msg = update.message!! val msg = update.message!!
plugin.sendMessageToMinecraft(msg.text!!, msg.from!!.rawUserMention())
// Suppress commands to be sent to Minecraft
if (msg.text!!.startsWith("/")) return
plugin.sendMessageToMinecraft(msg.text!!, rawUserMention(msg.from!!))
} }
private fun formatMsgFromMinecraft( private fun formatMsgFromMinecraft(
@ -162,21 +171,6 @@ class TgBot(private val plugin: Plugin, private val config: Configuration) {
text: String text: String
): String = ): String =
config.minecraftMessageFormat config.minecraftMessageFormat
.replace("%username%", fullEscape(username)) .replace("%username%", username.fullEscape())
.replace("%message%", escapeHTML(text)) .replace("%message%", text.escapeHtml())
private fun getBotCommands(): List<BotCommand> {
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()
}
} }

View File

@ -1,35 +1,34 @@
package org.kraftwerk28.spigot_tg_bridge 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 import com.vdurmont.emoji.EmojiParser
fun Bot.skipUpdates(lastUpdateID: Long = 0) { // fun Bot.skipUpdates(lastUpdateID: Long = 0) {
val newUpdates = getUpdates(lastUpdateID) // val newUpdates = getUpdates(lastUpdateID)
if (newUpdates.isNotEmpty()) { // if (newUpdates.isNotEmpty()) {
val lastUpd = newUpdates.last() // val lastUpd = newUpdates.last()
if (lastUpd !is Update) return // if (lastUpd !is Update) return
return skipUpdates(lastUpd.updateId + 1) // return skipUpdates(lastUpd.updateId + 1)
} // }
} // }
fun String.escapeHtml() = fun String.escapeHtml() = this
this.replace("&", "&amp;").replace(">", "&gt;").replace("<", "&lt;")
fun escapeHTML(s: String) = s
.replace("&", "&amp;") .replace("&", "&amp;")
.replace(">", "&gt;") .replace(">", "&gt;")
.replace("<", "&lt;") .replace("<", "&lt;")
fun escapeColorCodes(s: String) = s.replace("\u00A7.".toRegex(), "") fun String.escapeHTML() = this
.replace("&", "&amp;")
.replace(">", "&gt;")
.replace("<", "&lt;")
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 = fun String.escapeEmoji() = EmojiParser.parseToAliases(this)
(if (user.firstName.length < 2) null else user.firstName)
?: user.username fun TgApiService.User.rawUserMention(): String =
?: user.lastName!! (if (firstName.length < 2) null else firstName)
?: username
?: lastName!!