diff --git a/botalka/build.gradle.kts b/botalka/build.gradle.kts index 033f14e..d56fb95 100644 --- a/botalka/build.gradle.kts +++ b/botalka/build.gradle.kts @@ -23,6 +23,8 @@ dependencies { implementation("io.projectreactor.kafka:reactor-kafka") testImplementation("org.testcontainers:kafka") + + implementation("io.github.kotlin-telegram-bot.kotlin-telegram-bot:telegram:6.1.0") } val jooqPackageName = "$basePackage.storage.jooq" diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/endpoint/RatingHttpApi.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/endpoint/RatingHttpApi.kt index 3e7656f..510ee62 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/endpoint/RatingHttpApi.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/api/http/endpoint/RatingHttpApi.kt @@ -19,7 +19,6 @@ class RatingHttpApi( ResponseEntity.ok( RatingGrades( grades = service.grades().map { (student, grades) -> - println("$student $grades") StudentGradesMessage( studentId = student.id.number, grades = grades.entries diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/client/telegram/SpringTelegramBot.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/client/telegram/SpringTelegramBot.kt new file mode 100644 index 0000000..2f0043f --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/client/telegram/SpringTelegramBot.kt @@ -0,0 +1,39 @@ +package ru.vityaman.lms.botalka.app.spring.client.telegram + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.annotation.Profile +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Service +import ru.vityaman.lms.botalka.core.external.telegram.BasicTelegramBot +import ru.vityaman.lms.botalka.core.external.telegram.TelegramBot +import ru.vityaman.lms.botalka.core.external.telegram.TelegramChat +import ru.vityaman.lms.botalka.core.external.telegram.TelegramMessage + +@Service +@Profile("production") +class SpringTelegramBot( + @Value("\${external.service.telegram.token}") + token: String, + + @Value("\${external.service.telegram.admin-chat-id}") + adminChatId: Long, +) : TelegramBot by BasicTelegramBot(token) { + private val scope = CoroutineScope(Dispatchers.Default) + private val adminChat = TelegramChat(adminChatId) + + init { + scope.launch { + start() + } + } + + @EventListener(ApplicationReadyEvent::class) + fun notifyAdmin() = runBlocking { + send(adminChat, TelegramMessage("Restarted instance")) + } +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringPublicationConsumer.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringKafkaPublicationConsumer.kt similarity index 89% rename from botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringPublicationConsumer.kt rename to botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringKafkaPublicationConsumer.kt index 50d6abe..c965137 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringPublicationConsumer.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringKafkaPublicationConsumer.kt @@ -10,7 +10,8 @@ import ru.vityaman.lms.botalka.storage.kafka.KafkaProducer import ru.vityaman.lms.botalka.storage.kafka.KafkaTopic @Component -class SpringPublicationConsumer( +@Qualifier(SpringPublicationConfig.BeanName.KAFKA_CONSUMER) +class SpringKafkaPublicationConsumer( @Value("\${broker.bootstrap-servers}") bootstrapServers: String, diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringNotificationTask.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringNotificationTask.kt index e4f5248..78a35a3 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringNotificationTask.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringNotificationTask.kt @@ -1,12 +1,12 @@ package ru.vityaman.lms.botalka.app.spring.task import kotlinx.coroutines.runBlocking +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component -import ru.vityaman.lms.botalka.commons.Consumer import ru.vityaman.lms.botalka.core.logging.Slf4jLog -import ru.vityaman.lms.botalka.core.model.Homework +import ru.vityaman.lms.botalka.core.publication.PublicationConsumer import ru.vityaman.lms.botalka.core.publication.PublicationSupplier import ru.vityaman.lms.botalka.core.publication.logging.loggingNotificationCallbacks import ru.vityaman.lms.botalka.core.publication.task.NotificationTask @@ -19,20 +19,17 @@ class SpringNotificationTask( batchDurationSeconds: Int, supplier: PublicationSupplier, -) { - private val log = Slf4jLog("NotificationTask") + @Qualifier(SpringPublicationConfig.BeanName.TELEGRAM_CONSUMER) + consumer: PublicationConsumer, +) { private val logic = NotificationTask( supplier = supplier, - consumer = object : Consumer { - override suspend fun accept(value: Homework) { - log.info("Consumed $value") - } - }, + consumer = consumer, config = NotificationTask.Config( batchDuration = batchDurationSeconds.seconds, ), - callbacks = loggingNotificationCallbacks(log), + callbacks = loggingNotificationCallbacks(Slf4jLog("NotificationTask")), ) @Scheduled( diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringPublicationConfig.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringPublicationConfig.kt new file mode 100644 index 0000000..6a6a0b2 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringPublicationConfig.kt @@ -0,0 +1,8 @@ +package ru.vityaman.lms.botalka.app.spring.task + +class SpringPublicationConfig { + object BeanName { + const val KAFKA_CONSUMER = "kafkaPublicationConsumer" + const val TELEGRAM_CONSUMER = "telegramPublicationConsumer" + } +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringPublicationTask.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringPublicationTask.kt index 4169752..a316139 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringPublicationTask.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringPublicationTask.kt @@ -18,6 +18,7 @@ import java.util.concurrent.TimeUnit class SpringPublicationTask( homeworks: HomeworkStorage, + @Qualifier(SpringPublicationConfig.BeanName.KAFKA_CONSUMER) consumer: PublicationConsumer, @Qualifier(MainR2dbcConfig.BeanName.TX_ENV) diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringTelegramPublicationConsumer.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringTelegramPublicationConsumer.kt new file mode 100644 index 0000000..774f1a6 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/app/spring/task/SpringTelegramPublicationConsumer.kt @@ -0,0 +1,19 @@ +package ru.vityaman.lms.botalka.app.spring.task + +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import ru.vityaman.lms.botalka.core.external.telegram.TelegramBot +import ru.vityaman.lms.botalka.core.external.telegram.TelegramChat +import ru.vityaman.lms.botalka.core.publication.PublicationConsumer +import ru.vityaman.lms.botalka.core.publication.telegram.TelegramPublicationConsumer + +@Component +@Qualifier(SpringPublicationConfig.BeanName.TELEGRAM_CONSUMER) +class SpringTelegramPublicationConsumer( + telegram: TelegramBot, + + @Value("\${external.service.telegram.admin-chat-id}") + adminChatId: Long, +) : PublicationConsumer by +TelegramPublicationConsumer(telegram, TelegramChat(adminChatId)) diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/external/telegram/BasicTelegramBot.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/external/telegram/BasicTelegramBot.kt new file mode 100644 index 0000000..80f994b --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/external/telegram/BasicTelegramBot.kt @@ -0,0 +1,42 @@ +package ru.vityaman.lms.botalka.core.external.telegram + +import com.github.kotlintelegrambot.bot +import com.github.kotlintelegrambot.dispatch +import com.github.kotlintelegrambot.dispatcher.text +import com.github.kotlintelegrambot.entities.ChatId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class BasicTelegramBot(accessToken: String) : TelegramBot { + private val bot = bot { + token = accessToken + dispatch { + text { + send( + TelegramChat(message.chat.id), + TelegramMessage( + text = mapOf( + "Chat" to message.chat.id, + "From" to message.from?.id, + "Text" to message.text, + ).entries.joinToString(", ") { + "${it.key}: ${it.value}" + }, + ), + ) + } + } + } + + override suspend fun send(chat: TelegramChat, message: TelegramMessage) { + withContext(Dispatchers.IO) { + bot.sendMessage(ChatId.fromId(chat.id), text = message.text).get() + } + } + + override suspend fun start() { + withContext(Dispatchers.IO) { + bot.startPolling() + } + } +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/external/telegram/TelegramBot.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/external/telegram/TelegramBot.kt new file mode 100644 index 0000000..ae0aafb --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/external/telegram/TelegramBot.kt @@ -0,0 +1,6 @@ +package ru.vityaman.lms.botalka.core.external.telegram + +interface TelegramBot { + suspend fun send(chat: TelegramChat, message: TelegramMessage) + suspend fun start() +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/external/telegram/TelegramChat.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/external/telegram/TelegramChat.kt new file mode 100644 index 0000000..ee068cf --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/external/telegram/TelegramChat.kt @@ -0,0 +1,3 @@ +package ru.vityaman.lms.botalka.core.external.telegram + +data class TelegramChat(val id: Long) diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/external/telegram/TelegramMessage.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/external/telegram/TelegramMessage.kt new file mode 100644 index 0000000..1f5ec61 --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/external/telegram/TelegramMessage.kt @@ -0,0 +1,3 @@ +package ru.vityaman.lms.botalka.core.external.telegram + +data class TelegramMessage(val text: String) diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/publication/telegram/TelegramPublicationConsumer.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/publication/telegram/TelegramPublicationConsumer.kt new file mode 100644 index 0000000..6bf12ef --- /dev/null +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/core/publication/telegram/TelegramPublicationConsumer.kt @@ -0,0 +1,28 @@ +package ru.vityaman.lms.botalka.core.publication.telegram + +import kotlinx.coroutines.delay +import ru.vityaman.lms.botalka.core.external.telegram.TelegramBot +import ru.vityaman.lms.botalka.core.external.telegram.TelegramChat +import ru.vityaman.lms.botalka.core.external.telegram.TelegramMessage +import ru.vityaman.lms.botalka.core.model.Homework +import ru.vityaman.lms.botalka.core.publication.PublicationConsumer +import kotlin.time.Duration.Companion.seconds + +class TelegramPublicationConsumer( + private val telegram: TelegramBot, + private val adminChat: TelegramChat, +) : PublicationConsumer { + override suspend fun accept(value: Homework) { + val text = buildString { + append("Published homework '${value.title.text}'!\n") + append("\n") + append("${value.description}\n") + append("\n") + append("MaxScore: ${value.maxScore.value}\n") + append("Deadline: ${value.deadlineMoment}\n") + append("Id: ${value.id.number}\n") + } + telegram.send(adminChat, TelegramMessage(text)) + delay(1.seconds) + } +} diff --git a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/kafka/KafkaProducer.kt b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/kafka/KafkaProducer.kt index cac37ed..ba1a2b0 100644 --- a/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/kafka/KafkaProducer.kt +++ b/botalka/src/main/kotlin/ru/vityaman/lms/botalka/storage/kafka/KafkaProducer.kt @@ -34,7 +34,6 @@ class KafkaProducer( .let { SenderRecord.create(it, null) } .let { mono { it } } kafka.send(record).asFlow().collect {} - println("Sent $value") } data class Config( diff --git a/botalka/src/main/resources/application-test.yml b/botalka/src/main/resources/application-test.yml index efabba2..1ce959a 100644 --- a/botalka/src/main/resources/application-test.yml +++ b/botalka/src/main/resources/application-test.yml @@ -11,6 +11,9 @@ external: clientSecret: fake-yandex-client-secret login: url: http://localhost:8080/fake/external/service/yandex/login + telegram: + token: fake-telegram-bot-api-token + admin-chat-id: 0 task: scheduled: publication: diff --git a/botalka/src/main/resources/application.yml b/botalka/src/main/resources/application.yml index e583850..7cb1f4f 100644 --- a/botalka/src/main/resources/application.yml +++ b/botalka/src/main/resources/application.yml @@ -1,4 +1,6 @@ spring: + profiles: + active: production application: name: botalka datasource: @@ -66,6 +68,9 @@ external: clientSecret: ${LMS_YANDEX_CLIENT_SECRET} login: url: https://login.yandex.ru + telegram: + token: ${LMS_TEST_TELEGRAM_BOT_API_TOKEN} + admin-chat-id: ${LMS_TEST_TELEGRAM_ADMIN_CHAT_ID} task: scheduled: publication: diff --git a/botalka/src/test/kotlin/ru/vityaman/lms/botalka/app/spring/client/telegram/FakeTelegramBot.kt b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/app/spring/client/telegram/FakeTelegramBot.kt new file mode 100644 index 0000000..26273a8 --- /dev/null +++ b/botalka/src/test/kotlin/ru/vityaman/lms/botalka/app/spring/client/telegram/FakeTelegramBot.kt @@ -0,0 +1,22 @@ +package ru.vityaman.lms.botalka.app.spring.client.telegram + +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Primary +import org.springframework.stereotype.Service +import ru.vityaman.lms.botalka.core.external.telegram.TelegramBot +import ru.vityaman.lms.botalka.core.external.telegram.TelegramChat +import ru.vityaman.lms.botalka.core.external.telegram.TelegramMessage + +@Primary +@Service +class FakeTelegramBot : TelegramBot { + private val log = LoggerFactory.getLogger("FakeTelegramBot") + + override suspend fun send(chat: TelegramChat, message: TelegramMessage) { + log.debug("Received a {} from {}", message, chat) + } + + override suspend fun start() { + log.debug("Started") + } +} diff --git a/buildSrc/src/main/kotlin/lms.conventions.common.gradle.kts b/buildSrc/src/main/kotlin/lms.conventions.common.gradle.kts index 4110177..7261c5e 100644 --- a/buildSrc/src/main/kotlin/lms.conventions.common.gradle.kts +++ b/buildSrc/src/main/kotlin/lms.conventions.common.gradle.kts @@ -9,4 +9,5 @@ version = "0.0.1" repositories { mavenCentral() + maven(url = "https://jitpack.io") } diff --git a/compose.yml b/compose.yml index 657aaff..b4907ce 100644 --- a/compose.yml +++ b/compose.yml @@ -25,6 +25,8 @@ services: LMS_YANDEX_CLIENT_ID: ${LMS_YANDEX_CLIENT_ID?:err} LMS_YANDEX_CLIENT_SECRET: ${LMS_YANDEX_CLIENT_SECRET?:err} LMS_SECURITY_TOKEN_SIGNING_SECRET: ${LMS_SECURITY_TOKEN_SIGNING_SECRET?:err} + LMS_TEST_TELEGRAM_BOT_API_TOKEN: ${LMS_TEST_TELEGRAM_BOT_API_TOKEN?:err} + LMS_TEST_TELEGRAM_ADMIN_CHAT_ID: ${LMS_TEST_TELEGRAM_ADMIN_CHAT_ID?:err} networks: - lms-network depends_on: diff --git a/infra/down.sh b/infra/down.sh new file mode 100755 index 0000000..883c2fb --- /dev/null +++ b/infra/down.sh @@ -0,0 +1,13 @@ +#!/usr/bin/sh + +set -e + +cd "$(dirname "$0")"/.. || exit + +echo "[up] Setting up local variables..." +. ./infra/env/local.sh + +echo "[up] Setting up secrets..." +. ./infra/env/secret.sh + +docker compose down diff --git a/infra/fuzzing/custom/homeworks.sh b/infra/fuzzing/custom/homeworks.sh index c55dc81..3724c1d 100755 --- a/infra/fuzzing/custom/homeworks.sh +++ b/infra/fuzzing/custom/homeworks.sh @@ -20,5 +20,5 @@ for _ in $(seq 1 128); do \"max_score\": 500, \"publication_moment\": \"$MOMENT\", \"deadline_moment\": \"2026-07-11T12:00:00Z\" - }" + }" & done diff --git a/infra/kafka/setup.sh b/infra/kafka/setup.sh index 6abcfc9..e92b2b0 100755 --- a/infra/kafka/setup.sh +++ b/infra/kafka/setup.sh @@ -21,7 +21,7 @@ topic() { $KAFKA_TOPICS \ --bootstrap-server "$HOST:$PORT" \ --create --if-not-exists --topic "$NAME" \ - --replication-factor 1 --partitions 1 + --replication-factor 1 --partitions 2 } topic "publication" diff --git a/infra/env/up.sh b/infra/up.sh similarity index 85% rename from infra/env/up.sh rename to infra/up.sh index 24ce357..04d6e5b 100755 --- a/infra/env/up.sh +++ b/infra/up.sh @@ -1,8 +1,8 @@ -#!/usr/bin/env sh +#!/usr/bin/sh set -e -cd "$(dirname "$0")"/../.. || exit +cd "$(dirname "$0")"/.. || exit echo "[up] Setting up local variables..." . ./infra/env/local.sh