diff --git a/build.gradle.kts b/build.gradle.kts index 9f5ac1c..b79d47e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } group = "com.github.smaugfm" -version = "0.2.1-alpha" +version = "0.2.2-alpha" val myMavenRepoReadUrl: String by project val myMavenRepoReadUsername: String by project diff --git a/detektBaseline.xml b/detektBaseline.xml index 26369cc..da14a9f 100644 --- a/detektBaseline.xml +++ b/detektBaseline.xml @@ -4,7 +4,7 @@ ComplexMethod:PayeeSuggestor.kt$PayeeSuggestor$private fun jaroSimilarity(s1: String, s2: String): Double LongParameterList:TelegramApi.kt$TelegramApi$( chatId: Any, text: String, parseMode: String? = null, disableWebPagePreview: Boolean? = null, disableNotification: Boolean? = null, replyTo: Int? = null, markup: ReplyKeyboard? = null, ) - LongParameterList:TelegramApi.kt$TelegramApi$( chatId: Any? = null, messageId: Int? = null, inlineMessageId: String? = null, text: String, parseMode: String? = null, disableWebPagePreview: Boolean? = null, markup: InlineKeyboardMarkup? = null ) + LongParameterList:TelegramApi.kt$TelegramApi$( chatId: Any? = null, messageId: Int? = null, inlineMessageId: String? = null, text: String, parseMode: String? = null, disableWebPagePreview: Boolean? = null, markup: InlineKeyboardMarkup? = null, ) LongParameterList:Util.kt$( description: String, mcc: String, amount: String, category: String, payee: String, id: String, ) LoopWithTooManyJumpStatements:PayeeSuggestor.kt$PayeeSuggestor$for (j in start..end) { val c2 = s2[j] if (c1 != c2 || s2Consumed[j]) continue s2Consumed[j] = true matches += 1 if (j < s2MatchIndex) transpositions += 1 s2MatchIndex = j break } MagicNumber:CallbackQueryHandler.kt$CallbackQueryHandler$3 @@ -13,7 +13,6 @@ MagicNumber:PayeeSuggestor.kt$PayeeSuggestor$0.8 MagicNumber:PayeeSuggestor.kt$PayeeSuggestor$3.0 MagicNumber:Util.kt$10.0 - NewLineAtEndOfFile:CurrencyAsStringSerializer.kt$com.github.smaugfm.serializers.CurrencyAsStringSerializer.kt ReturnCount:PayeeSuggestor.kt$PayeeSuggestor$private fun jaroSimilarity(s1: String, s2: String): Double diff --git a/src/main/kotlin/com/github/smaugfm/YnabMono.kt b/src/main/kotlin/com/github/smaugfm/YnabMono.kt index 7bb53d1..851a6ae 100644 --- a/src/main/kotlin/com/github/smaugfm/YnabMono.kt +++ b/src/main/kotlin/com/github/smaugfm/YnabMono.kt @@ -9,7 +9,7 @@ import com.github.ajalt.clikt.parameters.options.required import com.github.ajalt.clikt.parameters.types.int import com.github.smaugfm.events.EventDispatcher import com.github.smaugfm.mono.MonoApi -import com.github.smaugfm.mono.MonoApi.Companion.setupWebhook +import com.github.smaugfm.mono.MonoApi.Companion.setupWebhookAll import com.github.smaugfm.settings.Settings import com.github.smaugfm.telegram.TelegramApi import com.github.smaugfm.telegram.handlers.TelegramHandler @@ -48,14 +48,13 @@ class YnabMono : CliktCommand() { val telegramApi = TelegramApi( settings.telegramBotUsername, settings.telegramBotToken, - settings.mappings.getTelegramChatIds(), ) logger.info("Created telegram api.") val ynabApi = YnabApi(settings.ynabToken, settings.ynabBudgetId) logger.info("Created ynab api.") if (!dontSetWebhook) { - monoApis.setupWebhook(monoWebhookUrl, monoWebhookPort ?: monoWebhookUrl.port) + monoApis.setupWebhookAll(monoWebhookUrl, monoWebhookPort ?: monoWebhookUrl.port) logger.info("Mono webhook setup successful. $monoWebhookUrl") } else { logger.info("Skipping mono webhook setup.") diff --git a/src/main/kotlin/com/github/smaugfm/events/Event.kt b/src/main/kotlin/com/github/smaugfm/events/Event.kt index 787ec21..b3d734a 100644 --- a/src/main/kotlin/com/github/smaugfm/events/Event.kt +++ b/src/main/kotlin/com/github/smaugfm/events/Event.kt @@ -1,5 +1,6 @@ package com.github.smaugfm.events +import com.elbekD.bot.types.CallbackQuery import com.elbekD.bot.types.Message import com.github.smaugfm.mono.MonoWebHookResponseData import com.github.smaugfm.telegram.TransactionActionType @@ -12,7 +13,7 @@ sealed class Event { sealed class Ynab : Event() { data class TransactionAction( - val type: TransactionActionType + val type: TransactionActionType, ) : Ynab(), IEvent } @@ -22,11 +23,8 @@ sealed class Event { val transaction: YnabTransactionDetail, ) : Telegram(), UnitEvent - data class CallbackQueryReceived( - val callbackQueryId: String, - val data: String, - val message: Message - ) : - Telegram(), UnitEvent + data class CallbackQueryReceived(val callbackQuery: CallbackQuery) : Telegram(), UnitEvent + data class RestartCommandReceived(val message: Message, val args: String?) : Telegram(), UnitEvent + data class StopCommandReceived(val message: Message, val args: String?) : Telegram(), UnitEvent } } diff --git a/src/main/kotlin/com/github/smaugfm/events/models.kt b/src/main/kotlin/com/github/smaugfm/events/models.kt index eb86ce8..64f8b36 100644 --- a/src/main/kotlin/com/github/smaugfm/events/models.kt +++ b/src/main/kotlin/com/github/smaugfm/events/models.kt @@ -8,6 +8,22 @@ interface IEventsHandlerRegistrar { fun registerEvents(builder: HandlersBuilder) } +open class CompositeHandler(val handlers: Collection) : IEventsHandlerRegistrar { + override fun registerEvents(builder: HandlersBuilder) { + handlers.forEach { it.registerEvents(builder) } + } +} + +abstract class Handler : IEventsHandlerRegistrar { + final override fun registerEvents(builder: HandlersBuilder) { + builder.apply { + registerHandlerFunctions() + } + } + + abstract fun HandlersBuilder.registerHandlerFunctions() +} + interface IEvent typealias UnitEvent = IEvent diff --git a/src/main/kotlin/com/github/smaugfm/mono/MonoApi.kt b/src/main/kotlin/com/github/smaugfm/mono/MonoApi.kt index 6219eac..49fa0ce 100644 --- a/src/main/kotlin/com/github/smaugfm/mono/MonoApi.kt +++ b/src/main/kotlin/com/github/smaugfm/mono/MonoApi.kt @@ -15,6 +15,7 @@ import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.statement.readText import io.ktor.features.ContentNegotiation +import io.ktor.features.origin import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.request.receive @@ -67,7 +68,7 @@ class MonoApi(private val token: String) { val waitForWebhook = CompletableDeferred() val json = defaultSerializer() - val server = embeddedServer(Netty, port = port) { + val tempServer = embeddedServer(Netty, port = port) { routing { get(url.path) { call.response.status(HttpStatusCode.OK) @@ -78,7 +79,7 @@ class MonoApi(private val token: String) { } } logger.info("Starting webhook setup server...") - server.start(wait = false) + tempServer.start(wait = false) val statusString = try { httpClient.post(url("personal/webhook")) { @@ -89,7 +90,7 @@ class MonoApi(private val token: String) { throw e } waitForWebhook.await() - server.stop(serverStopGracePeriod, serverStopGracePeriod) + tempServer.stop(serverStopGracePeriod, serverStopGracePeriod) return Json.decodeFromString(statusString) } @@ -133,7 +134,12 @@ class MonoApi(private val token: String) { } routing { post(webhook.path) { - logger.info("Webhook queried. Uri: ${call.request.uri}") + call.request.origin.host + logger.info( + "Webhook queried. " + + "Host: ${call.request.origin.remoteHost} " + + "Uri: ${call.request.uri}" + ) val response = call.receive() call.response.status(HttpStatusCode.OK) dispatcher(Event.Mono.NewStatementReceived(response.data)) @@ -141,11 +147,11 @@ class MonoApi(private val token: String) { } } return GlobalScope.launch(context) { - server.start(wait = true).let { Unit } + server.start(wait = true) } } - suspend fun Collection.setupWebhook(webhook: URI, port: Int) = + suspend fun Collection.setupWebhookAll(webhook: URI, port: Int) = this.forEach { it.setWebHook(webhook, port) } } } diff --git a/src/main/kotlin/com/github/smaugfm/telegram/TelegramApi.kt b/src/main/kotlin/com/github/smaugfm/telegram/TelegramApi.kt index 2826a19..419ffc1 100644 --- a/src/main/kotlin/com/github/smaugfm/telegram/TelegramApi.kt +++ b/src/main/kotlin/com/github/smaugfm/telegram/TelegramApi.kt @@ -17,7 +17,6 @@ private val logger = KotlinLogging.logger {} class TelegramApi( botUsername: String, botToken: String, - val allowedChatIds: Set, ) { private val bot: Bot = Bot.createPolling(botUsername, botToken) @@ -51,7 +50,7 @@ class TelegramApi( text: String, parseMode: String? = null, disableWebPagePreview: Boolean? = null, - markup: InlineKeyboardMarkup? = null + markup: InlineKeyboardMarkup? = null, ) { bot.editMessageText( chatId, @@ -76,13 +75,15 @@ class TelegramApi( ): Job { bot.onCallbackQuery { logger.info("Received callbackQuery.\n\t$it") - val chatId = it.from.id - if (chatId !in allowedChatIds) - return@onCallbackQuery - - it.data?.let { data -> - dispatcher(Event.Telegram.CallbackQueryReceived(it.id, data, it.message!!)) - } ?: logger.error("Received callback query without callback_data.\n$it") + dispatcher(Event.Telegram.CallbackQueryReceived(it)) + } + bot.onCommand("/restart") { msg, args -> + logger.info("Received message.\n\t$msg") + dispatcher(Event.Telegram.RestartCommandReceived(msg, args)) + } + bot.onCommand("/stop") { msg, args -> + logger.info("Received message.\n\t$msg") + dispatcher(Event.Telegram.StopCommandReceived(msg, args)) } return GlobalScope.launch(context) { bot.start() } diff --git a/src/main/kotlin/com/github/smaugfm/telegram/handlers/CallbackQueryHandler.kt b/src/main/kotlin/com/github/smaugfm/telegram/handlers/CallbackQueryHandler.kt index 0337fb1..591305a 100644 --- a/src/main/kotlin/com/github/smaugfm/telegram/handlers/CallbackQueryHandler.kt +++ b/src/main/kotlin/com/github/smaugfm/telegram/handlers/CallbackQueryHandler.kt @@ -1,12 +1,13 @@ package com.github.smaugfm.telegram.handlers +import com.elbekD.bot.types.CallbackQuery import com.elbekD.bot.types.InlineKeyboardMarkup import com.elbekD.bot.types.Message import com.elbekD.bot.types.MessageEntity import com.github.smaugfm.events.Event +import com.github.smaugfm.events.Handler import com.github.smaugfm.events.HandlersBuilder import com.github.smaugfm.events.IEventDispatcher -import com.github.smaugfm.events.IEventsHandlerRegistrar import com.github.smaugfm.settings.Mappings import com.github.smaugfm.telegram.TelegramApi import com.github.smaugfm.telegram.TransactionActionType @@ -20,37 +21,46 @@ private val logger = KotlinLogging.logger {} class CallbackQueryHandler( private val telegram: TelegramApi, val mappings: Mappings, -) : IEventsHandlerRegistrar { - override fun registerEvents(builder: HandlersBuilder) { - builder.apply { - registerUnit(this@CallbackQueryHandler::handle) - } +) : Handler() { + + override fun HandlersBuilder.registerHandlerFunctions() { + registerUnit(this@CallbackQueryHandler::handle) } suspend fun handle( dispatch: IEventDispatcher, event: Event.Telegram.CallbackQueryReceived, ) { - val type = TransactionActionType.deserialize(event.data, event.message) + val callbackQuery = event.callbackQuery + if (callbackQuery.from.id !in mappings.getTelegramChatIds()) { + logger.warn("Received Telegram callbackQuery from unknown chatId: ${callbackQuery.from.id}") + return + } + + val (callbackQueryId, data, message) = + extractFromCallbackQuery(callbackQuery) ?: return + + val type = TransactionActionType.deserialize(data, message) ?: return Unit.also { telegram.answerCallbackQuery( - event.callbackQueryId, + callbackQueryId, TelegramHandler.UNKNOWN_ERROR_MSG ) } + logger.info("Found callbackQuery action type $type") val updatedTransaction = dispatch(Event.Ynab.TransactionAction(type)).also { - telegram.answerCallbackQuery(event.callbackQueryId) + telegram.answerCallbackQuery(callbackQueryId) } - val updatedText = updateHTMLStatementMessage(updatedTransaction, event.message) - val updatedMarkup = updateMarkupKeyboard(type, event.message.reply_markup!!) + val updatedText = updateHTMLStatementMessage(updatedTransaction, message) + val updatedMarkup = updateMarkupKeyboard(type, message.reply_markup!!) - if (stripHTMLTagsFromMessage(updatedText) != event.message.text || - updatedMarkup != event.message.reply_markup + if (stripHTMLTagsFromMessage(updatedText) != message.text || + updatedMarkup != message.reply_markup ) { - with(event.message) { + with(message) { telegram.editMessage( chat.id, message_id, @@ -64,7 +74,7 @@ class CallbackQueryHandler( private fun updateHTMLStatementMessage( updatedTransaction: YnabTransactionDetail, - oldMessage: Message + oldMessage: Message, ): String { val oldText = oldMessage.text!! val oldTextLines = oldText.split("\n").filter { it.isNotBlank() } @@ -85,6 +95,18 @@ class CallbackQueryHandler( ) } + private fun extractFromCallbackQuery(callbackQuery: CallbackQuery): Triple? { + val callbackQueryId = callbackQuery.id + val data = callbackQuery.data.takeUnless { it.isNullOrBlank() } + ?: logger.warn("Received Telegram callbackQuery with empty data.\n$callbackQuery") + .let { return null } + val message = + callbackQuery.message ?: logger.warn("Received Telegram callbacQuery with empty message") + .let { return null } + + return Triple(callbackQueryId, data, message) + } + private fun pressedButtons(oldKeyboard: InlineKeyboardMarkup): Set> = oldKeyboard .inline_keyboard @@ -98,7 +120,7 @@ class CallbackQueryHandler( private fun updateMarkupKeyboard( type: TransactionActionType, - oldKeyboard: InlineKeyboardMarkup + oldKeyboard: InlineKeyboardMarkup, ): InlineKeyboardMarkup = formatInlineKeyboard(pressedButtons(oldKeyboard) + type::class) } diff --git a/src/main/kotlin/com/github/smaugfm/telegram/handlers/MessagesHandler.kt b/src/main/kotlin/com/github/smaugfm/telegram/handlers/MessagesHandler.kt new file mode 100644 index 0000000..f7786ef --- /dev/null +++ b/src/main/kotlin/com/github/smaugfm/telegram/handlers/MessagesHandler.kt @@ -0,0 +1,26 @@ +package com.github.smaugfm.telegram.handlers + +import com.github.smaugfm.events.Event +import com.github.smaugfm.events.Handler +import com.github.smaugfm.events.HandlersBuilder +import mu.KotlinLogging +import kotlin.system.exitProcess + +private val logger = KotlinLogging.logger {} + +class MessagesHandler : Handler() { + override fun HandlersBuilder.registerHandlerFunctions() { + registerUnit(this@MessagesHandler::handleRestart) + registerUnit(this@MessagesHandler::handleStop) + } + + private fun handleRestart(event: Event.Telegram.RestartCommandReceived) { + logger.info("Exiting with exit code 1 due to restart command.") + exitProcess(1) + } + + private fun handleStop(event: Event.Telegram.StopCommandReceived) { + logger.info("Exiting with exit code 0 due to stop command.") + exitProcess(0) + } +} diff --git a/src/main/kotlin/com/github/smaugfm/telegram/handlers/SendStatementMessageHandler.kt b/src/main/kotlin/com/github/smaugfm/telegram/handlers/SendStatementMessageHandler.kt index 75e1c53..822e092 100644 --- a/src/main/kotlin/com/github/smaugfm/telegram/handlers/SendStatementMessageHandler.kt +++ b/src/main/kotlin/com/github/smaugfm/telegram/handlers/SendStatementMessageHandler.kt @@ -1,8 +1,8 @@ package com.github.smaugfm.telegram.handlers import com.github.smaugfm.events.Event +import com.github.smaugfm.events.Handler import com.github.smaugfm.events.HandlersBuilder -import com.github.smaugfm.events.IEventsHandlerRegistrar import com.github.smaugfm.settings.Mappings import com.github.smaugfm.telegram.TelegramApi import mu.KotlinLogging @@ -11,11 +11,9 @@ private val logger = KotlinLogging.logger {} class SendStatementMessageHandler( private val telegram: TelegramApi, val mappings: Mappings, -) : IEventsHandlerRegistrar { - override fun registerEvents(builder: HandlersBuilder) { - builder.apply { - registerUnit(this@SendStatementMessageHandler::handle) - } +) : Handler() { + override fun HandlersBuilder.registerHandlerFunctions() { + registerUnit(this@SendStatementMessageHandler::handle) } suspend fun handle( diff --git a/src/main/kotlin/com/github/smaugfm/telegram/handlers/TelegramHandler.kt b/src/main/kotlin/com/github/smaugfm/telegram/handlers/TelegramHandler.kt index a655968..23e3704 100644 --- a/src/main/kotlin/com/github/smaugfm/telegram/handlers/TelegramHandler.kt +++ b/src/main/kotlin/com/github/smaugfm/telegram/handlers/TelegramHandler.kt @@ -1,22 +1,19 @@ package com.github.smaugfm.telegram.handlers -import com.github.smaugfm.events.HandlersBuilder -import com.github.smaugfm.events.IEventsHandlerRegistrar +import com.github.smaugfm.events.CompositeHandler import com.github.smaugfm.settings.Mappings import com.github.smaugfm.telegram.TelegramApi class TelegramHandler( private val telegram: TelegramApi, val mappings: Mappings, -) : IEventsHandlerRegistrar { - lateinit var sendStatementMessage: SendStatementMessageHandler - lateinit var callbackQuery: CallbackQueryHandler - - override fun registerEvents(builder: HandlersBuilder) { - sendStatementMessage = SendStatementMessageHandler(telegram, mappings).apply { registerEvents(builder) } - callbackQuery = CallbackQueryHandler(telegram, mappings).apply { registerEvents(builder) } - } - +) : CompositeHandler( + listOf( + SendStatementMessageHandler(telegram, mappings), + CallbackQueryHandler(telegram, mappings), + MessagesHandler() + ), +) { companion object { const val UNKNOWN_ERROR_MSG = "Произошла непредвиденная ошибка." } diff --git a/src/test/kotlin/com/github/smaugfm/Playground.kt b/src/test/kotlin/com/github/smaugfm/Playground.kt index 5337107..0bcfc2f 100644 --- a/src/test/kotlin/com/github/smaugfm/Playground.kt +++ b/src/test/kotlin/com/github/smaugfm/Playground.kt @@ -6,6 +6,7 @@ import com.github.smaugfm.mono.MonoStatementItem import com.github.smaugfm.mono.MonoWebHookResponseData import com.github.smaugfm.settings.Settings import com.github.smaugfm.telegram.TelegramApi +import com.github.smaugfm.telegram.handlers.SendStatementMessageHandler import com.github.smaugfm.telegram.handlers.TelegramHandler import com.github.smaugfm.util.MCC import com.github.smaugfm.util.PayeeSuggestor @@ -67,12 +68,15 @@ class Playground { val settings = Settings.loadDefault() val telegram = TelegramApi( settings.telegramBotUsername, - settings.telegramBotToken, - settings.mappings.getTelegramChatIds() + settings.telegramBotToken ) val description = "vasa" val monoAccount = "ps7BhBZPtgiR_36jfhYXlg" - val handler = TelegramHandler(telegram, settings.mappings) + val handler = + TelegramHandler(telegram, settings.mappings) + .handlers + .filterIsInstance() + .first() val statementItem = mockk() every { statementItem.time } returns Clock.System.now() - 2.days every { statementItem.amount } returns -11500 @@ -90,7 +94,7 @@ class Playground { every { transaction.id } returns UUID.randomUUID().toString() runBlocking { - handler.sendStatementMessage.handle( + handler.handle( Event.Telegram.SendStatementMessage(monoResponse, transaction) ) } diff --git a/src/test/kotlin/com/github/smaugfm/telegram/handlers/TelegramHandlerTest.kt b/src/test/kotlin/com/github/smaugfm/telegram/handlers/TelegramHandlerTest.kt index d9ec1ed..0eb81f0 100644 --- a/src/test/kotlin/com/github/smaugfm/telegram/handlers/TelegramHandlerTest.kt +++ b/src/test/kotlin/com/github/smaugfm/telegram/handlers/TelegramHandlerTest.kt @@ -2,6 +2,7 @@ package com.github.smaugfm.telegram.handlers import assertk.assertThat import assertk.assertions.isEqualTo +import com.elbekD.bot.types.CallbackQuery import com.elbekD.bot.types.InlineKeyboardButton import com.elbekD.bot.types.InlineKeyboardMarkup import com.elbekD.bot.types.Message @@ -28,7 +29,7 @@ import java.util.UUID import kotlin.time.days class TelegramHandlerTest { - val chatId = 0 + val chatId = 12322 val api = mockk() val mappings = mockk() val sendStatementMessageHandler = SendStatementMessageHandler(api, mappings) @@ -38,8 +39,10 @@ class TelegramHandlerTest { description: String, payee: String, monoAccount: String, - id: String + id: String, ): Pair { + every { mappings.getTelegramChatIds() } returns setOf(chatId) + val statementItem = mockk() every { statementItem.time } returns Clock.System.now() - 2.days every { statementItem.amount } returns -11500 @@ -65,6 +68,7 @@ class TelegramHandlerTest { val monoAccount = "vasility" val payee = "Rozetka" coEvery { api.sendMessage(any(), any(), any(), any(), any(), any(), any()) } returns Unit + every { mappings.getTelegramChatIds() } returns setOf(chatId) every { mappings.getTelegramChatIdAccByMono(any()) } returns chatId every { mappings.getAccountCurrency(any()) } returns Currency.getInstance("UAH") @@ -171,11 +175,10 @@ class TelegramHandlerTest { ) val keyboard = formatInlineKeyboard(emptySet()) - val chatId = 123241231L val messageId = 123413 val messageMock = mockk() { every { text } returns messageText - every { chat.id } returns chatId + every { chat.id } returns chatId.toLong() every { message_id } returns messageId every { entities } returns listOf( MessageEntity( @@ -193,10 +196,14 @@ class TelegramHandlerTest { val dispatcher = mockk() coEvery { dispatcher.invoke>(any()) } returns updatedTransaction - val event = mockk() { - every { callbackQueryId } returns "vasa" + val callbackQueryMock = mockk() { + every { id } returns "vasa" every { data } returns TransactionActionType.MakePayee::class.simpleName!! every { message } returns messageMock + every { from.id } returns chatId + } + val event = mockk() { + every { callbackQuery } returns callbackQueryMock } runBlocking { @@ -210,7 +217,7 @@ class TelegramHandlerTest { coVerify { dispatcher.invoke(Event.Ynab.TransactionAction(TransactionActionType.MakePayee(transactionId, description))) api.editMessage( - chatId, + chatId.toLong(), messageId, text = updatedMessageText, parseMode = "HTML",