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 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<Copy>("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")
}

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.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 {
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)
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, "<i>$username</i>")
tgBot.sendMessageToTelegram(text)
}

View File

@ -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)
}
}

View File

@ -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
class TgBot(
private val plugin: Plugin,
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 {
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)
}
fun start(plugin: Plugin, config: Configuration) {
val slashRegex = "^/+".toRegex()
val commands = config.commands
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 }
dispatch {
commandBindings.forEach { (text, handler) ->
command(text!!.replace(slashRegex, "")) {
handler(update)
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) {}
}
}
bot.setMyCommands(getBotCommands())
config.webhookConfig?.let { _ ->
plugin.logger.info("Running in webhook mode.")
} ?: run {
bot.startPolling()
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
}
}
}
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
// 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 -> ""
} + " ($t)"
}
} + " ($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:
<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:
""".trimIndent() +
"\n\n<code>chats:\n # other ids...\n - ${chatID}</code>"
bot.sendMessage(
ChatId.fromId(chatID),
text,
parseMode = ParseMode.HTML,
replyToMessageId = msg.messageId
)
"\n\n<code>chats:\n # other ids...\n - ${chatId}</code>"
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<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()
}
.replace("%username%", username.fullEscape())
.replace("%message%", text.escapeHtml())
}

View File

@ -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("&", "&amp;").replace(">", "&gt;").replace("<", "&lt;")
fun escapeHTML(s: String) = s
fun String.escapeHtml() = this
.replace("&", "&amp;")
.replace(">", "&gt;")
.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 =
(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!!