diff --git a/common/src/commonMain/kotlin/ChatConfig.kt b/common/src/commonMain/kotlin/ChatConfig.kt index 96f1442..181deb1 100644 --- a/common/src/commonMain/kotlin/ChatConfig.kt +++ b/common/src/commonMain/kotlin/ChatConfig.kt @@ -1,15 +1,27 @@ package dev.inmo.plaguposter.common import dev.inmo.tgbotapi.types.ChatId +import dev.inmo.tgbotapi.types.FullChatIdentifierSerializer +import dev.inmo.tgbotapi.types.IdChatIdentifier import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class ChatConfig( @SerialName("targetChat") - val targetChatId: ChatId, + @Serializable(FullChatIdentifierSerializer::class) + val targetChatId: IdChatIdentifier, @SerialName("sourceChat") - val sourceChatId: ChatId, + @Serializable(FullChatIdentifierSerializer::class) + val sourceChatId: IdChatIdentifier, @SerialName("cacheChat") - val cacheChatId: ChatId -) + @Serializable(FullChatIdentifierSerializer::class) + val cacheChatId: IdChatIdentifier +) { + fun check(chatId: IdChatIdentifier) = when (chatId) { + targetChatId, + sourceChatId, + cacheChatId -> true + else -> false + } +} diff --git a/common/src/jvmMain/kotlin/CommonPlugin.kt b/common/src/jvmMain/kotlin/CommonPlugin.kt index e5de8bd..dd8995c 100644 --- a/common/src/jvmMain/kotlin/CommonPlugin.kt +++ b/common/src/jvmMain/kotlin/CommonPlugin.kt @@ -6,10 +6,20 @@ import dev.inmo.kslog.common.logger import dev.inmo.plagubot.Plugin import dev.inmo.tgbotapi.extensions.api.chat.get.getChat import dev.inmo.tgbotapi.extensions.behaviour_builder.BehaviourContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.serialization.json.JsonObject +import org.jetbrains.exposed.sql.Database import org.koin.core.Koin +import org.koin.core.module.Module object CommonPlugin : Plugin { private val Log = logger + override fun Module.setupDI(database: Database, params: JsonObject) { + single { CoroutineScope(Dispatchers.Default + SupervisorJob()) } + } + override suspend fun BehaviourContext.setupBotPlugin(koin: Koin) { val config = koin.get() diff --git a/gradle.properties b/gradle.properties index 114a8d3..1aafafe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,5 +10,4 @@ android.enableJetifier=true # Project data group=dev.inmo -version=0.0.7 -android_code_version=7 +version=0.0.8 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ed6924d..4f56f66 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,11 +3,11 @@ kotlin = "1.7.22" kotlin-serialization = "1.4.1" -plagubot = "3.2.0" +plagubot = "3.2.1" tgbotapi = "4.2.1" -microutils = "0.16.0" +microutils = "0.16.1" kslog = "0.5.4" -krontab = "0.8.4" +krontab = "0.8.5" tgbotapi-libraries = "0.6.5" plagubot-plugins = "0.6.4" diff --git a/posts/src/commonMain/kotlin/models/PostId.kt b/posts/src/commonMain/kotlin/models/PostId.kt index 806d412..e3ff56d 100644 --- a/posts/src/commonMain/kotlin/models/PostId.kt +++ b/posts/src/commonMain/kotlin/models/PostId.kt @@ -7,4 +7,6 @@ import kotlin.jvm.JvmInline @JvmInline value class PostId( val string: String -) +) { + override fun toString(): String = string +} diff --git a/posts/src/commonMain/kotlin/sending/PostPublisher.kt b/posts/src/commonMain/kotlin/sending/PostPublisher.kt index b603a3d..3073c03 100644 --- a/posts/src/commonMain/kotlin/sending/PostPublisher.kt +++ b/posts/src/commonMain/kotlin/sending/PostPublisher.kt @@ -17,8 +17,8 @@ import dev.inmo.tgbotapi.types.message.content.MediaGroupPartContent class PostPublisher( private val bot: TelegramBot, private val postsRepo: PostsRepo, - private val cachingChatId: ChatId, - private val targetChatId: ChatId, + private val cachingChatId: IdChatIdentifier, + private val targetChatId: IdChatIdentifier, private val deleteAfterPosting: Boolean = true ) { suspend fun publish(postId: PostId) { diff --git a/ratings/source/src/jvmMain/kotlin/Plugin.kt b/ratings/source/src/jvmMain/kotlin/Plugin.kt index 9706fa9..d48c6b5 100644 --- a/ratings/source/src/jvmMain/kotlin/Plugin.kt +++ b/ratings/source/src/jvmMain/kotlin/Plugin.kt @@ -101,6 +101,7 @@ object Plugin : Plugin { } val post = postsRepo.getById(postId) ?: return false + ratingsRepo.set(postId, Rating(0.0)) for (content in post.content) { runCatchingSafely { val sent = send( @@ -140,7 +141,7 @@ object Plugin : Plugin { } } - postsRepo.deletedObjectsIdsFlow.subscribeSafelyWithoutExceptions(this) { postId -> + ratingsRepo.onValueRemoved.subscribeSafelyWithoutExceptions(this) { postId -> detachPoll(postId) } diff --git a/ratings/src/jvmMain/kotlin/exposed/ExposedRatingsRepo.kt b/ratings/src/jvmMain/kotlin/exposed/ExposedRatingsRepo.kt index 0c7f14e..480033b 100644 --- a/ratings/src/jvmMain/kotlin/exposed/ExposedRatingsRepo.kt +++ b/ratings/src/jvmMain/kotlin/exposed/ExposedRatingsRepo.kt @@ -1,6 +1,7 @@ package dev.inmo.plaguposter.ratings.exposed import dev.inmo.micro_utils.pagination.utils.optionallyReverse +import dev.inmo.micro_utils.repos.exposed.initTable import dev.inmo.micro_utils.repos.exposed.keyvalue.AbstractExposedKeyValueRepo import dev.inmo.plaguposter.posts.models.PostId import dev.inmo.plaguposter.ratings.models.Rating @@ -24,6 +25,10 @@ class ExposedRatingsRepo ( override val ResultRow.asObject: Rating get() = get(ratingsColumn).let(::Rating) + init { + initTable() + } + override fun update(k: PostId, v: Rating, it: UpdateBuilder) { it[ratingsColumn] = v.double } diff --git a/runner/build.gradle b/runner/build.gradle index d7cf67f..254b167 100644 --- a/runner/build.gradle +++ b/runner/build.gradle @@ -15,6 +15,9 @@ dependencies { api project(":plaguposter.posts_registrar") api project(":plaguposter.triggers.command") api project(":plaguposter.triggers.selector_with_timer") + api project(":plaguposter.triggers.timer") + api project(":plaguposter.triggers.timer.disablers.autoposts") + api project(":plaguposter.triggers.timer.disablers.ratings") api project(":plaguposter.ratings") api project(":plaguposter.ratings.source") api project(":plaguposter.ratings.selector") diff --git a/runner/config.json b/runner/config.json index 4ba4f3a..1ef0bf4 100644 --- a/runner/config.json +++ b/runner/config.json @@ -7,15 +7,19 @@ }, "botToken": "1234567890:ABCDEFGHIJKLMNOP_qrstuvwxyz12345678", "plugins": [ - "dev.inmo.plagubot.plugins.inline.queries.Plugin", "dev.inmo.plaguposter.posts.Plugin", "dev.inmo.plaguposter.posts.registrar.Plugin", "dev.inmo.plaguposter.ratings.Plugin", "dev.inmo.plaguposter.ratings.source.Plugin", "dev.inmo.plaguposter.ratings.selector.Plugin", "dev.inmo.plaguposter.triggers.selector_with_timer.Plugin", + "dev.inmo.plagubot.plugins.inline.queries.Plugin", "dev.inmo.plaguposter.triggers.command.Plugin", - "dev.inmo.plaguposter.posts.panel.Plugin" + "dev.inmo.plaguposter.posts.panel.Plugin", + "dev.inmo.plaguposter.common.CommonPlugin", + "dev.inmo.plaguposter.triggers.timer.Plugin", + "dev.inmo.plaguposter.triggers.timer.disablers.ratings.Plugin", + "dev.inmo.plaguposter.triggers.timer.disablers.autoposts.Plugin" ], "posts": { "chats": { diff --git a/settings.gradle b/settings.gradle index 4974edb..1bb37bc 100644 --- a/settings.gradle +++ b/settings.gradle @@ -12,6 +12,9 @@ String[] includes = [ ":triggers:command", ":triggers:selector_with_timer", ":triggers:selector_with_scheduling", + ":triggers:timer", + ":triggers:timer:disablers:ratings", + ":triggers:timer:disablers:autoposts", ":inlines", // ":settings", ":runner" diff --git a/triggers/selector_with_timer/src/commonMain/kotlin/AutopostFilter.kt b/triggers/selector_with_timer/src/commonMain/kotlin/AutopostFilter.kt new file mode 100644 index 0000000..66cd842 --- /dev/null +++ b/triggers/selector_with_timer/src/commonMain/kotlin/AutopostFilter.kt @@ -0,0 +1,8 @@ +package dev.inmo.plaguposter.triggers.selector_with_timer + +import com.soywiz.klock.DateTime +import dev.inmo.plaguposter.posts.models.PostId + +fun interface AutopostFilter { + suspend fun check(postId: PostId, dateTime: DateTime): Boolean +} diff --git a/triggers/selector_with_timer/src/jvmMain/kotlin/Plugin.kt b/triggers/selector_with_timer/src/jvmMain/kotlin/Plugin.kt index 22e5dc3..33f324c 100644 --- a/triggers/selector_with_timer/src/jvmMain/kotlin/Plugin.kt +++ b/triggers/selector_with_timer/src/jvmMain/kotlin/Plugin.kt @@ -34,9 +34,12 @@ object Plugin : Plugin { override suspend fun BehaviourContext.setupBotPlugin(koin: Koin) { val publisher = koin.get() val selector = koin.get() - koin.get().krontab.asFlow().subscribeSafelyWithoutExceptions(this) { - selector.take(now = it).forEach { postId -> - publisher.publish(postId) + val filters = koin.getAll().distinct() + koin.get().krontab.asFlow().subscribeSafelyWithoutExceptions(this) { dateTime -> + selector.take(now = dateTime).forEach { postId -> + if (filters.all { it.check(postId, dateTime) }) { + publisher.publish(postId) + } } } } diff --git a/triggers/timer/build.gradle b/triggers/timer/build.gradle new file mode 100644 index 0000000..aa16791 --- /dev/null +++ b/triggers/timer/build.gradle @@ -0,0 +1,18 @@ +plugins { + id "org.jetbrains.kotlin.multiplatform" + id "org.jetbrains.kotlin.plugin.serialization" +} + +apply from: "$mppProjectWithSerializationPresetPath" + +kotlin { + sourceSets { + commonMain { + dependencies { + api project(":plaguposter.common") + api project(":plaguposter.posts") + api project(":plaguposter.posts.panel") + } + } + } +} diff --git a/triggers/timer/disablers/autoposts/build.gradle b/triggers/timer/disablers/autoposts/build.gradle new file mode 100644 index 0000000..0bfbf70 --- /dev/null +++ b/triggers/timer/disablers/autoposts/build.gradle @@ -0,0 +1,18 @@ +plugins { + id "org.jetbrains.kotlin.multiplatform" + id "org.jetbrains.kotlin.plugin.serialization" +} + +apply from: "$mppProjectWithSerializationPresetPath" + +kotlin { + sourceSets { + commonMain { + dependencies { + api project(":plaguposter.common") + api project(":plaguposter.triggers.timer") + api project(":plaguposter.triggers.selector_with_timer") + } + } + } +} diff --git a/triggers/timer/disablers/autoposts/src/commonMain/kotlin/PackageInfo.kt b/triggers/timer/disablers/autoposts/src/commonMain/kotlin/PackageInfo.kt new file mode 100644 index 0000000..1df25ed --- /dev/null +++ b/triggers/timer/disablers/autoposts/src/commonMain/kotlin/PackageInfo.kt @@ -0,0 +1 @@ +package dev.inmo.plaguposter.triggers.timer.disablers.autoposts diff --git a/triggers/timer/disablers/autoposts/src/jvmMain/kotlin/Plugin.kt b/triggers/timer/disablers/autoposts/src/jvmMain/kotlin/Plugin.kt new file mode 100644 index 0000000..d301480 --- /dev/null +++ b/triggers/timer/disablers/autoposts/src/jvmMain/kotlin/Plugin.kt @@ -0,0 +1,27 @@ +package dev.inmo.plaguposter.triggers.timer.disablers.autoposts + +import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions +import dev.inmo.micro_utils.koin.singleWithRandomQualifier +import dev.inmo.micro_utils.koin.singleWithRandomQualifierAndBinds +import dev.inmo.micro_utils.pagination.FirstPagePagination +import dev.inmo.micro_utils.repos.unset +import dev.inmo.plagubot.Plugin +import dev.inmo.plaguposter.ratings.repo.RatingsRepo +import dev.inmo.plaguposter.triggers.selector_with_timer.AutopostFilter +import dev.inmo.plaguposter.triggers.timer.TimersRepo +import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.json.* +import org.jetbrains.exposed.sql.Database +import org.koin.core.module.Module + +object Plugin : Plugin { + override fun Module.setupDI(database: Database, params: JsonObject) { + singleWithRandomQualifier { + val timersRepo = get() + AutopostFilter { _, dateTime -> + val result = timersRepo.keys(dateTime, FirstPagePagination(1)) + result.results.isEmpty() + } + } + } +} diff --git a/triggers/timer/disablers/autoposts/src/main/AndroidManifest.xml b/triggers/timer/disablers/autoposts/src/main/AndroidManifest.xml new file mode 100644 index 0000000..602c1bc --- /dev/null +++ b/triggers/timer/disablers/autoposts/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/triggers/timer/disablers/ratings/build.gradle b/triggers/timer/disablers/ratings/build.gradle new file mode 100644 index 0000000..429e53f --- /dev/null +++ b/triggers/timer/disablers/ratings/build.gradle @@ -0,0 +1,18 @@ +plugins { + id "org.jetbrains.kotlin.multiplatform" + id "org.jetbrains.kotlin.plugin.serialization" +} + +apply from: "$mppProjectWithSerializationPresetPath" + +kotlin { + sourceSets { + commonMain { + dependencies { + api project(":plaguposter.common") + api project(":plaguposter.triggers.timer") + api project(":plaguposter.ratings") + } + } + } +} diff --git a/triggers/timer/disablers/ratings/src/commonMain/kotlin/PackageInfo.kt b/triggers/timer/disablers/ratings/src/commonMain/kotlin/PackageInfo.kt new file mode 100644 index 0000000..71d2ee0 --- /dev/null +++ b/triggers/timer/disablers/ratings/src/commonMain/kotlin/PackageInfo.kt @@ -0,0 +1 @@ +package dev.inmo.plaguposter.triggers.timer.disablers.ratings diff --git a/triggers/timer/disablers/ratings/src/jvmMain/kotlin/Plugin.kt b/triggers/timer/disablers/ratings/src/jvmMain/kotlin/Plugin.kt new file mode 100644 index 0000000..af960ef --- /dev/null +++ b/triggers/timer/disablers/ratings/src/jvmMain/kotlin/Plugin.kt @@ -0,0 +1,26 @@ +package dev.inmo.plaguposter.triggers.timer.disablers.ratings + +import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions +import dev.inmo.micro_utils.koin.singleWithRandomQualifier +import dev.inmo.micro_utils.repos.unset +import dev.inmo.plagubot.Plugin +import dev.inmo.plaguposter.ratings.repo.RatingsRepo +import dev.inmo.plaguposter.triggers.timer.TimersRepo +import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.json.* +import org.jetbrains.exposed.sql.Database +import org.koin.core.module.Module + +object Plugin : Plugin { + override fun Module.setupDI(database: Database, params: JsonObject) { + singleWithRandomQualifier(createdAtStart = true) { + val timersRepo = get() + val ratingsRepo = get() + val scope = get() + + timersRepo.onNewValue.subscribeSafelyWithoutExceptions(scope) { + ratingsRepo.unset(it.first) + } + } + } +} diff --git a/triggers/timer/disablers/ratings/src/main/AndroidManifest.xml b/triggers/timer/disablers/ratings/src/main/AndroidManifest.xml new file mode 100644 index 0000000..caad574 --- /dev/null +++ b/triggers/timer/disablers/ratings/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/triggers/timer/src/commonMain/kotlin/ButtonsBuilder.kt b/triggers/timer/src/commonMain/kotlin/ButtonsBuilder.kt new file mode 100644 index 0000000..efb2d30 --- /dev/null +++ b/triggers/timer/src/commonMain/kotlin/ButtonsBuilder.kt @@ -0,0 +1,287 @@ +package dev.inmo.plaguposter.triggers.timer + +import com.soywiz.klock.DateFormat +import com.soywiz.klock.DateTime +import com.soywiz.klock.DateTimeTz +import com.soywiz.klock.Month +import com.soywiz.klock.Year +import dev.inmo.micro_utils.coroutines.runCatchingSafely +import dev.inmo.micro_utils.repos.unset +import dev.inmo.plaguposter.common.SuccessfulSymbol +import dev.inmo.plaguposter.common.UnsuccessfulSymbol +import dev.inmo.plaguposter.posts.models.PostId +import dev.inmo.tgbotapi.extensions.api.answers.answer +import dev.inmo.tgbotapi.extensions.api.delete +import dev.inmo.tgbotapi.extensions.api.edit.edit +import dev.inmo.tgbotapi.extensions.behaviour_builder.BehaviourContext +import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onMessageDataCallbackQuery +import dev.inmo.tgbotapi.extensions.utils.types.buttons.dataButton +import dev.inmo.tgbotapi.extensions.utils.types.buttons.inlineKeyboard +import dev.inmo.tgbotapi.extensions.utils.withContentOrNull +import dev.inmo.tgbotapi.types.buttons.InlineKeyboardMarkup +import dev.inmo.tgbotapi.utils.bold +import dev.inmo.tgbotapi.utils.buildEntities +import dev.inmo.tgbotapi.utils.row + +object ButtonsBuilder { + private const val changeTimeData = "timer_time_hint" + private const val changeDateData = "timer_date_hint" + private const val changeHoursDataPrefix = "timer_h" + private const val changeMinutesDataPrefix = "timer_m" + private const val changeDayDataPrefix = "timer_d" + private const val changeMonthDataPrefix = "timer_M" + private const val changeYearDataPrefix = "timer_y" + private const val changeDateDataPrefix = "timer_s" + private const val cancelDateData = "timer_c" + private const val deleteDateDataPrefix = "timer_r" + val datePrintFormat = DateFormat("HH:mm, dd.MM.yyyy, zzz") + + fun buildTimerButtons( + postId: PostId, + dateTime: DateTimeTz, + exists: Boolean + ) = inlineKeyboard { + val unixMillis = dateTime.utc.unixMillisLong + row { + dataButton("Time (hh:mm):", changeTimeData) + dataButton(dateTime.hours.toString(), "$changeHoursDataPrefix $postId $unixMillis") + dataButton(dateTime.minutes.toString(), "$changeMinutesDataPrefix $postId $unixMillis") + } + row { + dataButton("Date (dd.mm.yyyy):", changeDateData) + dataButton("${dateTime.dayOfMonth}", "$changeDayDataPrefix $postId $unixMillis") + dataButton("${dateTime.month1}", "$changeMonthDataPrefix $postId $unixMillis") + dataButton("${dateTime.yearInt}", "$changeYearDataPrefix $postId $unixMillis") + } + + row { + if (exists) { + dataButton("\uD83D\uDDD1", "$deleteDateDataPrefix $postId") + } + dataButton(UnsuccessfulSymbol, cancelDateData) + dataButton(SuccessfulSymbol, "$changeDateDataPrefix $postId $unixMillis") + } + } + + fun buildTimerTextSources( + currentDateTime: DateTime, + previousTime: DateTime? + ) = buildEntities { + previousTime ?.let { + + "Previous timer time: " + bold(it.local.toString(datePrintFormat)) + "\n" + } + +"Currently editing time: " + bold(currentDateTime.local.toString(datePrintFormat)) + } + + suspend fun BehaviourContext.includeKeyboardHandling( + timersRepo: TimersRepo, + onSavePublishingTime: suspend (PostId, DateTime) -> Boolean + ) { + fun buildKeyboard( + prefix: String, + postId: PostId, + values: Iterable, + min: DateTime = nearestAvailableTimerTime(), + dateConverter: (Int) -> DateTimeTz + ): InlineKeyboardMarkup { + return inlineKeyboard { + values.chunked(6).forEach { + row { + it.forEach { + dataButton(it.toString(), "$prefix $postId ${dateConverter(it).utc.unixMillisLong.coerceAtLeast(min.unixMillisLong)}") + } + } + } + } + } + + suspend fun buildStandardDataCallbackQuery( + name: String, + prefix: String, + possibleValues: (DateTimeTz) -> Iterable, + dateTimeConverter: (Int, DateTimeTz) -> DateTimeTz + ) { + val setPrefix = "${prefix}s" + onMessageDataCallbackQuery(Regex("$prefix .+")) { + val (_, rawPostId, rawDateTimeMillis) = it.data.split(" ") + val currentMillis = rawDateTimeMillis.toLongOrNull() ?: return@onMessageDataCallbackQuery + val currentDateTime = DateTime(currentMillis) + val currentDateTimeLocal = DateTime(currentMillis).local + val postId = PostId(rawPostId) + val previousTime = timersRepo.get(postId) + + edit ( + it.message.withContentOrNull() ?: return@onMessageDataCallbackQuery, + replyMarkup = buildKeyboard( + setPrefix, + postId, + possibleValues(currentDateTimeLocal) + ) { + dateTimeConverter(it, currentDateTimeLocal) + } + ) { + +buildTimerTextSources(currentDateTime, previousTime) + "\n" + +"You are about to edit $name" + } + } + + onMessageDataCallbackQuery(Regex("$setPrefix .+")) { + val (_, rawPostId, rawDateTimeMillis) = it.data.split(" ") + + val currentMillis = rawDateTimeMillis.toLongOrNull() ?: return@onMessageDataCallbackQuery + val currentDateTime = DateTime(currentMillis) + val postId = PostId(rawPostId) + val previousTime = timersRepo.get(postId) + edit( + it.message.withContentOrNull() ?: return@onMessageDataCallbackQuery, + replyMarkup = buildTimerButtons( + postId, + currentDateTime.local, + timersRepo.contains(postId) + ) + ) { + +buildTimerTextSources(currentDateTime, previousTime) + } + } + } + + fun DateTimeTz.dateEq(other: DateTimeTz) = yearInt == other.yearInt && month0 == other.month0 && dayOfMonth == other.dayOfMonth + + buildStandardDataCallbackQuery( + "hour", + changeHoursDataPrefix, + { + val now = nearestAvailableTimerTime().local + + if (now.dateEq(it)) { + now.hours .. 23 + } else { + 0 .. 23 + } + } + ) { newValue, oldDateTime -> + DateTimeTz.local( + oldDateTime.local.copyDayOfMonth(hours = newValue), + oldDateTime.offset + ) + } + + buildStandardDataCallbackQuery( + "minute", + changeMinutesDataPrefix, + { + val now = nearestAvailableTimerTime().local + + if (now.dateEq(it) && now.hours >= it.hours) { + now.minutes until 60 + } else { + 0 until 60 + } + } + ) { newValue, oldDateTime -> + DateTimeTz.local( + oldDateTime.local.copyDayOfMonth(minutes = newValue), + oldDateTime.offset + ) + } + + buildStandardDataCallbackQuery( + "day", + changeDayDataPrefix, + { + val now = nearestAvailableTimerTime().local + + if (now.yearInt == it.yearInt && now.month0 == it.month0) { + now.dayOfMonth .. it.month.days(it.year) + } else { + 1 .. it.month.days(it.year) + } + } + ) { newValue, oldDateTime -> + DateTimeTz.local( + oldDateTime.local.copyDayOfMonth(dayOfMonth = newValue), + oldDateTime.offset + ) + } + + buildStandardDataCallbackQuery( + "month", + changeMonthDataPrefix, + { + val now = nearestAvailableTimerTime().local + + if (now.year == it.year) { + now.month1 .. 12 + } else { + 1 .. 12 + } + } + ) { newValue, oldDateTime -> + DateTimeTz.local( + oldDateTime.local.copyDayOfMonth(month = Month(newValue)), + oldDateTime.offset + ) + } + + buildStandardDataCallbackQuery( + "year", + changeYearDataPrefix, + { + val now = nearestAvailableTimerTime().local + (now.year.year .. (now.year.year + 5)) + } + ) { newValue, oldDateTime -> + DateTimeTz.local( + oldDateTime.local.copyDayOfMonth(year = Year(newValue)), + oldDateTime.offset + ) + } + + onMessageDataCallbackQuery(changeTimeData) { + answer(it, "Use the buttons to the right to set post publishing time (hh:mm)", showAlert = true) + } + + onMessageDataCallbackQuery(changeDateData) { + answer(it, "Use the buttons to the right to set post publishing date (dd.MM.yyyy)", showAlert = true) + } + + onMessageDataCallbackQuery(Regex("$changeDateDataPrefix .*")) { + val (_, rawPostId, rawDateTimeMillis) = it.data.split(" ") + val currentMillis = rawDateTimeMillis.toLongOrNull() ?: return@onMessageDataCallbackQuery + val currentDateTime = DateTime(currentMillis) + val postId = PostId(rawPostId) + + val success = runCatchingSafely { + onSavePublishingTime(postId, currentDateTime) + }.getOrElse { false } + + answer( + it, + if (success) "Successfully set timer" else "Unable to set timer" + ) + + it.message.delete(this) + } + + onMessageDataCallbackQuery(Regex("$deleteDateDataPrefix .*")) { + val (_, rawPostId) = it.data.split(" ") + val postId = PostId(rawPostId) + + val success = runCatchingSafely { + timersRepo.unset(postId) + true + }.getOrElse { false } + + answer( + it, + if (success) "Successfully unset timer" else "Unable to unset timer" + ) + + it.message.delete(this) + } + + onMessageDataCallbackQuery(cancelDateData) { + delete(it.message) + } + } +} diff --git a/triggers/timer/src/commonMain/kotlin/NearestAvailablePublishingTime.kt b/triggers/timer/src/commonMain/kotlin/NearestAvailablePublishingTime.kt new file mode 100644 index 0000000..55db364 --- /dev/null +++ b/triggers/timer/src/commonMain/kotlin/NearestAvailablePublishingTime.kt @@ -0,0 +1,9 @@ +package dev.inmo.plaguposter.triggers.timer + +import com.soywiz.klock.DateTime +import com.soywiz.klock.minutes + +fun nearestAvailableTimerTime() = (DateTime.now() + 1.minutes).copyDayOfMonth( + milliseconds = 0, + seconds = 0 +) diff --git a/triggers/timer/src/commonMain/kotlin/PackageInfo.kt b/triggers/timer/src/commonMain/kotlin/PackageInfo.kt new file mode 100644 index 0000000..e9843c7 --- /dev/null +++ b/triggers/timer/src/commonMain/kotlin/PackageInfo.kt @@ -0,0 +1 @@ +package dev.inmo.plaguposter.triggers.timer diff --git a/triggers/timer/src/commonMain/kotlin/TimerPanelButton.kt b/triggers/timer/src/commonMain/kotlin/TimerPanelButton.kt new file mode 100644 index 0000000..729d3df --- /dev/null +++ b/triggers/timer/src/commonMain/kotlin/TimerPanelButton.kt @@ -0,0 +1,28 @@ +package dev.inmo.plaguposter.triggers.timer + +import dev.inmo.plaguposter.common.SuccessfulSymbol +import dev.inmo.plaguposter.common.UnsuccessfulSymbol +import dev.inmo.plaguposter.posts.models.RegisteredPost +import dev.inmo.plaguposter.posts.panel.PanelButtonBuilder +import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.CallbackDataInlineKeyboardButton +import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.InlineKeyboardButton + +class TimerPanelButton( + private val timersRepo: TimersRepo +) : PanelButtonBuilder { + override val weight: Int + get() = 0 + + override suspend fun buildButton(post: RegisteredPost): InlineKeyboardButton? { + val publishingTime = timersRepo.get(post.id) + + return CallbackDataInlineKeyboardButton( + "⏰ ${ if (publishingTime == null) UnsuccessfulSymbol else SuccessfulSymbol }", + "$timerSetPrefix ${post.id}" + ) + } + + companion object { + const val timerSetPrefix = "timer_set_init" + } +} diff --git a/triggers/timer/src/commonMain/kotlin/TimersHandler.kt b/triggers/timer/src/commonMain/kotlin/TimersHandler.kt new file mode 100644 index 0000000..074108a --- /dev/null +++ b/triggers/timer/src/commonMain/kotlin/TimersHandler.kt @@ -0,0 +1,57 @@ +package dev.inmo.plaguposter.triggers.timer + +import com.soywiz.klock.DateTime +import dev.inmo.micro_utils.coroutines.launchSafelyWithoutExceptions +import dev.inmo.micro_utils.coroutines.plus +import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions +import dev.inmo.micro_utils.repos.unset +import dev.inmo.plaguposter.posts.models.PostId +import dev.inmo.plaguposter.posts.sending.PostPublisher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class TimersHandler( + private val timersRepo: TimersRepo, + private val publisher: PostPublisher, + private val scope: CoroutineScope +) { + private var currentPostAndJob: Pair? = null + private val currentJobMutex = Mutex() + + init { + (flowOf(Unit) + timersRepo.onNewValue + timersRepo.onValueRemoved).subscribeSafelyWithoutExceptions(scope) { + refreshPublishingJob() + } + } + + private suspend fun refreshPublishingJob() { + val minimal = timersRepo.getMinimalDateTimePost() + + currentJobMutex.withLock { + if (minimal ?.first == currentPostAndJob ?.first) { + return@withLock + } + + currentPostAndJob ?.second ?.cancel() + + currentPostAndJob = minimal ?.let { (postId, dateTime) -> + postId to scope.launchSafelyWithoutExceptions { + val now = DateTime.now() + val span = dateTime - now + + delay(span.millisecondsLong) + + publisher.publish(postId) + + timersRepo.unset(postId) + + refreshPublishingJob() + } + } + } + } +} diff --git a/triggers/timer/src/commonMain/kotlin/TimersRepo.kt b/triggers/timer/src/commonMain/kotlin/TimersRepo.kt new file mode 100644 index 0000000..f87dd6c --- /dev/null +++ b/triggers/timer/src/commonMain/kotlin/TimersRepo.kt @@ -0,0 +1,9 @@ +package dev.inmo.plaguposter.triggers.timer + +import com.soywiz.klock.DateTime +import dev.inmo.micro_utils.repos.KeyValueRepo +import dev.inmo.plaguposter.posts.models.PostId + +interface TimersRepo : KeyValueRepo { + suspend fun getMinimalDateTimePost(): Pair? +} diff --git a/triggers/timer/src/jvmMain/kotlin/Plugin.kt b/triggers/timer/src/jvmMain/kotlin/Plugin.kt new file mode 100644 index 0000000..f305c53 --- /dev/null +++ b/triggers/timer/src/jvmMain/kotlin/Plugin.kt @@ -0,0 +1,80 @@ +package dev.inmo.plaguposter.triggers.timer + +import com.soywiz.klock.DateTime +import dev.inmo.micro_utils.coroutines.runCatchingSafely +import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions +import dev.inmo.micro_utils.koin.singleWithRandomQualifierAndBinds +import dev.inmo.micro_utils.repos.set +import dev.inmo.plagubot.Plugin +import dev.inmo.plaguposter.common.ChatConfig +import dev.inmo.plaguposter.posts.models.PostId +import dev.inmo.plaguposter.posts.panel.PanelButtonsAPI +import dev.inmo.plaguposter.posts.repo.ReadPostsRepo +import dev.inmo.plaguposter.triggers.timer.repo.ExposedTimersRepo +import dev.inmo.tgbotapi.extensions.api.answers.answer +import dev.inmo.tgbotapi.extensions.api.edit.edit +import dev.inmo.tgbotapi.extensions.api.send.reply +import dev.inmo.tgbotapi.extensions.api.send.send +import dev.inmo.tgbotapi.extensions.behaviour_builder.BehaviourContext +import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onCommand +import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onMessageDataCallbackQuery +import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.json.* +import org.jetbrains.exposed.sql.Database +import org.koin.core.Koin +import org.koin.core.module.Module +import org.koin.dsl.binds + +object Plugin : Plugin { + override fun Module.setupDI(database: Database, params: JsonObject) { + single { ExposedTimersRepo(get(), get(), get()) } binds arrayOf(TimersRepo::class) + single(createdAtStart = true) { TimersHandler(get(), get(), get()) } + singleWithRandomQualifierAndBinds { TimerPanelButton(get()) } + } + + override suspend fun BehaviourContext.setupBotPlugin(koin: Koin) { + val timersRepo = koin.get() + val chatsConfig = koin.get() + val panelApi = koin.get() + val scope = koin.get() + with(ButtonsBuilder) { + includeKeyboardHandling(timersRepo) { postId, dateTime -> + timersRepo.set(postId, dateTime) + true + } + } + + timersRepo.onNewValue.subscribeSafelyWithoutExceptions(scope) { + panelApi.forceRefresh(it.first) + } + + timersRepo.onValueRemoved.subscribeSafelyWithoutExceptions(scope) { + panelApi.forceRefresh(it) + } + + onMessageDataCallbackQuery( + Regex("${TimerPanelButton.timerSetPrefix} [^\\s]+"), + initialFilter = { + chatsConfig.check(it.message.chat.id) + } + ) { + val (_, postIdRaw) = it.data.split(" ") + val postId = PostId(postIdRaw) + val now = nearestAvailableTimerTime() + val exists = timersRepo.get(postId) + val textSources = ButtonsBuilder.buildTimerTextSources(now, exists) + val buttons = ButtonsBuilder.buildTimerButtons( + postId, + now.local, + exists != null + ) + reply( + it.message, + textSources, + replyMarkup = buttons + ) + + answer(it) + } + } +} diff --git a/triggers/timer/src/jvmMain/kotlin/repo/ExposedTimersRepo.kt b/triggers/timer/src/jvmMain/kotlin/repo/ExposedTimersRepo.kt new file mode 100644 index 0000000..425070c --- /dev/null +++ b/triggers/timer/src/jvmMain/kotlin/repo/ExposedTimersRepo.kt @@ -0,0 +1,62 @@ +package dev.inmo.plaguposter.triggers.timer.repo + +import com.soywiz.klock.DateTime +import dev.inmo.micro_utils.common.firstNotNull +import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions +import dev.inmo.micro_utils.pagination.paginate +import dev.inmo.micro_utils.repos.exposed.initTable +import dev.inmo.micro_utils.repos.exposed.keyvalue.AbstractExposedKeyValueRepo +import dev.inmo.micro_utils.repos.unset +import dev.inmo.plaguposter.posts.models.PostId +import dev.inmo.plaguposter.posts.repo.PostsRepo +import dev.inmo.plaguposter.triggers.timer.TimersRepo +import kotlinx.coroutines.CoroutineScope +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.ISqlExpressionBuilder +import org.jetbrains.exposed.sql.Op +import org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.statements.InsertStatement +import org.jetbrains.exposed.sql.statements.UpdateBuilder +import org.jetbrains.exposed.sql.transactions.transaction + +class ExposedTimersRepo( + database: Database, + postsRepo: PostsRepo, + scope: CoroutineScope +) : TimersRepo, AbstractExposedKeyValueRepo( + database, + "timers" +) { + override val keyColumn = text("post_id") + private val dateTimeColumn = long("date_time") + override val selectById: ISqlExpressionBuilder.(PostId) -> Op = { keyColumn.eq(it.string) } + override val selectByValue: ISqlExpressionBuilder.(DateTime) -> Op = { dateTimeColumn.eq(it.unixMillisLong) } + override val ResultRow.asKey: PostId + get() = PostId(get(keyColumn)) + override val ResultRow.asObject: DateTime + get() = DateTime(get(dateTimeColumn)) + + val postsRepoListeningJob = postsRepo.deletedObjectsIdsFlow.subscribeSafelyWithoutExceptions(scope) { + unset(it) + } + + init { + initTable() + } + + override fun update(k: PostId, v: DateTime, it: UpdateBuilder) { + it[dateTimeColumn] = v.unixMillisLong + } + + override fun insertKey(k: PostId, v: DateTime, it: InsertStatement) { + it[keyColumn] = k.string + } + + override suspend fun getMinimalDateTimePost(): Pair? = transaction(database) { + selectAll().orderBy(dateTimeColumn).limit(1).firstOrNull() ?.let { + PostId(it[keyColumn]) to DateTime(it[dateTimeColumn]) + } + } +} diff --git a/triggers/timer/src/main/AndroidManifest.xml b/triggers/timer/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ef864ed --- /dev/null +++ b/triggers/timer/src/main/AndroidManifest.xml @@ -0,0 +1 @@ +