Add basic IgnAuth functionality

This commit is contained in:
kraftwerk28 2021-07-09 15:51:57 +03:00
parent d6404d3be2
commit 8c401887ad
12 changed files with 437 additions and 141 deletions

View File

@ -0,0 +1,35 @@
package org.kraftwerk28.spigot_tg_bridge
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.bukkit.plugin.java.JavaPlugin
open class AsyncJavaPlugin : JavaPlugin() {
private val scope = CoroutineScope(Dispatchers.Default)
private val jobs: MutableList<Job> = mutableListOf()
override fun onEnable() {
runBlocking { onEnableAsync() }
}
override fun onDisable() {
runBlocking {
onDisableAsync()
jobs.joinAll()
}
}
open suspend fun onEnableAsync() = Unit
open suspend fun onDisableAsync() = Unit
fun <T> launch(f: suspend () -> T) = scope.launch {
f()
}.also {
jobs.add(it)
}
}

View File

@ -0,0 +1,28 @@
package org.kraftwerk28.spigot_tg_bridge
import java.sql.Connection
import java.sql.DriverManager
import java.sql.SQLException
class Auth() {
var conn: Connection? = null
suspend fun connect() {
try {
val connString = "jdbc:sqlite:spigot-tg-bridge.sqlite"
val initDbQuery = """
create table if not exists ign_links (
telegram_id bigint,
telegram_username varchar,
minecraft_ign varchar,
linked_timestamp int
)
"""
conn = DriverManager.getConnection(connString).apply {
createStatement().execute(initDbQuery)
}
} catch (e: SQLException) {
e.printStackTrace()
}
}
}

View File

@ -6,12 +6,16 @@ class BotCommands(cfg: FileConfiguration) {
val time: String?
val online: String?
val chatID: String?
val linkIgn: String?
val getAllLinked: String?
init {
cfg.run {
time = getString("commands.time")
online = getString("commands.online")
chatID = getString("commands.chat_id")
linkIgn = getString("commands.link_ign")
getAllLinked = getString("commands.list_linked")
}
}
}

View File

