diff --git a/core/src/main/kotlin/io/heapy/kotbot/bot/_kotbot.kt b/core/src/main/kotlin/io/heapy/kotbot/bot/_kotbot.kt index 0c1704f..b938136 100644 --- a/core/src/main/kotlin/io/heapy/kotbot/bot/_kotbot.kt +++ b/core/src/main/kotlin/io/heapy/kotbot/bot/_kotbot.kt @@ -32,11 +32,15 @@ public data class Kotbot( requestTimeout = 60_000 } }, - public val json: Json = Json { - ignoreUnknownKeys = true - }, + public val json: Json = kotbotJson, ) +public val kotbotJson: Json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false +} + public suspend fun Kotbot.receiveUpdates( timeout: Int = 50, limit: Int = 100, @@ -150,7 +154,7 @@ public suspend inline fun Kotbot.requestForJson( } } -private inline fun logger(): Logger = +internal inline fun logger(): Logger = LoggerFactory.getLogger(T::class.java) private val log = logger() diff --git a/core/src/test/kotlin/io/heapy/kotbot/bot/Tests.kt b/core/src/test/kotlin/io/heapy/kotbot/bot/Tests.kt index 9bf8d01..15e62b2 100644 --- a/core/src/test/kotlin/io/heapy/kotbot/bot/Tests.kt +++ b/core/src/test/kotlin/io/heapy/kotbot/bot/Tests.kt @@ -359,6 +359,9 @@ class KotbotTest { } companion object { + private var offset: Int? = null + private val log = logger() + private val env = dotenv() private val kotbot = Kotbot( @@ -373,13 +376,26 @@ class KotbotTest { @JvmStatic @BeforeAll fun setUp() = runBlocking { + log.info("Drain updates") + + kotbot + .execute( + GetUpdates( + limit = 100, + timeout = 0, + ) + ) + .also { + log.info("Drained updates: {}", it.size) + } + kotbot.execute( SendMessage( chat_id = qaUserId.chatId, text = """ *Kotbot smoke test started\!* - - Job: [${env["CI_JOB_ID"]}](${env["CI_JOB_URL"]}) + + Github run [${env["GITHUB_RUN_ID"]}](${env["GITHUB_SERVER_URL"]}/${env["GITHUB_REPOSITORY"]}/actions/runs/${env["GITHUB_RUN_ID"]}) """.trimIndent(), parse_mode = "MarkdownV2", disable_web_page_preview = true, @@ -396,10 +412,10 @@ class KotbotTest { SendMessage( chat_id = qaUserId.chatId, text = """ - *Kotbot smoke test finished\!* - - Job: [${env.get("CI_JOB_ID")}](${env["CI_JOB_URL"]}) - """.trimIndent(), + *Kotbot smoke test finished\!* + + Github run [${env["GITHUB_RUN_ID"]}](${env["GITHUB_SERVER_URL"]}/${env["GITHUB_REPOSITORY"]}/actions/runs/${env["GITHUB_RUN_ID"]}) + """.trimIndent(), parse_mode = "MarkdownV2", disable_web_page_preview = true ) @@ -417,10 +433,14 @@ class KotbotTest { kotbot .execute( GetUpdates( - offset = null, + offset = offset, allowed_updates = listOf("callback_query") ) ) + .onEach { + offset = it.update_id + 1 + println("Received update: ${it.update_id}") + } .find { (it.callback_query?.message?.message_id == message.message_id) && (it.callback_query?.from?.id == qaUserId) diff --git a/core/src/test/kotlin/io/heapy/kotbot/bot/methods/GetUpdatesTest.kt b/core/src/test/kotlin/io/heapy/kotbot/bot/methods/GetUpdatesTest.kt new file mode 100644 index 0000000..aa74bc8 --- /dev/null +++ b/core/src/test/kotlin/io/heapy/kotbot/bot/methods/GetUpdatesTest.kt @@ -0,0 +1,26 @@ +package io.heapy.kotbot.bot.methods + +import io.heapy.kotbot.bot.kotbotJson +import io.heapy.kotbot.bot.method.GetUpdates +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class GetUpdatesTest { + @Test + fun `test get updates method`() { + val request = kotbotJson.encodeToString( + GetUpdates.serializer(), + GetUpdates( + offset = 1, + limit = 100, + timeout = 0, + allowed_updates = listOf("message", "edited_channel_post", "callback_query"), + ) + ) + + assertEquals( + """{"offset":1,"limit":100,"timeout":0,"allowed_updates":["message","edited_channel_post","callback_query"]}""", + request + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dd75f7d..a751e34 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ coroutines = "1.7.3" flyway = "9.21.1" hikari = "5.0.1" +jooq = "3.18.6" jsoup = "1.16.1" junit = "5.10.0" kotlin = "1.9.0" @@ -16,6 +17,9 @@ serialization = "1.5.1" [libraries] flyway = { module = "org.flywaydb:flyway-core", version.ref = "flyway" } +jooq-core = { module = "org.jooq:jooq", version.ref = "jooq" } +jooq-codegen = { module = "org.jooq:jooq-codegen", version.ref = "jooq" } + jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } junit = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 6343113..7c6b751 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,4 +6,5 @@ include( "core", "core-gen", "tgkotbot", + "tgkotbot-dataops", ) diff --git a/tgkotbot-dataops/build.gradle.kts b/tgkotbot-dataops/build.gradle.kts new file mode 100644 index 0000000..ed18cdd --- /dev/null +++ b/tgkotbot-dataops/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(libs.jooq.codegen) + implementation(libs.flyway) + implementation(libs.postgresql) + implementation(libs.logback) +} diff --git a/tgkotbot/src/main/kotlin/io/heapy/kotbot/KotlinChatsBot.kt b/tgkotbot/src/main/kotlin/io/heapy/kotbot/KotlinChatsBot.kt index 59a3989..6d5d97a 100644 --- a/tgkotbot/src/main/kotlin/io/heapy/kotbot/KotlinChatsBot.kt +++ b/tgkotbot/src/main/kotlin/io/heapy/kotbot/KotlinChatsBot.kt @@ -14,7 +14,6 @@ import io.micrometer.core.instrument.Tags import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.toList class KotlinChatsBot( private val kotbot: Kotbot, @@ -42,8 +41,8 @@ class KotlinChatsBot( try { val command = commands.find { command -> command.name == update.name - && command.context == update.context - && command.access.isAllowed(update.access) + && command.context == update.context + && command.access.isAllowed(update.access) } ?: return false command.execute(kotbot, update) @@ -72,25 +71,10 @@ class KotlinChatsBot( get() = message?.text?.split(' ')?.getOrNull(0) internal suspend fun executeRules(update: Update) { - rules - .map { rule -> rule to rule.validate(update) } - .flatMap { (rule, flow) -> - try { - flow.toList().also { actions -> - if (actions.isNotEmpty()) { - recordRuleTrigger(rule) - } - } - } catch (e: Exception) { - log.error("Exception in rule {}", rule, e) - recordRuleFailure(rule) - listOf() - } - } - .distinct() - .forEach { - kotbot.executeSafely(it) - } + val actions = Actions() + return rules.forEach { rule -> + rule.validate(kotbot, update, actions) + } } internal fun recordRuleTrigger(rule: Rule) { @@ -114,9 +98,9 @@ class KotlinChatsBot( private val log = logger() -internal suspend fun Kotbot.executeSafely( - method: Method -): Response? { +internal suspend fun , Result> Kotbot.executeSafely( + method: Request, +): Result? { return try { execute(method) } catch (e: Exception) { diff --git a/tgkotbot/src/main/kotlin/io/heapy/kotbot/Rules.kt b/tgkotbot/src/main/kotlin/io/heapy/kotbot/Rules.kt index 6c31594..9b67825 100644 --- a/tgkotbot/src/main/kotlin/io/heapy/kotbot/Rules.kt +++ b/tgkotbot/src/main/kotlin/io/heapy/kotbot/Rules.kt @@ -1,50 +1,70 @@ package io.heapy.kotbot +import io.heapy.kotbot.bot.Kotbot import io.heapy.kotbot.bot.Method import io.heapy.kotbot.bot.model.Update import io.heapy.kotbot.configuration.CasConfiguration import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf import kotlinx.serialization.Serializable interface Rule { - fun validate(update: Update): Flow> + suspend fun validate( + kotbot: Kotbot, + update: Update, + actions: Actions, + ) } -private val LOGGER = logger() +class Actions { + private val actions = mutableSetOf>() + + suspend fun , Result> runIfNew( + action: Request, + block: suspend (Request) -> Unit, + ) { + if (action !in actions) { + actions += action + block(action) + } + } +} + +private val log = logger() class DeleteJoinRule : Rule { - override fun validate(update: Update): Flow> { + override suspend fun validate( + kotbot: Kotbot, + update: Update, + actions: Actions, + ) { + update.message?.let { message -> if (!message.new_chat_members.isNullOrEmpty()) { - LOGGER.info("Delete joined users message ${message.new_chat_members}") - return flowOf( - message.delete, - ) + actions.runIfNew(message.delete) { + log.info("Delete joined users message ${message.new_chat_members}") + kotbot.executeSafely(it) + } } } - - return emptyFlow() } } class DeleteHelloRule : Rule { - override fun validate(update: Update): Flow> { + override suspend fun validate( + kotbot: Kotbot, + update: Update, + actions: Actions, + ) { update.anyText { text, message -> if (strings.contains(text.lowercase())) { - LOGGER.info("Delete hello message ${message.text} from ${message.from?.info}") - return flowOf( - message.delete, - ) + actions.runIfNew(message.delete) { + log.info("Delete hello message ${message.text} from ${message.from?.info}") + kotbot.executeSafely(it) + } } } - - return emptyFlow() } companion object { @@ -57,18 +77,22 @@ class DeleteHelloRule : Rule { } class LongTimeNoSeeRule : Rule { - override fun validate(update: Update): Flow> { + override suspend fun validate( + kotbot: Kotbot, + update: Update, + actions: Actions, + ) { update.anyText { text, message -> if (strings.contains(text.lowercase())) { - LOGGER.info("Delete spam ${message.text} from ${message.from?.info}") - return flowOf( - message.delete, - message.banFrom, - ) + log.info("Delete spam ${message.text} from ${message.from?.info}") + actions.runIfNew(message.delete) { + kotbot.executeSafely(it) + } + actions.runIfNew(message.banFrom) { + kotbot.executeSafely(it) + } } } - - return emptyFlow() } companion object { @@ -81,18 +105,22 @@ class LongTimeNoSeeRule : Rule { } class KasperskyCareersRule : Rule { - override fun validate(update: Update): Flow> { + override suspend fun validate( + kotbot: Kotbot, + update: Update, + actions: Actions, + ) { update.anyText { text, message -> if (strings.contains(text.lowercase())) { - LOGGER.info("Delete spam ${message.text} from ${message.from?.info}") - return flowOf( - message.delete, - message.banFrom, - ) + log.info("Delete spam ${message.text} from ${message.from?.info}") + actions.runIfNew(message.delete) { + kotbot.executeSafely(it) + } + actions.runIfNew(message.banFrom) { + kotbot.executeSafely(it) + } } } - - return emptyFlow() } companion object { @@ -103,19 +131,21 @@ class KasperskyCareersRule : Rule { } class DeleteSwearingRule : Rule { - override fun validate(update: Update): Flow> { + override suspend fun validate( + kotbot: Kotbot, + update: Update, + actions: Actions, + ) { update.anyText { text, message -> val normalizedText = text.lowercase() val isSwearing = strings.any { normalizedText.contains(it) } if (isSwearing) { - LOGGER.info("Delete message with swearing ${message.text} from ${message.from?.info}") - return flowOf( - message.delete, - ) + log.info("Delete message with swearing ${message.text} from ${message.from?.info}") + actions.runIfNew(message.delete) { + kotbot.executeSafely(it) + } } } - - return emptyFlow() } companion object { @@ -130,23 +160,27 @@ class DeleteSwearingRule : Rule { } class DeleteSpamRule : Rule { - override fun validate(update: Update): Flow> { + override suspend fun validate( + kotbot: Kotbot, + update: Update, + actions: Actions, + ) { update.anyText { text, message -> val kick = shorteners.any { shorter -> text.contains(shorter) } if (kick) { - LOGGER.info("Delete message with shortened link $text from ${message.from?.info}") + log.info("Delete message with shortened link $text from ${message.from?.info}") - return flowOf( - message.delete, - message.banFrom, - ) + actions.runIfNew(message.delete) { + kotbot.executeSafely(it) + } + actions.runIfNew(message.banFrom) { + kotbot.executeSafely(it) + } } } - - return emptyFlow() } companion object { @@ -165,18 +199,20 @@ class DeleteSpamRule : Rule { * Rule to remove messages with attached audio. */ class DeleteVoiceMessageRule : Rule { - override fun validate(update: Update): Flow> { + override suspend fun validate( + kotbot: Kotbot, + update: Update, + actions: Actions, + ) { update.anyMessage?.let { message -> if (message.voice != null) { - LOGGER.info("Delete voice message from ${message.from?.info}.") + log.info("Delete voice message from ${message.from?.info}.") - return flowOf( - message.delete, - ) + actions.runIfNew(message.delete) { + kotbot.executeSafely(it) + } } } - - return emptyFlow() } } @@ -184,18 +220,20 @@ class DeleteVoiceMessageRule : Rule { * Rule to remove messages with attached audio. */ class DeleteVideoNoteRule : Rule { - override fun validate(update: Update): Flow> { + override suspend fun validate( + kotbot: Kotbot, + update: Update, + actions: Actions, + ) { update.anyMessage?.let { message -> if (message.video_note != null) { - LOGGER.info("Delete video note message from ${message.from?.info}.") + log.info("Delete video note message from ${message.from?.info}.") - return flowOf( - message.delete, - ) + actions.runIfNew(message.delete) { + kotbot.executeSafely(it) + } } } - - return emptyFlow() } } @@ -204,23 +242,29 @@ class DeleteVideoNoteRule : Rule { * It's not covered by chat settings, since they don't apply on admins */ class DeleteStickersRule : Rule { - override fun validate(update: Update): Flow> { + override suspend fun validate( + kotbot: Kotbot, + update: Update, + actions: Actions, + ) { update.anyMessage?.let { message -> if (message.sticker != null) { - LOGGER.info("Delete sticker-message from ${message.from?.info}.") + log.info("Delete sticker-message from ${message.from?.info}.") - return flowOf( - message.delete, - ) + actions.runIfNew(message.delete) { + kotbot.executeSafely(it) + } } } - - return emptyFlow() } } class DeletePropagandaRule : Rule { - override fun validate(update: Update): Flow> { + override suspend fun validate( + kotbot: Kotbot, + update: Update, + actions: Actions, + ) { update.anyMessage?.let { message -> val hasOffensiveText = listOfNotNull( message.from?.first_name, @@ -231,15 +275,13 @@ class DeletePropagandaRule : Rule { } if (hasOffensiveText) { - LOGGER.info("Delete flag-message from ${message.from?.info}.") + log.info("Delete flag-message from ${message.from?.info}.") - return flowOf( - message.delete, - ) + actions.runIfNew(message.delete) { + kotbot.executeSafely(it) + } } } - - return emptyFlow() } } @@ -247,20 +289,28 @@ class CombotCasRule( private val client: HttpClient, private val casConfiguration: CasConfiguration, ) : Rule { - override fun validate(update: Update): Flow> = flow { + override suspend fun validate( + kotbot: Kotbot, + update: Update, + actions: Actions, + ) { update.anyMessage?.let { message -> val userId = message.from!!.id if (!casConfiguration.allowlist.contains(userId)) { val response = client.get("https://api.cas.chat/check?user_id=$userId").body() if (response.ok) { - LOGGER.info("User ${message.from?.info} is CAS banned") - emit(message.delete) - emit(message.banFrom) + log.info("User ${message.from?.info} is CAS banned") + actions.runIfNew(message.delete) { + kotbot.executeSafely(it) + } + actions.runIfNew(message.banFrom) { + kotbot.executeSafely(it) + } } else { - LOGGER.info("User ${message.from?.info} is NOT CAS banned") + log.info("User ${message.from?.info} is NOT CAS banned") } } else { - LOGGER.info("User ${message.from?.info} is in CAS allowlist") + log.info("User ${message.from?.info} is in CAS allowlist") } } }