diff --git a/settings.gradle b/settings.gradle index f765964ad9..9ef11db508 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,5 +8,6 @@ pluginManagement { include ":tgbotapi.core" include ":tgbotapi.extensions.api" include ":tgbotapi.extensions.utils" +include ":tgbotapi.extensions.steps" include ":tgbotapi" include ":docs" diff --git a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/CommonAbstracts/Texted.kt b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/CommonAbstracts/Texted.kt index 4ff32e73de..afd4ac6dec 100644 --- a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/CommonAbstracts/Texted.kt +++ b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/CommonAbstracts/Texted.kt @@ -19,13 +19,17 @@ interface TextedOutput : ParsableOutput, EntitiesOutput interface TextedInput : Texted { /** - * Not full list of entities. This list WILL NOT contain [TextPart]s with [dev.inmo.tgbotapi.types.MessageEntity.textsources.RegularTextSource] + * Here must be full list of entities. This list must contains [TextPart]s with + * [dev.inmo.tgbotapi.types.MessageEntity.textsources.RegularTextSource] in case if source text contains parts of + * regular text * @see [CaptionedInput.fullEntitiesList] */ val textEntities: List } /** + * Full list of [TextSource] built from source[TextedInput.textEntities] + * * @see TextedInput.textEntities * @see justTextSources */ diff --git a/tgbotapi.extensions.steps/README.md b/tgbotapi.extensions.steps/README.md new file mode 100644 index 0000000000..1d7622f9eb --- /dev/null +++ b/tgbotapi.extensions.steps/README.md @@ -0,0 +1 @@ +# TelegramBotAPI Steps Extensions diff --git a/tgbotapi.extensions.steps/build.gradle b/tgbotapi.extensions.steps/build.gradle new file mode 100644 index 0000000000..c8bd96a0f6 --- /dev/null +++ b/tgbotapi.extensions.steps/build.gradle @@ -0,0 +1,48 @@ +buildscript { + repositories { + mavenLocal() + jcenter() + mavenCentral() + } + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" + } +} + +plugins { + id "org.jetbrains.kotlin.multiplatform" + id "org.jetbrains.kotlin.plugin.serialization" +} + +project.version = "$library_version" +project.group = "$library_group" + +apply from: "publish.gradle" + +repositories { + mavenLocal() + jcenter() + mavenCentral() + maven { url "https://kotlin.bintray.com/kotlinx" } +} + +kotlin { + jvm() + js(BOTH) { + browser() + nodejs() + } + + sourceSets { + commonMain { + dependencies { + implementation kotlin('stdlib') + api project(":tgbotapi.core") + api project(":tgbotapi.extensions.utils") + api project(":tgbotapi.extensions.api") + } + } + } +} diff --git a/tgbotapi.extensions.steps/mpp_publish_template.kpsb b/tgbotapi.extensions.steps/mpp_publish_template.kpsb new file mode 100644 index 0000000000..4cd88bd29b --- /dev/null +++ b/tgbotapi.extensions.steps/mpp_publish_template.kpsb @@ -0,0 +1 @@ +{"bintrayConfig":{"repo":"TelegramBotAPI","packageName":"${project.name}","packageVcs":"https://github.com/InsanusMokrassar/TelegramBotAPI","autoPublish":true,"overridePublish":true},"licenses":[{"id":"Apache-2.0","title":"Apache Software License 2.0","url":"https://github.com/InsanusMokrassar/TelegramBotAPI/blob/master/LICENSE"}],"mavenConfig":{"name":"Telegram Bot API Steps Extensions","description":"These extensions project contains tools for simple interaction with chats","url":"https://insanusmokrassar.github.io/TelegramBotAPI/tgbotapi.extensions.steps","vcsUrl":"https://github.com/insanusmokrassar/TelegramBotAPI.git","developers":[{"id":"InsanusMokrassar","name":"Ovsiannikov Aleksei","eMail":"ovsyannikov.alexey95@gmail.com"}]}} \ No newline at end of file diff --git a/tgbotapi.extensions.steps/publish.gradle b/tgbotapi.extensions.steps/publish.gradle new file mode 100644 index 0000000000..00c1e2ab01 --- /dev/null +++ b/tgbotapi.extensions.steps/publish.gradle @@ -0,0 +1,69 @@ +apply plugin: 'maven-publish' + +task javadocsJar(type: Jar) { + classifier = 'javadoc' +} +task sourceJar (type : Jar) { + classifier = 'sources' +} + +afterEvaluate { + project.publishing.publications.all { + // rename artifacts + groupId "${project.group}" + if (it.name.contains('kotlinMultiplatform')) { + artifactId = "${project.name}" + artifact sourceJar + } else { + artifactId = "${project.name}-$name" + } + } +} + +publishing { + publications.all { + artifact javadocsJar + + pom { + description = "These extensions project contains tools for simple interaction with chats" + name = "Telegram Bot API Steps Extensions" + url = "https://insanusmokrassar.github.io/TelegramBotAPI/tgbotapi.extensions.steps" + + scm { + developerConnection = "scm:git:[fetch=]https://github.com/insanusmokrassar/TelegramBotAPI.git[push=]https://github.com/insanusmokrassar/TelegramBotAPI.git" + url = "https://github.com/insanusmokrassar/TelegramBotAPI.git" + } + + developers { + + developer { + id = "InsanusMokrassar" + name = "Ovsiannikov Aleksei" + email = "ovsyannikov.alexey95@gmail.com" + } + + } + + licenses { + + license { + name = "Apache Software License 2.0" + url = "https://github.com/InsanusMokrassar/TelegramBotAPI/blob/master/LICENSE" + } + + } + } + + repositories { + maven { + name = "bintray" + url = uri("https://api.bintray.com/maven/${project.hasProperty('BINTRAY_USER') ? project.property('BINTRAY_USER') : System.getenv('BINTRAY_USER')}/TelegramBotAPI/${project.name}/;publish=1;override=1") + credentials { + username = project.hasProperty('BINTRAY_USER') ? project.property('BINTRAY_USER') : System.getenv('BINTRAY_USER') + password = project.hasProperty('BINTRAY_KEY') ? project.property('BINTRAY_KEY') : System.getenv('BINTRAY_KEY') + } + } + } + + } +} \ No newline at end of file diff --git a/tgbotapi.extensions.steps/src/commonMain/kotlin/dev/inmo/tgbotapi/extensions/steps/Scenario.kt b/tgbotapi.extensions.steps/src/commonMain/kotlin/dev/inmo/tgbotapi/extensions/steps/Scenario.kt new file mode 100644 index 0000000000..9524c459d1 --- /dev/null +++ b/tgbotapi.extensions.steps/src/commonMain/kotlin/dev/inmo/tgbotapi/extensions/steps/Scenario.kt @@ -0,0 +1,16 @@ +package dev.inmo.tgbotapi.extensions.steps + +import dev.inmo.tgbotapi.bot.TelegramBot +import dev.inmo.tgbotapi.updateshandlers.FlowsUpdatesFilter +import kotlinx.coroutines.CoroutineScope + +typealias ScenarioReceiver = suspend Scenario.() -> T +typealias ScenarioAndTypeReceiver = suspend Scenario.(I) -> T + +data class Scenario( + val bot: TelegramBot, + val flowsUpdatesFilter: FlowsUpdatesFilter, + val scope: CoroutineScope +) + + diff --git a/tgbotapi.extensions.steps/src/commonMain/kotlin/dev/inmo/tgbotapi/extensions/steps/ScenarioBuilders.kt b/tgbotapi.extensions.steps/src/commonMain/kotlin/dev/inmo/tgbotapi/extensions/steps/ScenarioBuilders.kt new file mode 100644 index 0000000000..c5a1001dbd --- /dev/null +++ b/tgbotapi.extensions.steps/src/commonMain/kotlin/dev/inmo/tgbotapi/extensions/steps/ScenarioBuilders.kt @@ -0,0 +1,17 @@ +package dev.inmo.tgbotapi.extensions.steps + +import dev.inmo.tgbotapi.bot.TelegramBot +import dev.inmo.tgbotapi.updateshandlers.FlowsUpdatesFilter +import kotlinx.coroutines.CoroutineScope + +suspend fun TelegramBot.buildScenarios( + scope: CoroutineScope, + flowUpdatesFilter: FlowsUpdatesFilter = FlowsUpdatesFilter(), + block: ScenarioReceiver +) { + Scenario( + this, + flowUpdatesFilter, + scope + ).block() +} diff --git a/tgbotapi.extensions.steps/src/commonMain/kotlin/dev/inmo/tgbotapi/extensions/steps/expectations/Base.kt b/tgbotapi.extensions.steps/src/commonMain/kotlin/dev/inmo/tgbotapi/extensions/steps/expectations/Base.kt new file mode 100644 index 0000000000..63162ef7e5 --- /dev/null +++ b/tgbotapi.extensions.steps/src/commonMain/kotlin/dev/inmo/tgbotapi/extensions/steps/expectations/Base.kt @@ -0,0 +1,80 @@ +package dev.inmo.tgbotapi.extensions.steps.expectations + +import dev.inmo.micro_utils.coroutines.safelyWithoutExceptions +import dev.inmo.tgbotapi.bot.TelegramBot +import dev.inmo.tgbotapi.extensions.steps.Scenario +import dev.inmo.tgbotapi.extensions.steps.ScenarioReceiver +import dev.inmo.tgbotapi.requests.abstracts.Request +import dev.inmo.tgbotapi.types.update.abstracts.Update +import dev.inmo.tgbotapi.updateshandlers.FlowsUpdatesFilter +import dev.inmo.tgbotapi.utils.RiskFeature +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* + +private val cancelledByFilterException = CancellationException("Cancelled by filter precreatedException") + +typealias RequestBuilder = suspend (Update) -> Request +typealias NullableRequestBuilder = suspend (Update) -> Request? + +@RiskFeature("This method is not very comfortable to use and too low-level. It is recommended to use methods which already included into library") +suspend fun FlowsUpdatesFilter.expectFlow( + bot: TelegramBot, + initRequest: Request<*>? = null, + count: Int? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + cancelRequestFactory: NullableRequestBuilder<*> = { null }, + cancelTrigger: suspend (Update) -> Boolean = { cancelRequestFactory(it) != null }, + filter: suspend (Update) -> T? +): Flow { + val flow = allUpdatesFlow.mapNotNull { + val result = safelyWithoutExceptions { filter(it) } + if (result == null) { + if (cancelTrigger(it)) { + cancelRequestFactory(it) ?.also { + safelyWithoutExceptions { bot.execute(it) } + throw cancelledByFilterException + } + } + errorFactory(it) ?.also { errorRequest -> + safelyWithoutExceptions { bot.execute(errorRequest) } + } + null + } else { + result + } + } + val result = if (count == null) { + flow + } else { + flow.take(count) + } + initRequest ?.also { safelyWithoutExceptions { bot.execute(initRequest) } } + return result +} + +suspend fun Scenario.expectFlow( + initRequest: Request<*>? = null, + count: Int? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + cancelRequestFactory: NullableRequestBuilder<*> = { null }, + cancelTrigger: suspend (Update) -> Boolean = { cancelRequestFactory(it) != null }, + filter: suspend (Update) -> T? +) = flowsUpdatesFilter.expectFlow(bot, initRequest, count, errorFactory, cancelRequestFactory, cancelTrigger, filter) + +@RiskFeature("This method is not very comfortable to use and too low-level. It is recommended to use methods which already included into library") +suspend fun FlowsUpdatesFilter.expectOne( + bot: TelegramBot, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + cancelRequestFactory: NullableRequestBuilder<*> = { null }, + cancelTrigger: suspend (Update) -> Boolean = { cancelRequestFactory(it) != null }, + filter: suspend (Update) -> T?, +): T = expectFlow(bot, initRequest, 1, errorFactory, cancelRequestFactory, cancelTrigger, filter).first() + +suspend fun Scenario.expectOne( + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + cancelRequestFactory: NullableRequestBuilder<*> = { null }, + cancelTrigger: suspend (Update) -> Boolean = { cancelRequestFactory(it) != null }, + filter: suspend (Update) -> T? +) = flowsUpdatesFilter.expectOne(bot, initRequest, errorFactory, cancelRequestFactory, cancelTrigger, filter) diff --git a/tgbotapi.extensions.steps/src/commonMain/kotlin/dev/inmo/tgbotapi/extensions/steps/expectations/WaitContent.kt b/tgbotapi.extensions.steps/src/commonMain/kotlin/dev/inmo/tgbotapi/extensions/steps/expectations/WaitContent.kt new file mode 100644 index 0000000000..928856fe5b --- /dev/null +++ b/tgbotapi.extensions.steps/src/commonMain/kotlin/dev/inmo/tgbotapi/extensions/steps/expectations/WaitContent.kt @@ -0,0 +1,175 @@ +package dev.inmo.tgbotapi.extensions.steps.expectations + +import dev.inmo.tgbotapi.extensions.steps.Scenario +import dev.inmo.tgbotapi.extensions.utils.* +import dev.inmo.tgbotapi.requests.abstracts.Request +import dev.inmo.tgbotapi.types.message.abstracts.ContentMessage +import dev.inmo.tgbotapi.types.message.content.* +import dev.inmo.tgbotapi.types.message.content.abstracts.* +import dev.inmo.tgbotapi.types.message.content.media.* +import dev.inmo.tgbotapi.types.message.payments.InvoiceContent +import kotlinx.coroutines.flow.toList + +typealias ContentMessageToContentMapper = suspend ContentMessage.() -> T? + +private suspend fun Scenario.waitContentMessage( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + mapper: suspend ContentMessage.() -> O? +): List = expectFlow( + initRequest, + count, + errorFactory +) { + it.asMessageUpdate() ?.data ?.asContentMessage() ?.mapper() +}.toList().toList() + +private suspend inline fun Scenario.waitContent( + count: Int = 1, + initRequest: Request<*>? = null, + noinline errorFactory: NullableRequestBuilder<*> = { null }, + noinline filter: (suspend (ContentMessage) -> T?)? = null +) : List = waitContentMessage( + count, + initRequest, + errorFactory +) { + if (content is T) { + val message = (this as ContentMessage) + if (filter == null) { + message.content + } else { + filter(message) + } + } else { + null + } +} + +suspend fun Scenario.waitContact( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitDice( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitGame( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitLocation( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitPoll( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitText( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitVenue( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitAudioMediaGroup( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitDocumentMediaGroup( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitMedia( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitMediaGroup( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitVisualMediaGroup( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitAnimation( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitAudio( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitDocument( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitPhoto( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitSticker( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitVideo( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitVideoNote( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitVoice( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) +suspend fun Scenario.waitInvoice( + count: Int = 1, + initRequest: Request<*>? = null, + errorFactory: NullableRequestBuilder<*> = { null }, + filter: ContentMessageToContentMapper? = null +) = waitContent(count, initRequest, errorFactory, filter) diff --git a/tgbotapi.extensions.steps/src/commonMain/kotlin/dev/inmo/tgbotapi/extensions/steps/triggers_handling/CommandHandling.kt b/tgbotapi.extensions.steps/src/commonMain/kotlin/dev/inmo/tgbotapi/extensions/steps/triggers_handling/CommandHandling.kt new file mode 100644 index 0000000000..ed12c1daa9 --- /dev/null +++ b/tgbotapi.extensions.steps/src/commonMain/kotlin/dev/inmo/tgbotapi/extensions/steps/triggers_handling/CommandHandling.kt @@ -0,0 +1,37 @@ +package dev.inmo.tgbotapi.extensions.steps.triggers_handling + +import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions +import dev.inmo.tgbotapi.CommonAbstracts.textSources +import dev.inmo.tgbotapi.extensions.steps.* +import dev.inmo.tgbotapi.extensions.steps.expectations.expectFlow +import dev.inmo.tgbotapi.extensions.utils.* +import dev.inmo.tgbotapi.types.message.abstracts.ContentMessage +import dev.inmo.tgbotapi.types.message.content.TextContent + +suspend fun Scenario.command( + commandRegex: Regex, + requireOnlyCommandInMessage: Boolean = true, + scenarioReceiver: ScenarioAndTypeReceiver> +) { + flowsUpdatesFilter.expectFlow(bot) { + it.asMessageUpdate() ?.data ?.asContentMessage() ?.let { message -> + message.content.asTextContent() ?.let { + val textSources = it.textSources + val sizeRequirement = if (requireOnlyCommandInMessage) { + textSources.size == 1 + } else { + true + } + if (sizeRequirement && textSources.any { commandRegex.matches(it.asBotCommandTextSource() ?.command ?: return@any false) }) { + message as ContentMessage + } else { + null + } + } + } + }.subscribeSafelyWithoutExceptions(scope) { + scenarioReceiver(it) + } +} + +