@ -16,7 +16,7 @@ class CommandHandler(private val plugin: Plugin) : CommandExecutor {
if (sender !is ConsoleCommandSender) return false
return when (label) {
C.COMMANDS.PLUGIN_RELOAD -> {
plugin.reload()
plugin.launch { plugin.reload() }
true
}
else -> false

View File

@ -17,6 +17,7 @@ class Configuration(plugin: Plugin) {
val logPlayerAsleep: Boolean
val onlineString: String
val nobodyOnlineString: String
val enableIgnAuth: Boolean
// Telegram bot stuff
val botToken: String
@ -24,6 +25,7 @@ class Configuration(plugin: Plugin) {
val logFromTGtoMC: Boolean
val allowWebhook: Boolean
val webhookConfig: Map<String, Any>?
val pollTimeout: Int
var commands: BotCommands
@ -78,10 +80,14 @@ class Configuration(plugin: Plugin) {
)!!
// isEnabled = getBoolean("enable", true)
allowedChats = getLongList("chats")
enableIgnAuth = getBoolean("enableIgnAuth", false)
botToken = getString("botToken") ?: throw Exception(C.WARN.noToken)
allowWebhook = getBoolean("useWebhook", false)
@Suppress("unchecked_cast")
webhookConfig = get("webhookConfig") as Map<String, Any>?
pollTimeout = getInt("pollTimeout", 30)
logJoinLeave = getBoolean("logJoinLeave", false)
onlineString = getString("strings.online", "Online")!!
nobodyOnlineString = getString(

View File

@ -0,0 +1,59 @@
package org.kraftwerk28.spigot_tg_bridge
import java.sql.Date
import com.google.gson.annotations.SerializedName as Name
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>)
data class DbLinkedUser(
val tgId: Long,
val tgFirstName: String,
val tgLastName: String?,
val tgUsername: String?,
val minecraftUuid: String,
val minecraftUsername: String,
val createdTimestamp: Date,
)

View File

@ -9,15 +9,16 @@ import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerQuitEvent
class EventHandler(
private val plugin: Plugin,
private val config: Configuration,
private val tgBot: TgBot,
private val config: Configuration
) : Listener {
@EventHandler
fun onPlayerChat(event: AsyncPlayerChatEvent) {
if (!config.logFromMCtoTG) return
event.run {
tgBot.sendMessageToTelegram(message, player.displayName)
sendMessage(message, player.displayName)
}
}
@ -26,7 +27,7 @@ class EventHandler(
if (!config.logJoinLeave) return
val username = event.player.displayName.fullEscape()
val text = config.joinString.replace("%username%", username)
tgBot.sendMessageToTelegram(text)
sendMessage(text)
}
@EventHandler
@ -34,7 +35,7 @@ class EventHandler(
if (!config.logJoinLeave) return
val username = event.player.displayName.fullEscape()
val text = config.leaveString.replace("%username%", username)
tgBot.sendMessageToTelegram(text)
sendMessage(text)
}
@EventHandler
@ -43,7 +44,7 @@ class EventHandler(
event.deathMessage?.let {
val username = event.entity.displayName.fullEscape()
val text = it.replace(username, "<i>$username</i>")
tgBot.sendMessageToTelegram(text)
sendMessage(text)
}
}
@ -53,6 +54,12 @@ class EventHandler(
if (event.bedEnterResult != PlayerBedEnterEvent.BedEnterResult.OK)
return
val text = "<i>${event.player.displayName}</i> fell asleep."
tgBot.sendMessageToTelegram(text)
sendMessage(text)
}
private fun sendMessage(text: String, username: String? = null) {
plugin.launch {
tgBot.sendMessageToTelegram(text, username)
}
}
}

View File

@ -0,0 +1,90 @@
package org.kraftwerk28.spigot_tg_bridge
import java.sql.Connection
import java.sql.DriverManager
import java.sql.SQLException
const val INIT_DB_QUERY = """
create table if not exists user (
tg_id bigint not null primary key,
tg_username varchar,
tg_first_name varchar not null,
tg_last_name varchar,
mc_username varchar,
mc_uuid varchar,
linked_timestamp datetime default current_timestamp
);
"""
class IgnAuth(
fileName: String,
val plugin: Plugin,
var conn: Connection? = null,
) {
init {
plugin.launch {
initializeConnection(fileName)
}
}
suspend fun initializeConnection(fileName: String) = try {
DriverManager.getConnection("jdbc:sqlite:$fileName").apply {
createStatement().execute(INIT_DB_QUERY)
}.also {
conn = it
}
} catch (e: SQLException) {
plugin.logger.info(e.message)
}
suspend fun close() = conn?.run {
close()
}
suspend fun linkUser(
tgId: Long,
tgUsername: String? = null,
tgFirstName: String,
tgLastName: String? = null,
minecraftUsername: String?,
minecraftUuid: String,
): Boolean = conn?.stmt(
"""
insert into user (
tg_id,
tg_username,
tg_first_name,
tg_last_name,
mc_uuid,
mc_username,
)
values (?, ?, ?, ?, ?, ?)
""",
tgId,
tgUsername,
tgFirstName,
tgLastName,
minecraftUuid,
minecraftUsername,
)?.run {
execute()
} ?: false
suspend fun getLinkedUserByIgn(ign: String) =
conn?.stmt("select * from user where mc_uuid = ?", ign)?.first {
toLinkedUser()
}
suspend fun getLinkedUserByTgId(id: Long) =
conn?.stmt("select * from user where tg_id = ?", id)?.first {
toLinkedUser()
}
suspend fun unlinkUserByTgId(id: Long) =
conn?.stmt("delete from user where tg_id = ?", id)?.run {
executeUpdate() > 0
}
suspend fun getAllLinkedUsers() =
conn?.stmt("select * from user")?.map { toLinkedUser() }
}

View File

@ -1,44 +1,53 @@
package org.kraftwerk28.spigot_tg_bridge
import org.bukkit.event.HandlerList
import org.bukkit.plugin.java.JavaPlugin
import java.lang.Exception
import org.kraftwerk28.spigot_tg_bridge.Constants as C
class Plugin : JavaPlugin() {
class Plugin : AsyncJavaPlugin() {
var tgBot: TgBot? = null
var eventHandler: EventHandler? = null
private var eventHandler: EventHandler? = null
var config: Configuration? = null
var ignAuth: IgnAuth? = null
override fun onEnable() {
try {
config = Configuration(this)
} catch (e: Exception) {
logger.warning(e.message)
return
}
config?.let { config ->
override suspend fun onEnableAsync() = try {
config = Configuration(this).also { config ->
if (!config.isEnabled) return
val cmdHandler = CommandHandler(this)
if (config.enableIgnAuth) {
val dbFilePath = dataFolder.resolve("spigot-tg-bridge.sqlite")
ignAuth = IgnAuth(
fileName = dbFilePath.absolutePath,
plugin = this,
)
}
tgBot?.run { stop() }
tgBot = TgBot(this, config).also { bot ->
eventHandler = EventHandler(bot, config).also {
bot.startPolling()
eventHandler = EventHandler(this, config, bot).also {
server.pluginManager.registerEvents(it, this)
}
}
getCommand(C.COMMANDS.PLUGIN_RELOAD)?.setExecutor(cmdHandler)
config.serverStartMessage?.let { message ->
tgBot?.sendMessageToTelegram(message)
getCommand(C.COMMANDS.PLUGIN_RELOAD)?.run {
setExecutor(CommandHandler(this@Plugin))
}
config.serverStartMessage?.let {
tgBot?.sendMessageToTelegram(it)
}
}
} catch (e: Exception) {
// Configuration file is missing or incomplete
logger.warning(e.message)
}
override fun onDisable() {
config?.let { config ->
if (!config.isEnabled) return
override suspend fun onDisableAsync() {
config?.let fn@{ config ->
if (!config.isEnabled)
return@fn
config.serverStopMessage?.let {
tgBot?.sendMessageToTelegram(it, blocking = true)
tgBot?.sendMessageToTelegram(it)
}
eventHandler?.let { HandlerList.unregisterAll(it) }
tgBot?.run { stop() }
@ -66,14 +75,14 @@ class Plugin : JavaPlugin() {
.also { server.broadcastMessage(it) }
}
fun reload() {
suspend fun reload() {
config = Configuration(this).also { config ->
if (!config.isEnabled) return
logger.info(C.INFO.reloading)
eventHandler?.let { HandlerList.unregisterAll(it) }
tgBot?.run { stop() }
tgBot = TgBot(this, config).also { bot ->
eventHandler = EventHandler(bot, config).also {
eventHandler = EventHandler(this, config, bot).also {
server.pluginManager.registerEvents(it, this)
}
}

View File

@ -4,48 +4,8 @@ import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import com.google.gson.annotations.SerializedName as Name
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

View File

@ -1,14 +1,11 @@
package org.kraftwerk28.spigot_tg_bridge
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import retrofit2.Call
import retrofit2.Retrofit
@ -16,75 +13,84 @@ import retrofit2.converter.gson.GsonConverterFactory
import java.time.Duration
import org.kraftwerk28.spigot_tg_bridge.Constants as C
typealias UpdateRequest = Call<TgApiService.TgResponse<List<TgApiService.Update>>>?
typealias UpdateRequest = Call<TgResponse<List<Update>>>?
class TgBot(
private val plugin: Plugin,
private val config: Configuration,
private val pollTimeout: Int = 30,
) {
private val api: TgApiService
private val client: OkHttpClient
private val updateChan = Channel<TgApiService.Update>()
private val scope = CoroutineScope(Dispatchers.Default)
private val pollJob: Job
private val handlerJob: Job
private val updateChan = Channel<Update>()
private var pollJob: Job? = null
private var handlerJob: Job? = null
private var currentOffset: Long = -1
private var me: TgApiService.User
private var commandRegex: Regex
private val commandMap = config.commands.run {
mapOf(
online to ::onlineHandler,
time to ::timeHandler,
chatID to ::chatIdHandler,
)
}
private var me: User? = null
private var commandRegex: Regex? = null
private val commandMap: Map<String?, suspend (u: Update) -> Unit> =
config.commands.run {
mapOf(
online to ::onlineHandler,
time to ::timeHandler,
chatID to ::chatIdHandler,
linkIgn to ::linkIgnHandler,
getAllLinked to ::getLinkedUsersHandler,
)
}
init {
client = OkHttpClient
.Builder()
.readTimeout(Duration.ZERO)
.build()
api = Retrofit.Builder()
.baseUrl("https://api.telegram.org/bot${config.botToken}/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(TgApiService::class.java)
}
runBlocking {
me = api.getMe().result!!
// I intentionally 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.deleteWebhook(true)
api.setMyCommands(commands)
}
private suspend fun initialize() {
me = api.getMe().result!!
// I intentionally 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 { BotCommand(it.first!!, it.second) }
.let { SetMyCommands(it) }
api.deleteWebhook(dropPendingUpdates = true)
api.setMyCommands(commands)
}
suspend fun startPolling() {
initialize()
pollJob = initPolling()
handlerJob = initHandler()
}
private fun initPolling() = scope.launch {
suspend fun stop() {
pollJob?.cancelAndJoin()
handlerJob?.join()
}
private fun initPolling() = plugin.launch {
loop@ while (true) {
try {
api.getUpdates(offset = currentOffset, timeout = pollTimeout)
.result?.let { updates ->
if (!updates.isEmpty()) {
updates.forEach { updateChan.send(it) }
currentOffset = updates.last().updateId + 1
}
api.getUpdates(
offset = currentOffset,
timeout = config.pollTimeout,
).result?.let { updates ->
if (!updates.isEmpty()) {
updates.forEach { updateChan.send(it) }
currentOffset = updates.last().updateId + 1
}
}
} catch (e: Exception) {
when (e) {
is CancellationException -> break@loop
@ -98,7 +104,7 @@ class TgBot(
updateChan.close()
}
private fun initHandler() = scope.launch {
private fun initHandler() = plugin.launch {
updateChan.consumeEach {
try {
handleUpdate(it)
@ -108,28 +114,20 @@ class TgBot(
}
}
suspend fun handleUpdate(update: TgApiService.Update) {
suspend fun handleUpdate(update: Update) {
// Ignore PM or channel
if (listOf("private", "channel").contains(update.message?.chat?.type))
return
update.message?.text?.let {
commandRegex.matchEntire(it)?.groupValues?.let {
val (command) = it
commandMap.get(command)?.let { it(update) }
commandRegex?.matchEntire(it)?.groupValues?.let {
commandMap.get(it[1])?.let { it(update) }
} ?: run {
onTextHandler(update)
}
}
}
fun stop() {
runBlocking {
pollJob.cancelAndJoin()
handlerJob.join()
}
}
private suspend fun timeHandler(update: TgApiService.Update) {
private suspend fun timeHandler(update: Update) {
val msg = update.message!!
if (!config.allowedChats.contains(msg.chat.id)) {
return
@ -156,7 +154,7 @@ class TgBot(
api.sendMessage(msg.chat.id, text, replyToMessageId = msg.messageId)
}
private suspend fun onlineHandler(update: TgApiService.Update) {
private suspend fun onlineHandler(update: Update) {
val msg = update.message!!
if (!config.allowedChats.contains(msg.chat.id)) {
return
@ -172,7 +170,7 @@ class TgBot(
api.sendMessage(msg.chat.id, text, replyToMessageId = msg.messageId)
}
private suspend fun chatIdHandler(update: TgApiService.Update) {
private suspend fun chatIdHandler(update: Update) {
val msg = update.message!!
val chatId = msg.chat.id
val text = """
@ -186,7 +184,24 @@ class TgBot(
api.sendMessage(chatId, text, replyToMessageId = msg.messageId)
}
private suspend fun onTextHandler(update: TgApiService.Update) {
private suspend fun linkIgnHandler(update: Update) {
val tgUser = update.message!!.from!!
val mcUuid = getMinecraftUuidByUsername(update.message.text!!)
if (mcUuid == null) {
// Respond...
return
}
val linked = plugin.ignAuth?.linkUser(
tgId = tgUser.id,
tgFirstName = tgUser.firstName,
tgLastName = tgUser.lastName,
minecraftUsername = tgUser.username,
minecraftUuid = mcUuid,
)
println(tgUser.toString())
}
private suspend fun onTextHandler(update: Update) {
val msg = update.message!!
if (!config.logFromTGtoMC || msg.from == null)
return
@ -197,22 +212,34 @@ class TgBot(
)
}
fun sendMessageToTelegram(
text: String,
username: String? = null,
blocking: Boolean = false,
) {
private suspend fun getLinkedUsersHandler(update: Update) {
val linkedUsers = plugin.ignAuth?.run {
getAllLinkedUsers()
} ?: listOf()
if (linkedUsers.isEmpty()) {
api.sendMessage(update.message!!.chat.id, "No linked users.")
} else {
val text = "<b>Linked users:</b>\n" +
linkedUsers.mapIndexed { i, dbUser ->
"${i + 1}. ${dbUser.fullName()}"
}.joinToString("\n")
api.sendMessage(update.message!!.chat.id, text)
}
}
suspend fun sendMessageToTelegram(text: String, username: String? = null) {
val formatted = username?.let {
config.telegramFormat
.replace(C.USERNAME_PLACEHOLDER, username.fullEscape())
.replace(C.MESSAGE_TEXT_PLACEHOLDER, text.escapeHtml())
} ?: text
scope.launch {
config.allowedChats.forEach { chatId ->
api.sendMessage(chatId, formatted)
}
}.also {
if (blocking) runBlocking { it.join() }
config.allowedChats.forEach { chatId ->
api.sendMessage(chatId, formatted)
}
// plugin.launch {
// config.allowedChats.forEach { chatId ->
// api.sendMessage(chatId, formatted)
// }
// }
}
}

View File

@ -1,6 +1,12 @@
package org.kraftwerk28.spigot_tg_bridge
import com.vdurmont.emoji.EmojiParser
import java.io.BufferedReader
import java.net.HttpURLConnection
import java.net.URL
import java.sql.Connection
import java.sql.PreparedStatement
import java.sql.ResultSet
fun String.escapeHtml() = this
.replace("&", "&amp;")
@ -18,7 +24,72 @@ fun String.fullEscape() = escapeHTML().escapeColorCodes()
fun String.escapeEmoji() = EmojiParser.parseToAliases(this)
fun TgApiService.User.rawUserMention(): String =
fun User.rawUserMention(): String =
(if (firstName.length < 2) null else firstName)
?: username
?: lastName!!
fun DbLinkedUser.fullName() = tgFirstName + (tgLastName?.let { " $it" } ?: "")
fun Connection.stmt(query: String, vararg args: Any?) =
prepareStatement(query).apply {
args.zip(1..args.size).forEach { (arg, i) ->
when (arg) {
is String -> setString(i, arg)
is Long -> setLong(i, arg)
is Int -> setInt(i, arg)
}
}
}
suspend fun checkMinecraftLicense(playerUuid: String): Boolean = try {
val urlString = "https://api.mojang.com/user/profiles/$playerUuid/names"
val conn = (URL(urlString).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
}
conn.responseCode == 200
} catch (e: Exception) {
e.printStackTrace()
false
}
suspend fun getMinecraftUuidByUsername(username: String): String? = try {
val urlString = "https://api.mojang.com/users/profiles/minecraft/$username"
val conn = (URL(urlString).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
}
val regex = "\"name\"\\s*:\\s*\"(\\w+)\"".toRegex()
val body = BufferedReader(conn.inputStream.reader()).readText()
regex.matchEntire(body)?.groupValues?.get(1)
} catch (e: Exception) {
e.printStackTrace()
null
}
fun ResultSet.toLinkedUser() = DbLinkedUser(
tgId = getLong("tg_id"),
tgUsername = getString("tg_username"),
tgFirstName = getString("tg_first_name"),
tgLastName = getString("tg_last_name"),
createdTimestamp = getDate("linked_timestamp"),
minecraftUsername = getString("mc_username"),
minecraftUuid = getString("mc_uuid"),
)
fun <T> PreparedStatement.first(convertFn: ResultSet.() -> T): T? {
val result = executeQuery()
return if (result.next()) {
result.convertFn()
} else {
null
}
}
fun <T> PreparedStatement.map(convertFn: ResultSet.() -> T): List<T> {
val resultSet = executeQuery()
val result = mutableListOf<T>()
while (resultSet.next()) {
result.add(resultSet.convertFn())
}
return result
}