diff --git a/.deploy/lambda/lib/JProfByBotStack.ts b/.deploy/lambda/lib/JProfByBotStack.ts index 9f9aab0b..9c93b973 100644 --- a/.deploy/lambda/lib/JProfByBotStack.ts +++ b/.deploy/lambda/lib/JProfByBotStack.ts @@ -25,6 +25,18 @@ export class JProfByBotStack extends cdk.Stack { partitionKey: { name: 'chat', type: dynamodb.AttributeType.NUMBER }, billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, }); + const dialogStatesTable = new dynamodb.Table(this, 'jprof-by-bot-table-dialog-states', { + tableName: 'jprof-by-bot-table-dialog-states', + partitionKey: { name: 'userId', type: dynamodb.AttributeType.NUMBER }, + sortKey: { name: 'chatId', type: dynamodb.AttributeType.NUMBER }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + }); + const quizojisTable = new dynamodb.Table(this, 'jprof-by-bot-table-quizojis', { + tableName: 'jprof-by-bot-table-quizojis', + partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + }); + const layerLibGL = new lambda.LayerVersion(this, 'jprof-by-bot-lambda-layer-libGL', { code: lambda.Code.fromAsset('layers/libGL.zip'), compatibleRuntimes: [lambda.Runtime.JAVA_11], @@ -50,6 +62,8 @@ export class JProfByBotStack extends cdk.Stack { 'TABLE_VOTES': votesTable.tableName, 'TABLE_YOUTUBE_CHANNELS_WHITELIST': youtubeChannelsWhitelistTable.tableName, 'TABLE_KOTLIN_MENTIONS': kotlinMentionsTable.tableName, + 'TABLE_DIALOG_STATES': dialogStatesTable.tableName, + 'TABLE_QUIZOJIS': quizojisTable.tableName, 'TOKEN_TELEGRAM_BOT': props.telegramToken, 'TOKEN_YOUTUBE_API': props.youtubeToken, }, @@ -58,6 +72,8 @@ export class JProfByBotStack extends cdk.Stack { votesTable.grantReadWriteData(lambdaWebhook); youtubeChannelsWhitelistTable.grantReadData(lambdaWebhook); kotlinMentionsTable.grantReadWriteData(lambdaWebhook); + dialogStatesTable.grantReadWriteData(lambdaWebhook); + quizojisTable.grantReadWriteData(lambdaWebhook); const api = new apigateway.RestApi(this, 'jprof-by-bot-api', { restApiName: 'jprof-by-bot-api', diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 78c42847..aaa3aa64 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -57,6 +57,8 @@ jobs: - run: votes/dynamodb/src/test/resources/seed.sh - run: youtube/dynamodb/src/test/resources/seed.sh - run: kotlin/dynamodb/src/test/resources/seed.sh + - run: dialogs/dynamodb/src/test/resources/seed.sh + - run: quizoji/dynamodb/src/test/resources/seed.sh - run: ./gradlew clean dbTest - uses: actions/upload-artifact@v2 if: always() diff --git a/build.gradle.kts b/build.gradle.kts index 34852961..e0c36a02 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { kotlin("jvm").version("1.4.32").apply(false) + kotlin("plugin.serialization").version("1.4.32").apply(false) id("com.github.johnrengelman.shadow").version("6.1.0").apply(false) } diff --git a/dialogs/build.gradle.kts b/dialogs/build.gradle.kts new file mode 100644 index 00000000..0bc306b1 --- /dev/null +++ b/dialogs/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + kotlin("jvm") + kotlin("plugin.serialization") +} + +dependencies { + api(libs.tgbotapi.core) + implementation(libs.kotlinx.serialization.core) +} diff --git a/dialogs/dynamodb/build.gradle.kts b/dialogs/dynamodb/build.gradle.kts new file mode 100644 index 00000000..756b2079 --- /dev/null +++ b/dialogs/dynamodb/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + kotlin("jvm") +} + +dependencies { + api(project.projects.dialogs) + api(libs.dynamodb) + implementation(project.projects.utils.dynamodb) + implementation(project.projects.utils.tgbotapiSerialization) + implementation(libs.kotlinx.coroutines.jdk8) + implementation(libs.kotlinx.serialization.json) + + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.aws.junit5.dynamo.v2) + testImplementation(project.projects.utils.awsJunit5) + testRuntimeOnly(libs.junit.jupiter.engine) +} + +tasks { + val dbTest by registering(Test::class) { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Runs the DB tests." + shouldRunAfter("test") + outputs.upToDateWhen { false } + useJUnitPlatform { + includeTags("db") + } + } +} diff --git a/dialogs/dynamodb/src/main/kotlin/by/jprof/telegram/bot/dialogs/dynamodb/dao/DialogStateDAO.kt b/dialogs/dynamodb/src/main/kotlin/by/jprof/telegram/bot/dialogs/dynamodb/dao/DialogStateDAO.kt new file mode 100644 index 00000000..1428b380 --- /dev/null +++ b/dialogs/dynamodb/src/main/kotlin/by/jprof/telegram/bot/dialogs/dynamodb/dao/DialogStateDAO.kt @@ -0,0 +1,68 @@ +package by.jprof.telegram.bot.dialogs.dynamodb.dao + +import by.jprof.telegram.bot.dialogs.dao.DialogStateDAO +import by.jprof.telegram.bot.dialogs.model.DialogState +import by.jprof.telegram.bot.utils.dynamodb.toAttributeValue +import by.jprof.telegram.bot.utils.dynamodb.toString +import by.jprof.telegram.bot.utils.tgbotapi_serialization.TgBotAPI +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.future.await +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.plus +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient +import software.amazon.awssdk.services.dynamodb.model.AttributeValue + +class DialogStateDAO( + private val dynamoDb: DynamoDbAsyncClient, + private val table: String +) : DialogStateDAO { + override suspend fun get(chatId: Long, userId: Long): DialogState? { + return withContext(Dispatchers.IO) { + dynamoDb.getItem { + it.tableName(table) + it.key( + mapOf( + "userId" to userId.toAttributeValue(), + "chatId" to chatId.toAttributeValue(), + ) + ) + }.await()?.item()?.takeUnless { it.isEmpty() }?.toDialogState() + } + } + + override suspend fun save(dialogState: DialogState) { + withContext(Dispatchers.IO) { + dynamoDb.putItem { + it.tableName(table) + it.item(dialogState.toAttributes()) + }.await() + } + } + + override suspend fun delete(chatId: Long, userId: Long) { + withContext(Dispatchers.IO) { + dynamoDb.deleteItem { + it.tableName(table) + it.key( + mapOf( + "userId" to userId.toAttributeValue(), + "chatId" to chatId.toAttributeValue(), + ) + ) + } + } + } +} + +private val json = Json { serializersModule = DialogState.serializers + TgBotAPI.module } + +fun Map.toDialogState(): DialogState = json.decodeFromString(this["value"].toString("value")) + +fun DialogState.toAttributes(): Map = mapOf( + "userId" to this.userId.toAttributeValue(), + "chatId" to this.chatId.toAttributeValue(), + "value" to json.encodeToString(this).toAttributeValue(), +) diff --git a/dialogs/dynamodb/src/test/kotlin/by/jprof/telegram/bot/dialogs/dynamodb/dao/DialogStateDAOTest.kt b/dialogs/dynamodb/src/test/kotlin/by/jprof/telegram/bot/dialogs/dynamodb/dao/DialogStateDAOTest.kt new file mode 100644 index 00000000..b6716acf --- /dev/null +++ b/dialogs/dynamodb/src/test/kotlin/by/jprof/telegram/bot/dialogs/dynamodb/dao/DialogStateDAOTest.kt @@ -0,0 +1,77 @@ +package by.jprof.telegram.bot.dialogs.dynamodb.dao + +import by.jprof.telegram.bot.dialogs.model.quizoji.WaitingForQuestion +import by.jprof.telegram.bot.utils.aws_junit5.Endpoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import me.madhead.aws_junit5.common.AWSClient +import me.madhead.aws_junit5.dynamo.v2.DynamoDB +import org.junit.jupiter.api.* +import org.junit.jupiter.api.extension.ExtendWith +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient + +@Tag("db") +@ExtendWith(DynamoDB::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class DialogStateDAOTest { + @AWSClient(endpoint = Endpoint::class) + private lateinit var dynamoDB: DynamoDbAsyncClient + private lateinit var sut: DialogStateDAO + + @BeforeAll + internal fun setup() { + sut = DialogStateDAO(dynamoDB, "dialog-states") + } + + @Test + fun get() = runBlocking { + Assertions.assertEquals( + WaitingForQuestion( + chatId = 1, + userId = 1, + ), + sut.get(1, 1) + ) + } + + @Test + fun save() = runBlocking { + sut.save( + WaitingForQuestion( + chatId = 1, + userId = 2, + ) + ) + + Assertions.assertEquals( + WaitingForQuestion( + chatId = 1, + userId = 2, + ), + sut.get(1, 2) + ) + } + + @Test + fun delete() = runBlocking { + sut.save( + WaitingForQuestion( + chatId = 1, + userId = 3, + ) + ) + sut.delete(1, 3) + + withTimeout(3_000) { + while (null != sut.get(1, 3)) { + delay(100) + } + } + } + + @Test + fun getUnexisting() = runBlocking { + Assertions.assertNull(sut.get(0, 0)) + } +} diff --git a/dialogs/dynamodb/src/test/kotlin/by/jprof/telegram/bot/dialogs/dynamodb/dao/DialogStateTest.kt b/dialogs/dynamodb/src/test/kotlin/by/jprof/telegram/bot/dialogs/dynamodb/dao/DialogStateTest.kt new file mode 100644 index 00000000..bbd527e8 --- /dev/null +++ b/dialogs/dynamodb/src/test/kotlin/by/jprof/telegram/bot/dialogs/dynamodb/dao/DialogStateTest.kt @@ -0,0 +1,70 @@ +package by.jprof.telegram.bot.dialogs.dynamodb.dao + +import by.jprof.telegram.bot.dialogs.model.quizoji.WaitingForOptions +import by.jprof.telegram.bot.dialogs.model.quizoji.WaitingForQuestion +import dev.inmo.tgbotapi.types.message.content.TextContent +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import software.amazon.awssdk.services.dynamodb.model.AttributeValue + +internal class DialogStateTest { + @Test + fun quizojiWaitingForQuestionToAttributes() { + Assertions.assertEquals( + mapOf( + "userId" to AttributeValue.builder().n("2").build(), + "chatId" to AttributeValue.builder().n("1").build(), + "value" to AttributeValue + .builder() + .s("{\"type\":\"WaitingForQuestion\",\"chatId\":1,\"userId\":2}") + .build(), + ), + WaitingForQuestion(1, 2).toAttributes() + ) + } + + @Test + fun quizojiAttributesToWaitingForQuestion() { + Assertions.assertEquals( + WaitingForQuestion(1, 2), + mapOf( + "userId" to AttributeValue.builder().n("2").build(), + "chatId" to AttributeValue.builder().n("1").build(), + "value" to AttributeValue + .builder() + .s("{\"type\":\"WaitingForQuestion\",\"chatId\":1,\"userId\":2}") + .build(), + ).toDialogState() + ) + } + + @Test + fun quizojiWaitingForOptionsToAttributes() { + Assertions.assertEquals( + mapOf( + "userId" to AttributeValue.builder().n("2").build(), + "chatId" to AttributeValue.builder().n("1").build(), + "value" to AttributeValue + .builder() + .s("{\"type\":\"WaitingForOptions\",\"chatId\":1,\"userId\":2,\"question\":{\"type\":\"TextContent\",\"text\":\"Question\"}}") + .build(), + ), + WaitingForOptions(1, 2, TextContent("Question")).toAttributes() + ) + } + + @Test + fun quizojiAttributesToWaitingForOptions() { + Assertions.assertEquals( + WaitingForOptions(1, 2, TextContent("Question")), + mapOf( + "userId" to AttributeValue.builder().n("2").build(), + "chatId" to AttributeValue.builder().n("1").build(), + "value" to AttributeValue + .builder() + .s("{\"type\":\"WaitingForOptions\",\"chatId\":1,\"userId\":2,\"question\":{\"type\":\"TextContent\",\"text\":\"Question\"}}") + .build(), + ).toDialogState() + ) + } +} diff --git a/dialogs/dynamodb/src/test/resources/dialog-states.items.json b/dialogs/dynamodb/src/test/resources/dialog-states.items.json new file mode 100644 index 00000000..6f388918 --- /dev/null +++ b/dialogs/dynamodb/src/test/resources/dialog-states.items.json @@ -0,0 +1,19 @@ +{ + "dialog-states": [ + { + "PutRequest": { + "Item": { + "userId": { + "N": "1" + }, + "chatId": { + "N": "1" + }, + "value": { + "S": "{\n \"userId\": 1,\n \"chatId\": 1,\n \"type\": \"WaitingForQuestion\"\n}\n" + } + } + } + } + ] +} diff --git a/dialogs/dynamodb/src/test/resources/dialog-states.table.json b/dialogs/dynamodb/src/test/resources/dialog-states.table.json new file mode 100644 index 00000000..a1bc8321 --- /dev/null +++ b/dialogs/dynamodb/src/test/resources/dialog-states.table.json @@ -0,0 +1,27 @@ +{ + "TableName": "dialog-states", + "AttributeDefinitions": [ + { + "AttributeName": "chatId", + "AttributeType": "N" + }, + { + "AttributeName": "userId", + "AttributeType": "N" + } + ], + "KeySchema": [ + { + "AttributeName": "userId", + "KeyType": "HASH" + }, + { + "AttributeName": "chatId", + "KeyType": "RANGE" + } + ], + "ProvisionedThroughput": { + "ReadCapacityUnits": 1, + "WriteCapacityUnits": 1 + } +} diff --git a/dialogs/dynamodb/src/test/resources/seed.sh b/dialogs/dynamodb/src/test/resources/seed.sh new file mode 100755 index 00000000..c0291db6 --- /dev/null +++ b/dialogs/dynamodb/src/test/resources/seed.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh + +set -x + +aws --version +aws --endpoint-url "${DYNAMODB_URL}" dynamodb delete-table --table-name dialog-states || true +aws --endpoint-url "${DYNAMODB_URL}" dynamodb create-table --cli-input-json file://dialogs/dynamodb/src/test/resources/dialog-states.table.json +aws --endpoint-url "${DYNAMODB_URL}" dynamodb batch-write-item --request-items file://dialogs/dynamodb/src/test/resources/dialog-states.items.json diff --git a/dialogs/src/main/kotlin/by/jprof/telegram/bot/dialogs/dao/DialogStateDAO.kt b/dialogs/src/main/kotlin/by/jprof/telegram/bot/dialogs/dao/DialogStateDAO.kt new file mode 100644 index 00000000..fb6c5a66 --- /dev/null +++ b/dialogs/src/main/kotlin/by/jprof/telegram/bot/dialogs/dao/DialogStateDAO.kt @@ -0,0 +1,11 @@ +package by.jprof.telegram.bot.dialogs.dao + +import by.jprof.telegram.bot.dialogs.model.DialogState + +interface DialogStateDAO { + suspend fun get(chatId: Long, userId: Long): DialogState? + + suspend fun save(dialogState: DialogState) + + suspend fun delete(chatId: Long, userId: Long) +} diff --git a/dialogs/src/main/kotlin/by/jprof/telegram/bot/dialogs/model/DialogState.kt b/dialogs/src/main/kotlin/by/jprof/telegram/bot/dialogs/model/DialogState.kt new file mode 100644 index 00000000..21f71759 --- /dev/null +++ b/dialogs/src/main/kotlin/by/jprof/telegram/bot/dialogs/model/DialogState.kt @@ -0,0 +1,22 @@ +package by.jprof.telegram.bot.dialogs.model + +import by.jprof.telegram.bot.dialogs.model.quizoji.WaitingForOptions +import by.jprof.telegram.bot.dialogs.model.quizoji.WaitingForQuestion +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass + +interface DialogState { + companion object { + val serializers = SerializersModule { + polymorphic(DialogState::class) { + subclass(WaitingForQuestion::class) + subclass(WaitingForOptions::class) + } + } + } + + val chatId: Long + + val userId: Long +} diff --git a/dialogs/src/main/kotlin/by/jprof/telegram/bot/dialogs/model/quizoji/WaitingForOptions.kt b/dialogs/src/main/kotlin/by/jprof/telegram/bot/dialogs/model/quizoji/WaitingForOptions.kt new file mode 100644 index 00000000..2b3dc221 --- /dev/null +++ b/dialogs/src/main/kotlin/by/jprof/telegram/bot/dialogs/model/quizoji/WaitingForOptions.kt @@ -0,0 +1,15 @@ +package by.jprof.telegram.bot.dialogs.model.quizoji + +import by.jprof.telegram.bot.dialogs.model.DialogState +import dev.inmo.tgbotapi.types.message.content.abstracts.MessageContent +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("WaitingForOptions") +data class WaitingForOptions( + override val chatId: Long, + override val userId: Long, + val question: MessageContent, + val options: List = emptyList(), +) : DialogState diff --git a/dialogs/src/main/kotlin/by/jprof/telegram/bot/dialogs/model/quizoji/WaitingForQuestion.kt b/dialogs/src/main/kotlin/by/jprof/telegram/bot/dialogs/model/quizoji/WaitingForQuestion.kt new file mode 100644 index 00000000..3bbdf095 --- /dev/null +++ b/dialogs/src/main/kotlin/by/jprof/telegram/bot/dialogs/model/quizoji/WaitingForQuestion.kt @@ -0,0 +1,12 @@ +package by.jprof.telegram.bot.dialogs.model.quizoji + +import by.jprof.telegram.bot.dialogs.model.DialogState +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("WaitingForQuestion") +data class WaitingForQuestion( + override val chatId: Long, + override val userId: Long +) : DialogState diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c71cb087..fc8f4bc3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,9 +8,10 @@ awssdk = "2.16.43" koin = "3.0.1" +kotlinx-serialization = "1.2.1" jackson = "2.12.3" -tgbotapi = "0.33.3" +tgbotapi = "0.35.0" jsoup = "1.13.1" @@ -34,6 +35,8 @@ dynamodb = { group = "software.amazon.awssdk", name = "dynamodb", version.ref = koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } jackson-databind = { group = "com.fasterxml.jackson.core", name = "jackson-databind", version.ref = "jackson" } tgbotapi-core = { group = "dev.inmo", name = "tgbotapi.core", version.ref = "tgbotapi" } diff --git a/jep/src/main/kotlin/by/jprof/telegram/bot/jep/JEPUpdateProcessor.kt b/jep/src/main/kotlin/by/jprof/telegram/bot/jep/JEPUpdateProcessor.kt index 9256bd9e..551a5a9f 100644 --- a/jep/src/main/kotlin/by/jprof/telegram/bot/jep/JEPUpdateProcessor.kt +++ b/jep/src/main/kotlin/by/jprof/telegram/bot/jep/JEPUpdateProcessor.kt @@ -5,7 +5,6 @@ import by.jprof.telegram.bot.votes.dao.VotesDAO import by.jprof.telegram.bot.votes.model.Votes import by.jprof.telegram.bot.votes.tgbotapi_extensions.toInlineKeyboardMarkup import by.jprof.telegram.bot.votes.voting_processor.VotingProcessor -import dev.inmo.tgbotapi.CommonAbstracts.justTextSources import dev.inmo.tgbotapi.bot.RequestsExecutor import dev.inmo.tgbotapi.extensions.api.send.reply import dev.inmo.tgbotapi.types.MessageEntity.textsources.TextLinkTextSource @@ -65,8 +64,7 @@ class JEPUpdateProcessor( (message as? ContentMessage<*>)?.let { contentMessage -> (contentMessage.content as? TextContent)?.let { content -> content - .textEntities - .justTextSources() + .textSources .mapNotNull { (it as? URLTextSource)?.source ?: (it as? TextLinkTextSource)?.url } diff --git a/jep/src/test/kotlin/by/jprof/telegram/bot/jep/JEPUpdateProcessorTest.kt b/jep/src/test/kotlin/by/jprof/telegram/bot/jep/JEPUpdateProcessorTest.kt index 9d47329d..87870cb0 100644 --- a/jep/src/test/kotlin/by/jprof/telegram/bot/jep/JEPUpdateProcessorTest.kt +++ b/jep/src/test/kotlin/by/jprof/telegram/bot/jep/JEPUpdateProcessorTest.kt @@ -3,7 +3,6 @@ package by.jprof.telegram.bot.jep import by.jprof.telegram.bot.votes.dao.VotesDAO import by.jprof.telegram.bot.votes.model.Votes import by.jprof.telegram.bot.votes.tgbotapi_extensions.toInlineKeyboardMarkup -import dev.inmo.tgbotapi.CommonAbstracts.TextPart import dev.inmo.tgbotapi.bot.RequestsExecutor import dev.inmo.tgbotapi.extensions.api.answers.answerCallbackQuery import dev.inmo.tgbotapi.requests.send.SendTextMessage @@ -92,8 +91,8 @@ internal class JEPUpdateProcessorTest { every { content } returns TextContent( "Hello, world!", listOf( - TextPart(0..2, URLTextSource("https://google.com")), - TextPart(0..2, TextLinkTextSource("google", "https://google.com")), + URLTextSource("https://google.com"), + TextLinkTextSource(" google", "https://google.com") ) ) } @@ -110,8 +109,8 @@ internal class JEPUpdateProcessorTest { every { content } returns TextContent( "Hello, world!", listOf( - TextPart(0..2, URLTextSource("https://openjdk.java.net/jeps/1")), - TextPart(0..2, TextLinkTextSource("JEP 2", "https://openjdk.java.net/jeps/2")), + URLTextSource("https://openjdk.java.net/jeps/1"), + TextLinkTextSource("JEP 2", "https://openjdk.java.net/jeps/2"), ) ) every { chat } returns mockk { @@ -155,8 +154,8 @@ internal class JEPUpdateProcessorTest { every { content } returns TextContent( "Hello, world!", listOf( - TextPart(0..2, URLTextSource("https://openjdk.java.net/jeps/1")), - TextPart(0..2, TextLinkTextSource("JEP 2", "https://openjdk.java.net/jeps/2")), + URLTextSource("https://openjdk.java.net/jeps/1"), + TextLinkTextSource("JEP 2", "https://openjdk.java.net/jeps/2"), ) ) every { chat } returns mockk { diff --git a/quizoji/build.gradle.kts b/quizoji/build.gradle.kts new file mode 100644 index 00000000..73f07b34 --- /dev/null +++ b/quizoji/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + kotlin("jvm") +} + +dependencies { + api(project.projects.core) + api(libs.tgbotapi.core) + implementation(project.projects.dialogs) + implementation(project.projects.votes) + implementation(project.projects.votes.tgbotapiExtensions) + implementation(project.projects.votes.votingProcessor) + implementation(libs.log4j.api) + implementation(libs.tgbotapi.extensions.api) + implementation(libs.tgbotapi.extensions.utils) + + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.mockk) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.log4j.core) +} diff --git a/quizoji/dynamodb/build.gradle.kts b/quizoji/dynamodb/build.gradle.kts new file mode 100644 index 00000000..53bd1f65 --- /dev/null +++ b/quizoji/dynamodb/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + kotlin("jvm") +} + +dependencies { + api(project.projects.quizoji) + api(libs.dynamodb) + implementation(project.projects.utils.dynamodb) + implementation(project.projects.utils.tgbotapiSerialization) + implementation(libs.kotlinx.coroutines.jdk8) + + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.aws.junit5.dynamo.v2) + testImplementation(project.projects.utils.awsJunit5) + testRuntimeOnly(libs.junit.jupiter.engine) +} + +tasks { + val dbTest by registering(Test::class) { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Runs the DB tests." + shouldRunAfter("test") + outputs.upToDateWhen { false } + useJUnitPlatform { + includeTags("db") + } + } +} diff --git a/quizoji/dynamodb/src/main/kotlin/by/jprof/telegram/bot/quizoji/dynamodb/dao/QuizojiDAO.kt b/quizoji/dynamodb/src/main/kotlin/by/jprof/telegram/bot/quizoji/dynamodb/dao/QuizojiDAO.kt new file mode 100644 index 00000000..35da0b9d --- /dev/null +++ b/quizoji/dynamodb/src/main/kotlin/by/jprof/telegram/bot/quizoji/dynamodb/dao/QuizojiDAO.kt @@ -0,0 +1,50 @@ +package by.jprof.telegram.bot.quizoji.dynamodb.dao + +import by.jprof.telegram.bot.quizoji.dao.QuizojiDAO +import by.jprof.telegram.bot.quizoji.model.Quizoji +import by.jprof.telegram.bot.utils.dynamodb.toAttributeValue +import by.jprof.telegram.bot.utils.dynamodb.toString +import by.jprof.telegram.bot.utils.tgbotapi_serialization.TgBotAPI +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.future.await +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient +import software.amazon.awssdk.services.dynamodb.model.AttributeValue + +class QuizojiDAO( + private val dynamoDb: DynamoDbAsyncClient, + private val table: String +) : QuizojiDAO { + override suspend fun save(quizoji: Quizoji) { + withContext(Dispatchers.IO) { + dynamoDb.putItem { + it.tableName(table) + it.item(quizoji.toAttributes()) + }.await() + } + } + + override suspend fun get(id: String): Quizoji? { + return withContext(Dispatchers.IO) { + dynamoDb.getItem { + it.tableName(table) + it.key(mapOf("id" to id.toAttributeValue())) + }.await()?.item()?.takeUnless { it.isEmpty() }?.toQuizoji() + } + } +} + +private val json = Json { serializersModule = TgBotAPI.module } + +fun Quizoji.toAttributes(): Map = mapOf( + "id" to this.id.toAttributeValue(), + "question" to json.encodeToString(this.question).toAttributeValue(), +) + +fun Map.toQuizoji(): Quizoji = Quizoji( + id = this["id"].toString("id"), + question = json.decodeFromString(this["question"].toString("value")), +) diff --git a/quizoji/dynamodb/src/test/kotlin/by/jprof/telegram/bot/quizoji/dynamodb/dao/QuizojiDAOTest.kt b/quizoji/dynamodb/src/test/kotlin/by/jprof/telegram/bot/quizoji/dynamodb/dao/QuizojiDAOTest.kt new file mode 100644 index 00000000..742f0252 --- /dev/null +++ b/quizoji/dynamodb/src/test/kotlin/by/jprof/telegram/bot/quizoji/dynamodb/dao/QuizojiDAOTest.kt @@ -0,0 +1,50 @@ +package by.jprof.telegram.bot.quizoji.dynamodb.dao + +import by.jprof.telegram.bot.quizoji.model.Quizoji +import by.jprof.telegram.bot.utils.aws_junit5.Endpoint +import dev.inmo.tgbotapi.types.MessageEntity.textsources.RegularTextSource +import dev.inmo.tgbotapi.types.message.content.TextContent +import kotlinx.coroutines.runBlocking +import me.madhead.aws_junit5.common.AWSClient +import me.madhead.aws_junit5.dynamo.v2.DynamoDB +import org.junit.jupiter.api.* +import org.junit.jupiter.api.extension.ExtendWith +import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient + +@Tag("db") +@ExtendWith(DynamoDB::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class QuizojiDAOTest { + @AWSClient(endpoint = Endpoint::class) + private lateinit var dynamoDB: DynamoDbAsyncClient + private lateinit var sut: QuizojiDAO + + @BeforeAll + internal fun setup() { + sut = QuizojiDAO(dynamoDB, "quizoji") + } + + @Test + fun save() = runBlocking { + sut.save(quizoji) + } + + @Test + fun get() = runBlocking { + Assertions.assertEquals(quizoji, sut.get("test")) + } + + @Test + fun getUnexisting() = runBlocking { + Assertions.assertNull(sut.get("unexisting")) + } + + private val quizoji + get() = Quizoji( + id = "test", + question = TextContent( + text = "Choose the door", + textSources = listOf(RegularTextSource("Choose the door")), + ), + ) +} diff --git a/quizoji/dynamodb/src/test/kotlin/by/jprof/telegram/bot/quizoji/dynamodb/dao/QuizojiTest.kt b/quizoji/dynamodb/src/test/kotlin/by/jprof/telegram/bot/quizoji/dynamodb/dao/QuizojiTest.kt new file mode 100644 index 00000000..f2ea7888 --- /dev/null +++ b/quizoji/dynamodb/src/test/kotlin/by/jprof/telegram/bot/quizoji/dynamodb/dao/QuizojiTest.kt @@ -0,0 +1,42 @@ +package by.jprof.telegram.bot.quizoji.dynamodb.dao + +import by.jprof.telegram.bot.quizoji.model.Quizoji +import dev.inmo.tgbotapi.types.MessageEntity.textsources.RegularTextSource +import dev.inmo.tgbotapi.types.message.content.TextContent +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import software.amazon.awssdk.services.dynamodb.model.AttributeValue + +internal class QuizojiTest { + @Test + fun toAttributes() { + Assertions.assertEquals( + attributes, + quizoji.toAttributes() + ) + } + + @Test + fun toQuizoji() { + Assertions.assertEquals( + quizoji, + attributes.toQuizoji() + ) + } + + private val quizoji + get() = Quizoji( + id = "test", + question = TextContent( + text = "Choose the door", + textSources = listOf(RegularTextSource("Choose the door")), + ), + ) + private val attributes + get() = mapOf( + "id" to AttributeValue.builder().s("test").build(), + "question" to AttributeValue.builder() + .s("{\"type\":\"TextContent\",\"text\":\"Choose the door\",\"textSources\":[{\"type\":\"regular\",\"value\":{\"source\":\"Choose the door\"}}]}") + .build(), + ) +} diff --git a/quizoji/dynamodb/src/test/resources/quizoji.items.json b/quizoji/dynamodb/src/test/resources/quizoji.items.json new file mode 100644 index 00000000..6cad5527 --- /dev/null +++ b/quizoji/dynamodb/src/test/resources/quizoji.items.json @@ -0,0 +1,16 @@ +{ + "quizoji": [ + { + "PutRequest": { + "Item": { + "id": { + "S": "test" + }, + "question": { + "S": "{\"type\":\"TextContent\",\"text\":\"Choose the door\",\"textSources\":[{\"type\":\"regular\",\"value\":{\"source\":\"Choose the door\"}}]}" + } + } + } + } + ] +} diff --git a/quizoji/dynamodb/src/test/resources/quizoji.table.json b/quizoji/dynamodb/src/test/resources/quizoji.table.json new file mode 100644 index 00000000..e32e4ca8 --- /dev/null +++ b/quizoji/dynamodb/src/test/resources/quizoji.table.json @@ -0,0 +1,19 @@ +{ + "TableName": "quizoji", + "AttributeDefinitions": [ + { + "AttributeName": "id", + "AttributeType": "S" + } + ], + "KeySchema": [ + { + "AttributeName": "id", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "ReadCapacityUnits": 1, + "WriteCapacityUnits": 1 + } +} diff --git a/quizoji/dynamodb/src/test/resources/seed.sh b/quizoji/dynamodb/src/test/resources/seed.sh new file mode 100755 index 00000000..abc109f9 --- /dev/null +++ b/quizoji/dynamodb/src/test/resources/seed.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh + +set -x + +aws --version +aws --endpoint-url "${DYNAMODB_URL}" dynamodb delete-table --table-name quizoji || true +aws --endpoint-url "${DYNAMODB_URL}" dynamodb create-table --cli-input-json file://quizoji/dynamodb/src/test/resources/quizoji.table.json +aws --endpoint-url "${DYNAMODB_URL}" dynamodb batch-write-item --request-items file://quizoji/dynamodb/src/test/resources/quizoji.items.json diff --git a/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiDoneCommandUpdateProcessor.kt b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiDoneCommandUpdateProcessor.kt new file mode 100644 index 00000000..3e3f408e --- /dev/null +++ b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiDoneCommandUpdateProcessor.kt @@ -0,0 +1,107 @@ +package by.jprof.telegram.bot.quizoji + +import by.jprof.telegram.bot.core.UpdateProcessor +import by.jprof.telegram.bot.dialogs.dao.DialogStateDAO +import by.jprof.telegram.bot.dialogs.model.quizoji.WaitingForOptions +import by.jprof.telegram.bot.quizoji.dao.QuizojiDAO +import by.jprof.telegram.bot.quizoji.model.Quizoji +import by.jprof.telegram.bot.votes.dao.VotesDAO +import by.jprof.telegram.bot.votes.model.Votes +import by.jprof.telegram.bot.votes.tgbotapi_extensions.toInlineKeyboardMarkup +import dev.inmo.tgbotapi.bot.RequestsExecutor +import dev.inmo.tgbotapi.extensions.api.send.sendMessage +import dev.inmo.tgbotapi.extensions.utils.asMessageUpdate +import dev.inmo.tgbotapi.extensions.utils.asPrivateChat +import dev.inmo.tgbotapi.extensions.utils.asPrivateContentMessage +import dev.inmo.tgbotapi.extensions.utils.asTextContent +import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.SwitchInlineQueryInlineKeyboardButton +import dev.inmo.tgbotapi.types.buttons.InlineKeyboardMarkup +import dev.inmo.tgbotapi.types.message.content.TextContent +import dev.inmo.tgbotapi.types.update.abstracts.Update +import org.apache.logging.log4j.LogManager + +class QuizojiDoneCommandUpdateProcessor( + private val dialogStateDAO: DialogStateDAO, + private val quizojiDAO: QuizojiDAO, + private val votesDAO: VotesDAO, + private val bot: RequestsExecutor, +) : UpdateProcessor { + companion object { + private val logger = LogManager.getLogger(QuizojiDoneCommandUpdateProcessor::class.java)!! + private val idChars = ('0'..'9') + ('a'..'z') + ('A'..'Z') + private const val idLength = 20 + } + + override suspend fun process(update: Update) { + val message = update.asMessageUpdate()?.data?.asPrivateContentMessage() ?: return + val chat = message.chat.asPrivateChat() ?: return + val state = dialogStateDAO.get(chat.id.chatId, message.user.id.chatId) + + if (state !is WaitingForOptions) { + return + } + + val content = message.content.asTextContent() ?: return + + if (content.text != "/done") { + return + } + + if (state.options.isEmpty()) { + bot.sendMessage( + chat = chat, + text = "Please, provide some options for your quizoji!" + ) + + return + } + + logger.debug("{} finished his Quizoji", chat.id.chatId) + + val quizoji = Quizoji( + id = generateQuizojiID(), + question = state.question, + ) + val votes = Votes( + id = "QUIZOJI-${quizoji.id}", + options = state.options + ) + + votesDAO.save(votes) + quizojiDAO.save(quizoji) + dialogStateDAO.delete(chatId = chat.id.chatId, userId = message.user.id.chatId) + + bot.sendMessage( + chat = chat, + text = "Quizoji created! Use the 'Publish' button to send it." + ) + + when (val question = state.question) { + is TextContent -> { + bot.sendMessage( + chat = chat, + entities = question.textSources, + replyMarkup = InlineKeyboardMarkup( + votes + .toInlineKeyboardMarkup(8) + .keyboard + .plus( + listOf( + listOf( + SwitchInlineQueryInlineKeyboardButton( + text = "Publish", + switchInlineQuery = "quizoji ${quizoji.id}", + ) + ) + ) + ) + ) + ) + } + } + } + + private fun generateQuizojiID(): String { + return (0 until idLength).map { idChars.random() }.joinToString(separator = "") + } +} diff --git a/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiInlineQueryUpdateProcessor.kt b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiInlineQueryUpdateProcessor.kt new file mode 100644 index 00000000..db83828a --- /dev/null +++ b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiInlineQueryUpdateProcessor.kt @@ -0,0 +1,74 @@ +package by.jprof.telegram.bot.quizoji + +import by.jprof.telegram.bot.core.UpdateProcessor +import by.jprof.telegram.bot.quizoji.dao.QuizojiDAO +import by.jprof.telegram.bot.votes.dao.VotesDAO +import by.jprof.telegram.bot.votes.tgbotapi_extensions.toInlineKeyboardMarkup +import dev.inmo.tgbotapi.bot.RequestsExecutor +import dev.inmo.tgbotapi.extensions.api.answers.answerInlineQuery +import dev.inmo.tgbotapi.extensions.utils.asInlineQueryUpdate +import dev.inmo.tgbotapi.types.InlineQueries.InlineQueryResult.InlineQueryResultArticle +import dev.inmo.tgbotapi.types.InlineQueries.InputMessageContent.InputTextMessageContent +import dev.inmo.tgbotapi.types.message.content.TextContent +import dev.inmo.tgbotapi.types.update.abstracts.Update +import org.apache.logging.log4j.LogManager + +class QuizojiInlineQueryUpdateProcessor( + private val quizojiDAO: QuizojiDAO, + private val votesDAO: VotesDAO, + private val bot: RequestsExecutor, +) : UpdateProcessor { + companion object { + private val logger = LogManager.getLogger(QuizojiInlineQueryUpdateProcessor::class.java)!! + } + + override suspend fun process(update: Update) { + val inlineQuery = update.asInlineQueryUpdate()?.data ?: return + + if (inlineQuery.query.equals("quizoji", ignoreCase = true)) { + bot.answerInlineQuery( + inlineQuery = inlineQuery, + switchPmText = "Create new quizoji", + switchPmParameter = "quizoji", + ) + } else if (inlineQuery.query.startsWith("quizoji")) { + val id = inlineQuery.query.substring(7).trim() + + logger.debug("Quizoji #{} requested", id) + + val quizoji = quizojiDAO.get(id) + + if (null == quizoji) { + logger.warn("Quizoji #{} not found!", id) + + return + } + + val votes = votesDAO.get("QUIZOJI-$id") + + if (null == votes) { + logger.warn("Votes for quizoji #{} not found!", id) + + return + } + + when (val question = quizoji.question) { + is TextContent -> { + bot.answerInlineQuery( + inlineQuery = inlineQuery, + results = listOf( + InlineQueryResultArticle( + id = quizoji.id, + title = "Quizoji with ${votes.options.joinToString()} options", + inputMessageContent = InputTextMessageContent( + entities = question.textSources + ), + replyMarkup = votes.toInlineKeyboardMarkup(8) + ) + ) + ) + } + } + } + } +} diff --git a/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiOptionUpdateProcessor.kt b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiOptionUpdateProcessor.kt new file mode 100644 index 00000000..e040a78d --- /dev/null +++ b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiOptionUpdateProcessor.kt @@ -0,0 +1,73 @@ +package by.jprof.telegram.bot.quizoji + +import by.jprof.telegram.bot.core.UpdateProcessor +import by.jprof.telegram.bot.dialogs.dao.DialogStateDAO +import by.jprof.telegram.bot.dialogs.model.quizoji.WaitingForOptions +import dev.inmo.tgbotapi.bot.RequestsExecutor +import dev.inmo.tgbotapi.extensions.api.send.sendMessage +import dev.inmo.tgbotapi.extensions.utils.asMessageUpdate +import dev.inmo.tgbotapi.extensions.utils.asPrivateChat +import dev.inmo.tgbotapi.extensions.utils.asPrivateContentMessage +import dev.inmo.tgbotapi.types.MessageEntity.textsources.BotCommandTextSource +import dev.inmo.tgbotapi.types.ParseMode.MarkdownV2 +import dev.inmo.tgbotapi.types.message.content.TextContent +import dev.inmo.tgbotapi.types.update.abstracts.Update +import org.apache.logging.log4j.LogManager + +class QuizojiOptionUpdateProcessor( + private val dialogStateDAO: DialogStateDAO, + private val bot: RequestsExecutor, +) : UpdateProcessor { + companion object { + private val logger = LogManager.getLogger(QuizojiOptionUpdateProcessor::class.java)!! + } + + override suspend fun process(update: Update) { + val message = update.asMessageUpdate()?.data?.asPrivateContentMessage() ?: return + val chat = message.chat.asPrivateChat() ?: return + val state = dialogStateDAO.get(chat.id.chatId, message.user.id.chatId) + val content = message.content + + if (state !is WaitingForOptions) { + return + } + + if (content !is TextContent) { + logger.warn("Unsupported option content: {}", content) + + bot.sendMessage( + chat = chat, + text = "Unsupported option type: ${content::class.simpleName?.replace("Content", "")}" + ) + + return + } + + if (content.textSources.any { it is BotCommandTextSource }) { + logger.warn("Command sent as option: {}", content) + + return + } + + logger.debug("{} provided an option ({}) for his Quizoji", chat.id.chatId, content.text) + + dialogStateDAO.save( + WaitingForOptions( + chatId = chat.id.chatId, + userId = message.user.id.chatId, + question = state.question, + options = state.options + content.text + ) + ) + + bot.sendMessage( + chat = chat, + text = if (state.options.size >= 7) { + "Send more options or /done when ready\\." + } else { + "Send more options or /done when ready\\.\n\n_Up to ${7 - state.options.size} more options are recommended\\._" + }, + parseMode = MarkdownV2 + ) + } +} diff --git a/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiQuestionUpdateProcessor.kt b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiQuestionUpdateProcessor.kt new file mode 100644 index 00000000..2d2fc5fb --- /dev/null +++ b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiQuestionUpdateProcessor.kt @@ -0,0 +1,75 @@ +package by.jprof.telegram.bot.quizoji + +import by.jprof.telegram.bot.core.UpdateProcessor +import by.jprof.telegram.bot.dialogs.dao.DialogStateDAO +import by.jprof.telegram.bot.dialogs.model.quizoji.WaitingForOptions +import by.jprof.telegram.bot.dialogs.model.quizoji.WaitingForQuestion +import dev.inmo.tgbotapi.bot.RequestsExecutor +import dev.inmo.tgbotapi.extensions.api.send.sendMessage +import dev.inmo.tgbotapi.extensions.utils.asMessageUpdate +import dev.inmo.tgbotapi.extensions.utils.asPrivateChat +import dev.inmo.tgbotapi.extensions.utils.asPrivateContentMessage +import dev.inmo.tgbotapi.types.MessageEntity.textsources.BotCommandTextSource +import dev.inmo.tgbotapi.types.ParseMode.MarkdownV2 +import dev.inmo.tgbotapi.types.message.content.TextContent +import dev.inmo.tgbotapi.types.message.content.abstracts.MessageContent +import dev.inmo.tgbotapi.types.update.abstracts.Update +import org.apache.logging.log4j.LogManager +import kotlin.reflect.KClass + +class QuizojiQuestionUpdateProcessor( + private val dialogStateDAO: DialogStateDAO, + private val bot: RequestsExecutor, +) : UpdateProcessor { + companion object { + private val logger = LogManager.getLogger(QuizojiQuestionUpdateProcessor::class.java)!! + private val supportedTypes = setOf>( + TextContent::class + ) + } + + override suspend fun process(update: Update) { + val message = update.asMessageUpdate()?.data?.asPrivateContentMessage() ?: return + val chat = message.chat.asPrivateChat() ?: return + val state = dialogStateDAO.get(chat.id.chatId, message.user.id.chatId) + + if (state !is WaitingForQuestion) { + return + } + + val content = message.content + + if (content::class !in supportedTypes) { + logger.warn("Unsupported message content: {}", content) + + bot.sendMessage( + chat = chat, + text = "Unsupported question type: ${content::class.simpleName?.replace("Content", "")}" + ) + + return + } + + if ((content as? TextContent)?.textSources?.any { it is BotCommandTextSource } == true) { + logger.warn("Command sent as question: {}", content) + + return + } + + logger.debug("{} provided a question ({}) for his Quizoji", chat.id.chatId, content) + + dialogStateDAO.save( + WaitingForOptions( + chatId = chat.id.chatId, + userId = message.user.id.chatId, + question = content + ) + ) + + bot.sendMessage( + chat = chat, + text = "Now send me the options, one per message\\. When done, send /done\\.\n\n_Up to 8 options are recommended, otherwise the buttons will be wrapped in multiple lines\\._", + parseMode = MarkdownV2 + ) + } +} diff --git a/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiStartCommandUpdateProcessor.kt b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiStartCommandUpdateProcessor.kt new file mode 100644 index 00000000..bcf0ff61 --- /dev/null +++ b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiStartCommandUpdateProcessor.kt @@ -0,0 +1,41 @@ +package by.jprof.telegram.bot.quizoji + +import by.jprof.telegram.bot.core.UpdateProcessor +import by.jprof.telegram.bot.dialogs.dao.DialogStateDAO +import by.jprof.telegram.bot.dialogs.model.quizoji.WaitingForQuestion +import dev.inmo.tgbotapi.bot.RequestsExecutor +import dev.inmo.tgbotapi.extensions.api.send.sendMessage +import dev.inmo.tgbotapi.extensions.utils.asMessageUpdate +import dev.inmo.tgbotapi.extensions.utils.asPrivateChat +import dev.inmo.tgbotapi.extensions.utils.asPrivateContentMessage +import dev.inmo.tgbotapi.extensions.utils.asTextContent +import dev.inmo.tgbotapi.types.update.abstracts.Update +import org.apache.logging.log4j.LogManager + +class QuizojiStartCommandUpdateProcessor( + private val dialogStateDAO: DialogStateDAO, + private val bot: RequestsExecutor, +) : UpdateProcessor { + companion object { + private val logger = LogManager.getLogger(QuizojiStartCommandUpdateProcessor::class.java)!! + } + + override suspend fun process(update: Update) { + val message = update.asMessageUpdate()?.data?.asPrivateContentMessage() ?: return + val chat = message.chat.asPrivateChat() ?: return + val content = message.content.asTextContent() ?: return + + if (content.text != "/start quizoji") { + return + } + + logger.debug("{} started new Quizoji", chat.id.chatId) + + dialogStateDAO.save(WaitingForQuestion(chat.id.chatId, message.user.id.chatId)) + + bot.sendMessage( + chat = chat, + text = "Let's create a Quizoji! First, send me the question." + ) + } +} diff --git a/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiVoteUpdateProcessor.kt b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiVoteUpdateProcessor.kt new file mode 100644 index 00000000..c1f5de94 --- /dev/null +++ b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/QuizojiVoteUpdateProcessor.kt @@ -0,0 +1,28 @@ +package by.jprof.telegram.bot.quizoji + +import by.jprof.telegram.bot.core.UpdateProcessor +import by.jprof.telegram.bot.votes.dao.VotesDAO +import by.jprof.telegram.bot.votes.model.Votes +import by.jprof.telegram.bot.votes.tgbotapi_extensions.toInlineKeyboardMarkup +import by.jprof.telegram.bot.votes.voting_processor.VotingProcessor +import dev.inmo.tgbotapi.bot.RequestsExecutor +import dev.inmo.tgbotapi.types.update.CallbackQueryUpdate +import dev.inmo.tgbotapi.types.update.abstracts.Update + +class QuizojiVoteUpdateProcessor( + votesDAO: VotesDAO, + bot: RequestsExecutor +) : VotingProcessor( + "QUIZOJI", + votesDAO, + { throw UnsupportedOperationException("Votes should be constructed elsewhere!") }, + bot, +), UpdateProcessor { + override suspend fun process(update: Update) { + when (update) { + is CallbackQueryUpdate -> processCallbackQuery(update.data) + } + } + + override fun votesToInlineKeyboardMarkup(votes: Votes) = votes.toInlineKeyboardMarkup(8) +} diff --git a/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/dao/QuizojiDAO.kt b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/dao/QuizojiDAO.kt new file mode 100644 index 00000000..bd1235e6 --- /dev/null +++ b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/dao/QuizojiDAO.kt @@ -0,0 +1,9 @@ +package by.jprof.telegram.bot.quizoji.dao + +import by.jprof.telegram.bot.quizoji.model.Quizoji + +interface QuizojiDAO { + suspend fun save(quizoji: Quizoji) + + suspend fun get(id: String): Quizoji? +} diff --git a/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/model/Quizoji.kt b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/model/Quizoji.kt new file mode 100644 index 00000000..bff3095b --- /dev/null +++ b/quizoji/src/main/kotlin/by/jprof/telegram/bot/quizoji/model/Quizoji.kt @@ -0,0 +1,8 @@ +package by.jprof.telegram.bot.quizoji.model + +import dev.inmo.tgbotapi.types.message.content.abstracts.MessageContent + +data class Quizoji( + val id: String, + val question: MessageContent, +) diff --git a/quizoji/src/test/kotlin/by/jprof/telegram/bot/quizoji/QuizojiDoneCommandUpdateProcessorTest.kt b/quizoji/src/test/kotlin/by/jprof/telegram/bot/quizoji/QuizojiDoneCommandUpdateProcessorTest.kt new file mode 100644 index 00000000..d3203340 --- /dev/null +++ b/quizoji/src/test/kotlin/by/jprof/telegram/bot/quizoji/QuizojiDoneCommandUpdateProcessorTest.kt @@ -0,0 +1,322 @@ +package by.jprof.telegram.bot.quizoji + +import by.jprof.telegram.bot.dialogs.dao.DialogStateDAO +import by.jprof.telegram.bot.dialogs.model.DialogState +import by.jprof.telegram.bot.dialogs.model.quizoji.WaitingForOptions +import by.jprof.telegram.bot.quizoji.dao.QuizojiDAO +import by.jprof.telegram.bot.quizoji.model.Quizoji +import by.jprof.telegram.bot.votes.dao.VotesDAO +import by.jprof.telegram.bot.votes.model.Votes +import com.soywiz.klock.DateTime +import dev.inmo.tgbotapi.bot.RequestsExecutor +import dev.inmo.tgbotapi.extensions.api.send.sendMessage +import dev.inmo.tgbotapi.types.ChatId +import dev.inmo.tgbotapi.types.CommonUser +import dev.inmo.tgbotapi.types.MessageEntity.textsources.RegularTextSource +import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.CallbackDataInlineKeyboardButton +import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.SwitchInlineQueryInlineKeyboardButton +import dev.inmo.tgbotapi.types.buttons.InlineKeyboardMarkup +import dev.inmo.tgbotapi.types.chat.PrivateChatImpl +import dev.inmo.tgbotapi.types.chat.abstracts.ChannelChat +import dev.inmo.tgbotapi.types.message.PrivateContentMessageImpl +import dev.inmo.tgbotapi.types.message.abstracts.ChannelContentMessage +import dev.inmo.tgbotapi.types.message.content.TextContent +import dev.inmo.tgbotapi.types.message.content.media.AudioContent +import dev.inmo.tgbotapi.types.update.MessageUpdate +import dev.inmo.tgbotapi.types.update.PollUpdate +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(MockKExtension::class) +internal class QuizojiDoneCommandUpdateProcessorTest { + @MockK(relaxed = true) + private lateinit var bot: RequestsExecutor + + @MockK + private lateinit var dialogStateDAO: DialogStateDAO + + @MockK + private lateinit var quizojiDAO: QuizojiDAO + + @MockK + private lateinit var votesDAO: VotesDAO + + lateinit var sut: QuizojiDoneCommandUpdateProcessor + + @BeforeEach + fun setUp() { + sut = QuizojiDoneCommandUpdateProcessor( + dialogStateDAO = dialogStateDAO, + quizojiDAO = quizojiDAO, + votesDAO = votesDAO, + bot = bot, + ) + } + + @Test + fun processNonMessageUpdate() = runBlocking { + sut.process( + PollUpdate( + updateId = 1, + data = mockk() + ) + ) + + verify { listOf(dialogStateDAO, quizojiDAO, votesDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processNonPrivateContentMessage() = runBlocking { + sut.process( + MessageUpdate( + updateId = 1, + data = mockk>() + ) + ) + + verify { listOf(dialogStateDAO, quizojiDAO, votesDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processNonPrivateChat() = runBlocking { + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = mockk(), + chat = mockk(), + content = mockk(), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + verify { listOf(dialogStateDAO, quizojiDAO, votesDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processNonWaitingForOptions() = runBlocking { + coEvery { dialogStateDAO.get(1, 2) }.returns(object : DialogState { + override val chatId = 1L + override val userId = 2L + }) + + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = CommonUser(ChatId(2), "Test"), + chat = PrivateChatImpl(ChatId(1)), + content = TextContent("/done"), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + coVerify(exactly = 1) { dialogStateDAO.get(1, 2) } + verify { listOf(quizojiDAO, votesDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processNonTextContentMessage() = runBlocking { + coEvery { dialogStateDAO.get(1, 2) }.returns(WaitingForOptions(1, 2, TextContent(""), listOf(""))) + + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = CommonUser(ChatId(2), "Test"), + chat = PrivateChatImpl(ChatId(1)), + content = mockk(), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + coVerify(exactly = 1) { dialogStateDAO.get(1, 2) } + verify { listOf(quizojiDAO, votesDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processWrongCommand() = runBlocking { + coEvery { dialogStateDAO.get(1, 2) }.returns(WaitingForOptions(1, 2, TextContent(""), listOf(""))) + + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = CommonUser(ChatId(2), "Test"), + chat = PrivateChatImpl(ChatId(1)), + content = TextContent("/start doing your morning exercise"), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + coVerify(exactly = 1) { dialogStateDAO.get(1, 2) } + verify { listOf(quizojiDAO, votesDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processNoOptions() = runBlocking { + val question = TextContent("Question", listOf(RegularTextSource("Question"))) + + coEvery { dialogStateDAO.get(1, 2) }.returns(WaitingForOptions(1, 2, question)) + coEvery { votesDAO.save(any()) } just runs + coEvery { quizojiDAO.save(any()) } just runs + coEvery { dialogStateDAO.delete(any(), any()) } just runs + + val chat = PrivateChatImpl(ChatId(1)) + + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = CommonUser(ChatId(2), "Test"), + chat = chat, + content = TextContent("/done"), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + coVerify(exactly = 1) { dialogStateDAO.get(1, 2) } + coVerify(exactly = 1) { + bot.sendMessage( + chat = chat, + text = "Please, provide some options for your quizoji!" + ) + } + verify { listOf(quizojiDAO, votesDAO) wasNot called } + + clearAllMocks() + } + + @Test + fun processTextQuestion() = runBlocking { + val question = TextContent("Question", listOf(RegularTextSource("Question"))) + + coEvery { dialogStateDAO.get(1, 2) }.returns(WaitingForOptions(1, 2, question, listOf("1", "2"))) + coEvery { votesDAO.save(any()) } just runs + coEvery { quizojiDAO.save(any()) } just runs + coEvery { dialogStateDAO.delete(any(), any()) } just runs + + val chat = PrivateChatImpl(ChatId(1)) + + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = CommonUser(ChatId(2), "Test"), + chat = chat, + content = TextContent("/done"), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + val quizojiSlot = CapturingSlot() + val votesSlot = CapturingSlot() + + coVerify(exactly = 1) { dialogStateDAO.get(1, 2) } + coVerify(exactly = 1) { quizojiDAO.save(capture(quizojiSlot)) } + Assertions.assertEquals(question, quizojiSlot.captured.question) + coVerify(exactly = 1) { votesDAO.save(capture(votesSlot)) } + Assertions.assertEquals("QUIZOJI-${quizojiSlot.captured.id}", votesSlot.captured.id) + Assertions.assertEquals(listOf("1", "2"), votesSlot.captured.options) + coVerify(exactly = 1) { dialogStateDAO.delete(1, 2) } + coVerify(exactly = 1) { + bot.sendMessage( + chat = chat, + text = "Quizoji created! Use the 'Publish' button to send it." + ) + } + coVerify { + bot.sendMessage( + chat = chat, + entities = question.textSources, + replyMarkup = InlineKeyboardMarkup( + listOf( + listOf( + CallbackDataInlineKeyboardButton( + text = "0 1", + callbackData = "QUIZOJI-${quizojiSlot.captured.id}:1" + ), + CallbackDataInlineKeyboardButton( + text = "0 2", + callbackData = "QUIZOJI-${quizojiSlot.captured.id}:2" + ) + ), + listOf( + SwitchInlineQueryInlineKeyboardButton( + text = "Publish", + switchInlineQuery = "quizoji ${quizojiSlot.captured.id}", + ) + ), + ) + ) + ) + } + + clearAllMocks() + } +} diff --git a/quizoji/src/test/kotlin/by/jprof/telegram/bot/quizoji/QuizojiInlineQueryUpdateProcessorTest.kt b/quizoji/src/test/kotlin/by/jprof/telegram/bot/quizoji/QuizojiInlineQueryUpdateProcessorTest.kt new file mode 100644 index 00000000..4911012e --- /dev/null +++ b/quizoji/src/test/kotlin/by/jprof/telegram/bot/quizoji/QuizojiInlineQueryUpdateProcessorTest.kt @@ -0,0 +1,207 @@ +package by.jprof.telegram.bot.quizoji + +import by.jprof.telegram.bot.quizoji.dao.QuizojiDAO +import by.jprof.telegram.bot.quizoji.model.Quizoji +import by.jprof.telegram.bot.votes.dao.VotesDAO +import by.jprof.telegram.bot.votes.model.Votes +import dev.inmo.tgbotapi.bot.RequestsExecutor +import dev.inmo.tgbotapi.extensions.api.answers.answerInlineQuery +import dev.inmo.tgbotapi.types.InlineQueries.query.BaseInlineQuery +import dev.inmo.tgbotapi.types.MessageEntity.textsources.RegularTextSource +import dev.inmo.tgbotapi.types.message.content.TextContent +import dev.inmo.tgbotapi.types.update.InlineQueryUpdate +import dev.inmo.tgbotapi.types.update.MessageUpdate +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(MockKExtension::class) +internal class QuizojiInlineQueryUpdateProcessorTest { + @MockK + private lateinit var quizojiDAO: QuizojiDAO + + @MockK + private lateinit var votesDAO: VotesDAO + + @MockK(relaxed = true) + private lateinit var bot: RequestsExecutor + + lateinit var sut: QuizojiInlineQueryUpdateProcessor + + @BeforeEach + fun setUp() { + sut = QuizojiInlineQueryUpdateProcessor( + quizojiDAO = quizojiDAO, + votesDAO = votesDAO, + bot = bot, + ) + } + + @Test + fun processNonInilineQuery() = runBlocking { + sut.process( + MessageUpdate( + updateId = 1L, + data = mockk() + ) + ) + + verify { listOf(quizojiDAO, votesDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processAlienInilineQuery() = runBlocking { + sut.process( + InlineQueryUpdate( + updateId = 1L, + data = BaseInlineQuery( + id = "QuizojiStartCommandUpdateProcessorTest", + from = mockk(), + query = "alien", + offset = "", + chatType = null, + ) + ) + ) + + verify { listOf(quizojiDAO, votesDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processNewQuizojiInilineQuery() = runBlocking { + val inlineQuery = BaseInlineQuery( + id = "QuizojiStartCommandUpdateProcessorTest", + from = mockk(), + query = "quizoji", + offset = "", + chatType = null, + ) + + sut.process( + InlineQueryUpdate( + updateId = 1L, + data = inlineQuery + ) + ) + + coVerify(exactly = 1) { + bot.answerInlineQuery( + inlineQuery = inlineQuery, + switchPmText = "Create new quizoji", + switchPmParameter = "quizoji", + ) + } + verify { listOf(quizojiDAO, votesDAO) wasNot called } + + clearAllMocks() + } + + @Test + fun processUnknownQuizojiIDInilineQuery() = runBlocking { + val inlineQuery = BaseInlineQuery( + id = "QuizojiStartCommandUpdateProcessorTest", + from = mockk(), + query = "quizoji id", + offset = "", + chatType = null, + ) + + coEvery { quizojiDAO.get("id") }.returns(null) + + sut.process( + InlineQueryUpdate( + updateId = 1L, + data = inlineQuery + ) + ) + + coVerify(exactly = 1) { + quizojiDAO.get("id") + } + + verify { listOf(votesDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processUnexistingVotesQuizojiIDInilineQuery() = runBlocking { + val inlineQuery = BaseInlineQuery( + id = "QuizojiStartCommandUpdateProcessorTest", + from = mockk(), + query = "quizoji id", + offset = "", + chatType = null, + ) + + coEvery { quizojiDAO.get("id") }.returns(Quizoji("id", TextContent("Question"))) + coEvery { votesDAO.get("QUIZOJI-id") }.returns(null) + + sut.process( + InlineQueryUpdate( + updateId = 1L, + data = inlineQuery + ) + ) + + coVerify(exactly = 1) { + quizojiDAO.get("id") + } + coVerify(exactly = 1) { + votesDAO.get("QUIZOJI-id") + } + + verify { listOf(bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processQuizojiIDInilineQuery() = runBlocking { + val inlineQuery = BaseInlineQuery( + id = "QuizojiStartCommandUpdateProcessorTest", + from = mockk(), + query = "quizoji id", + offset = "", + chatType = null, + ) + + coEvery { quizojiDAO.get("id") }.returns( + Quizoji( + "id", + TextContent("Question", listOf(RegularTextSource("Question"))) + ) + ) + coEvery { votesDAO.get("QUIZOJI-id") }.returns(Votes("QUIZOJI-id", listOf("1", "2"), mapOf("1" to "1"))) + + sut.process( + InlineQueryUpdate( + updateId = 1L, + data = inlineQuery + ) + ) + + coVerify(exactly = 1) { + quizojiDAO.get("id") + } + coVerify(exactly = 1) { + votesDAO.get("QUIZOJI-id") + } +// coVerify(exactly = 1) { +// bot.answerInlineQuery( +// inlineQuery = inlineQuery, +// results = any() +// ) +// } + + clearAllMocks() + } +} diff --git a/quizoji/src/test/kotlin/by/jprof/telegram/bot/quizoji/QuizojiOptionUpdateProcessorTest.kt b/quizoji/src/test/kotlin/by/jprof/telegram/bot/quizoji/QuizojiOptionUpdateProcessorTest.kt new file mode 100644 index 00000000..7fc49a6d --- /dev/null +++ b/quizoji/src/test/kotlin/by/jprof/telegram/bot/quizoji/QuizojiOptionUpdateProcessorTest.kt @@ -0,0 +1,342 @@ +package by.jprof.telegram.bot.quizoji + +import by.jprof.telegram.bot.dialogs.dao.DialogStateDAO +import by.jprof.telegram.bot.dialogs.model.DialogState +import by.jprof.telegram.bot.dialogs.model.quizoji.WaitingForOptions +import com.soywiz.klock.DateTime +import dev.inmo.tgbotapi.bot.RequestsExecutor +import dev.inmo.tgbotapi.extensions.api.send.sendMessage +import dev.inmo.tgbotapi.types.ChatId +import dev.inmo.tgbotapi.types.CommonUser +import dev.inmo.tgbotapi.types.MessageEntity.textsources.BotCommandTextSource +import dev.inmo.tgbotapi.types.ParseMode.MarkdownV2 +import dev.inmo.tgbotapi.types.chat.PrivateChatImpl +import dev.inmo.tgbotapi.types.chat.abstracts.ChannelChat +import dev.inmo.tgbotapi.types.dice.Dice +import dev.inmo.tgbotapi.types.dice.SlotMachineDiceAnimationType +import dev.inmo.tgbotapi.types.message.PrivateContentMessageImpl +import dev.inmo.tgbotapi.types.message.abstracts.ChannelContentMessage +import dev.inmo.tgbotapi.types.message.content.DiceContent +import dev.inmo.tgbotapi.types.message.content.TextContent +import dev.inmo.tgbotapi.types.update.MessageUpdate +import dev.inmo.tgbotapi.types.update.PollUpdate +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(MockKExtension::class) +internal class QuizojiOptionUpdateProcessorTest { + @MockK(relaxed = true) + private lateinit var bot: RequestsExecutor + + @MockK(relaxed = true) + private lateinit var dialogStateDAO: DialogStateDAO + + lateinit var sut: QuizojiOptionUpdateProcessor + + @BeforeEach + fun setUp() { + sut = QuizojiOptionUpdateProcessor( + dialogStateDAO = dialogStateDAO, + bot = bot, + ) + } + + @Test + fun processNonMessageUpdate() = runBlocking { + sut.process( + PollUpdate( + updateId = 1, + data = mockk() + ) + ) + + verify { listOf(dialogStateDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processNonPrivateContentMessage() = runBlocking { + sut.process( + MessageUpdate( + updateId = 1, + data = mockk>() + ) + ) + + verify { listOf(dialogStateDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processNonPrivateChat() = runBlocking { + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = mockk(), + chat = mockk(), + content = mockk(), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + verify { listOf(dialogStateDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processNonWaitingForOptions() = runBlocking { + val chat = PrivateChatImpl( + id = ChatId(1), + ) + + coEvery { dialogStateDAO.get(1, 2) }.returns(object : DialogState { + override val chatId = 1L + override val userId = 2L + }) + + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = CommonUser(id = ChatId(2), "Test"), + chat = chat, + content = TextContent( + text = "Test" + ), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + coVerify(exactly = 1) { dialogStateDAO.get(1, 2) } + verify { listOf(bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processUnsupportedContent() = runBlocking { + val chat = PrivateChatImpl( + id = ChatId(1), + ) + + coEvery { dialogStateDAO.get(1, 2) }.returns(WaitingForOptions(1, 2, TextContent("Test"))) + + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = CommonUser(id = ChatId(2), "Test"), + chat = chat, + content = DiceContent(dice = Dice(value = 3, animationType = SlotMachineDiceAnimationType)), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + coVerify(exactly = 1) { dialogStateDAO.get(1, 2) } + coVerify(exactly = 1) { + bot.sendMessage( + chat = chat, + text = "Unsupported option type: Dice" + ) + } + + clearAllMocks() + } + + @Test + fun processDoneCommand() = runBlocking { + val chat = PrivateChatImpl( + id = ChatId(1), + ) + + coEvery { dialogStateDAO.get(1, 2) }.returns(WaitingForOptions(1, 2, TextContent("Test"))) + + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = CommonUser(id = ChatId(2), "Test"), + chat = chat, + content = TextContent( + text = "/done", + textSources = listOf(BotCommandTextSource("done")) + ), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + coVerify(exactly = 1) { dialogStateDAO.get(1, 2) } + verify { listOf(bot) wasNot called } + + clearAllMocks() + } + + @Test + fun process() = runBlocking { + val chat = PrivateChatImpl( + id = ChatId(1), + ) + val content = TextContent( + text = "Option" + ) + + coEvery { dialogStateDAO.get(1, 2) }.returns(WaitingForOptions(1, 2, TextContent("Test"))) + + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = CommonUser(id = ChatId(2), "Test"), + chat = chat, + content = content, + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + coVerify(exactly = 1) { dialogStateDAO.get(1, 2) } + coVerify(exactly = 1) { + dialogStateDAO.save( + WaitingForOptions( + chatId = 1, + userId = 2, + question = TextContent("Test"), + options = listOf("Option") + ) + ) + } + coVerify(exactly = 1) { + bot.sendMessage( + chat = chat, + text = "Send more options or /done when ready\\.\n\n_Up to 7 more options are recommended\\._", + parseMode = MarkdownV2 + ) + } + + clearAllMocks() + } + + @Test + fun processManyOptions() = runBlocking { + val chat = PrivateChatImpl( + id = ChatId(1), + ) + val content = TextContent( + text = "Option 8" + ) + + coEvery { dialogStateDAO.get(1, 2) }.returns( + WaitingForOptions( + 1, + 2, + TextContent("Test"), + listOf( + "Option 1", + "Option 2", + "Option 3", + "Option 4", + "Option 5", + "Option 6", + "Option 7", + ) + ) + ) + + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = CommonUser(id = ChatId(2), "Test"), + chat = chat, + content = content, + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + coVerify(exactly = 1) { dialogStateDAO.get(1, 2) } + coVerify(exactly = 1) { + dialogStateDAO.save( + WaitingForOptions( + chatId = 1, + userId = 2, + question = TextContent("Test"), + options = listOf( + "Option 1", + "Option 2", + "Option 3", + "Option 4", + "Option 5", + "Option 6", + "Option 7", + "Option 8", + ) + ) + ) + } + coVerify(exactly = 1) { + bot.sendMessage( + chat = chat, + text = "Send more options or /done when ready\\.", + parseMode = MarkdownV2 + ) + } + + clearAllMocks() + } +} diff --git a/quizoji/src/test/kotlin/by/jprof/telegram/bot/quizoji/QuizojiQuestionUpdateProcessorTest.kt b/quizoji/src/test/kotlin/by/jprof/telegram/bot/quizoji/QuizojiQuestionUpdateProcessorTest.kt new file mode 100644 index 00000000..4c8108f9 --- /dev/null +++ b/quizoji/src/test/kotlin/by/jprof/telegram/bot/quizoji/QuizojiQuestionUpdateProcessorTest.kt @@ -0,0 +1,229 @@ +package by.jprof.telegram.bot.quizoji + +import by.jprof.telegram.bot.dialogs.dao.DialogStateDAO +import by.jprof.telegram.bot.dialogs.model.DialogState +import by.jprof.telegram.bot.dialogs.model.quizoji.WaitingForOptions +import by.jprof.telegram.bot.dialogs.model.quizoji.WaitingForQuestion +import com.soywiz.klock.DateTime +import dev.inmo.tgbotapi.bot.RequestsExecutor +import dev.inmo.tgbotapi.extensions.api.send.sendMessage +import dev.inmo.tgbotapi.types.ChatId +import dev.inmo.tgbotapi.types.CommonUser +import dev.inmo.tgbotapi.types.ParseMode.MarkdownV2 +import dev.inmo.tgbotapi.types.chat.PrivateChatImpl +import dev.inmo.tgbotapi.types.chat.abstracts.ChannelChat +import dev.inmo.tgbotapi.types.dice.Dice +import dev.inmo.tgbotapi.types.dice.SlotMachineDiceAnimationType +import dev.inmo.tgbotapi.types.message.PrivateContentMessageImpl +import dev.inmo.tgbotapi.types.message.abstracts.ChannelContentMessage +import dev.inmo.tgbotapi.types.message.content.DiceContent +import dev.inmo.tgbotapi.types.message.content.TextContent +import dev.inmo.tgbotapi.types.update.MessageUpdate +import dev.inmo.tgbotapi.types.update.PollUpdate +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(MockKExtension::class) +internal class QuizojiQuestionUpdateProcessorTest { + @MockK(relaxed = true) + private lateinit var bot: RequestsExecutor + + @MockK(relaxed = true) + private lateinit var dialogStateDAO: DialogStateDAO + + lateinit var sut: QuizojiQuestionUpdateProcessor + + @BeforeEach + fun setUp() { + sut = QuizojiQuestionUpdateProcessor( + dialogStateDAO = dialogStateDAO, + bot = bot, + ) + } + + @Test + fun processNonMessageUpdate() = runBlocking { + sut.process( + PollUpdate( + updateId = 1, + data = mockk() + ) + ) + + verify { listOf(dialogStateDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processNonPrivateContentMessage() = runBlocking { + sut.process( + MessageUpdate( + updateId = 1, + data = mockk>() + ) + ) + + verify { listOf(dialogStateDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processNonPrivateChat() = runBlocking { + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = mockk(), + chat = mockk(), + content = mockk(), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + verify { listOf(dialogStateDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processNonWaitingForQuestion() = runBlocking { + val chat = PrivateChatImpl( + id = ChatId(1), + ) + + coEvery { dialogStateDAO.get(1, 2) }.returns(object : DialogState { + override val chatId = 1L + override val userId = 2L + }) + + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = CommonUser(id = ChatId(2), "Test"), + chat = chat, + content = TextContent( + text = "Test" + ), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + coVerify(exactly = 1) { dialogStateDAO.get(1, 2) } + verify { listOf(bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processUnsupportedContent() = runBlocking { + val chat = PrivateChatImpl( + id = ChatId(1), + ) + + coEvery { dialogStateDAO.get(1, 2) }.returns(WaitingForQuestion(1, 2)) + + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = CommonUser(id = ChatId(2), "Test"), + chat = chat, + content = DiceContent(dice = Dice(value = 3, animationType = SlotMachineDiceAnimationType)), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + coVerify(exactly = 1) { dialogStateDAO.get(1, 2) } + coVerify(exactly = 1) { + bot.sendMessage( + chat = chat, + text = "Unsupported question type: Dice" + ) + } + + clearAllMocks() + } + + @Test + fun process() = runBlocking { + val chat = PrivateChatImpl( + id = ChatId(1), + ) + val content = TextContent( + text = "Test" + ) + + coEvery { dialogStateDAO.get(1, 2) }.returns(WaitingForQuestion(1, 2)) + + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = CommonUser(id = ChatId(2), "Test"), + chat = chat, + content = content, + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + coVerify(exactly = 1) { dialogStateDAO.get(1, 2) } + coVerify(exactly = 1) { + dialogStateDAO.save( + WaitingForOptions( + chatId = 1, + userId = 2, + question = content + ) + ) + } + coVerify(exactly = 1) { + bot.sendMessage( + chat = chat, + text = "Now send me the options, one per message\\. When done, send /done\\.\n\n_Up to 8 options are recommended, otherwise the buttons will be wrapped in multiple lines\\._", + parseMode = MarkdownV2 + ) + } + + clearAllMocks() + } +} diff --git a/quizoji/src/test/kotlin/by/jprof/telegram/bot/quizoji/QuizojiStartCommandUpdateProcessorTest.kt b/quizoji/src/test/kotlin/by/jprof/telegram/bot/quizoji/QuizojiStartCommandUpdateProcessorTest.kt new file mode 100644 index 00000000..4e4ee9da --- /dev/null +++ b/quizoji/src/test/kotlin/by/jprof/telegram/bot/quizoji/QuizojiStartCommandUpdateProcessorTest.kt @@ -0,0 +1,195 @@ +package by.jprof.telegram.bot.quizoji + +import by.jprof.telegram.bot.dialogs.dao.DialogStateDAO +import by.jprof.telegram.bot.dialogs.model.quizoji.WaitingForQuestion +import com.soywiz.klock.DateTime +import dev.inmo.tgbotapi.bot.RequestsExecutor +import dev.inmo.tgbotapi.extensions.api.send.sendMessage +import dev.inmo.tgbotapi.types.Bot +import dev.inmo.tgbotapi.types.ChatId +import dev.inmo.tgbotapi.types.CommonUser +import dev.inmo.tgbotapi.types.chat.PrivateChatImpl +import dev.inmo.tgbotapi.types.chat.abstracts.ChannelChat +import dev.inmo.tgbotapi.types.chat.abstracts.PrivateChat +import dev.inmo.tgbotapi.types.message.PrivateContentMessageImpl +import dev.inmo.tgbotapi.types.message.abstracts.ChannelContentMessage +import dev.inmo.tgbotapi.types.message.content.TextContent +import dev.inmo.tgbotapi.types.message.content.media.AudioContent +import dev.inmo.tgbotapi.types.update.MessageUpdate +import dev.inmo.tgbotapi.types.update.PollUpdate +import io.mockk.* +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(MockKExtension::class) +internal class QuizojiStartCommandUpdateProcessorTest { + @MockK(relaxed = true) + private lateinit var bot: RequestsExecutor + + @MockK(relaxed = true) + private lateinit var dialogStateDAO: DialogStateDAO + + lateinit var sut: QuizojiStartCommandUpdateProcessor + + @BeforeEach + fun setUp() { + sut = QuizojiStartCommandUpdateProcessor( + dialogStateDAO = dialogStateDAO, + bot = bot, + ) + } + + @Test + fun processNonMessageUpdate() = runBlocking { + sut.process( + PollUpdate( + updateId = 1, + data = mockk() + ) + ) + + verify { listOf(dialogStateDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processNonPrivateContentMessage() = runBlocking { + sut.process( + MessageUpdate( + updateId = 1, + data = mockk>() + ) + ) + + verify { listOf(dialogStateDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processNonPrivateChat() = runBlocking { + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = mockk(), + chat = mockk(), + content = mockk(), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + verify { listOf(dialogStateDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processNonTextContentMessage() = runBlocking { + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = mockk(), + chat = mockk(), + content = mockk(), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + verify { listOf(dialogStateDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun processWrongCommand() = runBlocking { + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = mockk(), + chat = mockk(), + content = TextContent( + text = "/start doing your morning exercise" + ), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + verify { listOf(dialogStateDAO, bot) wasNot called } + + clearAllMocks() + } + + @Test + fun process() = runBlocking { + val chat = PrivateChatImpl( + id = ChatId(1), + ) + + sut.process( + MessageUpdate( + updateId = 1, + data = PrivateContentMessageImpl( + messageId = 1, + user = CommonUser(id = ChatId(2), "Test"), + chat = chat, + content = TextContent( + text = "/start quizoji" + ), + date = DateTime.now(), + editDate = null, + forwardInfo = null, + replyTo = null, + replyMarkup = null, + senderBot = null, + paymentInfo = null, + ) + ) + ) + + coVerify(exactly = 1) { + dialogStateDAO.save( + WaitingForQuestion(chatId = 1, userId = 2) + ) + } + coVerify(exactly = 1) { + bot.sendMessage( + chat = chat, + text = "Let's create a Quizoji! First, send me the question." + ) + } + + clearAllMocks() + } +} diff --git a/runners/lambda/build.gradle.kts b/runners/lambda/build.gradle.kts index 8767733b..2b47fe12 100644 --- a/runners/lambda/build.gradle.kts +++ b/runners/lambda/build.gradle.kts @@ -13,4 +13,6 @@ dependencies { implementation(project.projects.jep) implementation(project.projects.youtube.dynamodb) implementation(project.projects.kotlin.dynamodb) + implementation(project.projects.dialogs.dynamodb) + implementation(project.projects.quizoji.dynamodb) } diff --git a/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/config/database.kt b/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/config/database.kt index 87df79fe..1ac80e68 100644 --- a/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/config/database.kt +++ b/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/config/database.kt @@ -1,12 +1,16 @@ package by.jprof.telegram.bot.runners.lambda.config +import by.jprof.telegram.bot.dialogs.dao.DialogStateDAO import by.jprof.telegram.bot.kotlin.dao.KotlinMentionsDAO +import by.jprof.telegram.bot.quizoji.dao.QuizojiDAO import by.jprof.telegram.bot.votes.dao.VotesDAO import by.jprof.telegram.bot.youtube.dao.YouTubeChannelsWhitelistDAO import org.koin.core.qualifier.named import org.koin.dsl.module import software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient +import by.jprof.telegram.bot.dialogs.dynamodb.dao.DialogStateDAO as DynamoDBDialogStateDAO import by.jprof.telegram.bot.kotlin.dynamodb.dao.KotlinMentionsDAO as DynamoDBKotlinMentionsDAO +import by.jprof.telegram.bot.quizoji.dynamodb.dao.QuizojiDAO as DynamoDBQuizojiDAO import by.jprof.telegram.bot.votes.dynamodb.dao.VotesDAO as DynamoDBVotesDAO import by.jprof.telegram.bot.youtube.dynamodb.dao.YouTubeChannelsWhitelistDAO as DynamoDBYouTubeChannelsWhitelistDAO @@ -35,4 +39,18 @@ val databaseModule = module { get(named(TABLE_KOTLIN_MENTIONS)) ) } + + single { + DynamoDBDialogStateDAO( + get(), + get(named(TABLE_DIALOG_STATES)) + ) + } + + single { + DynamoDBQuizojiDAO( + get(), + get(named(TABLE_QUIZOJIS)) + ) + } } diff --git a/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/config/env.kt b/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/config/env.kt index 2e731bb9..4236a0e7 100644 --- a/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/config/env.kt +++ b/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/config/env.kt @@ -8,6 +8,8 @@ const val TOKEN_YOUTUBE_API = "TOKEN_YOUTUBE_API" const val TABLE_VOTES = "TABLE_VOTES" const val TABLE_YOUTUBE_CHANNELS_WHITELIST = "TABLE_YOUTUBE_CHANNELS_WHITELIST" const val TABLE_KOTLIN_MENTIONS = "TABLE_KOTLIN_MENTIONS" +const val TABLE_DIALOG_STATES = "TABLE_DIALOG_STATES" +const val TABLE_QUIZOJIS = "TABLE_QUIZOJIS" val envModule = module { listOf( @@ -16,6 +18,8 @@ val envModule = module { TABLE_VOTES, TABLE_YOUTUBE_CHANNELS_WHITELIST, TABLE_KOTLIN_MENTIONS, + TABLE_DIALOG_STATES, + TABLE_QUIZOJIS, ).forEach { variable -> single(named(variable)) { System.getenv(variable)!! diff --git a/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/config/pipeline.kt b/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/config/pipeline.kt index 2f839064..06c342ba 100644 --- a/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/config/pipeline.kt +++ b/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/config/pipeline.kt @@ -5,6 +5,7 @@ import by.jprof.telegram.bot.core.UpdateProcessor import by.jprof.telegram.bot.jep.JEPUpdateProcessor import by.jprof.telegram.bot.jep.JsoupJEPSummary import by.jprof.telegram.bot.kotlin.KotlinMentionsUpdateProcessor +import by.jprof.telegram.bot.quizoji.* import by.jprof.telegram.bot.youtube.YouTubeUpdateProcessor import org.koin.core.qualifier.named import org.koin.dsl.module @@ -37,4 +38,49 @@ val pipelineModule = module { bot = get(), ) } + + single(named("QuizojiInlineQueryUpdateProcessor")) { + QuizojiInlineQueryUpdateProcessor( + quizojiDAO = get(), + votesDAO = get(), + bot = get(), + ) + } + + single(named("QuizojiStartCommandUpdateProcessor")) { + QuizojiStartCommandUpdateProcessor( + dialogStateDAO = get(), + bot = get(), + ) + } + + single(named("QuizojiQuestionUpdateProcessor")) { + QuizojiQuestionUpdateProcessor( + dialogStateDAO = get(), + bot = get(), + ) + } + + single(named("QuizojiOptionUpdateProcessor")) { + QuizojiOptionUpdateProcessor( + dialogStateDAO = get(), + bot = get(), + ) + } + + single(named("QuizojiDoneCommandUpdateProcessor")) { + QuizojiDoneCommandUpdateProcessor( + dialogStateDAO = get(), + quizojiDAO = get(), + votesDAO = get(), + bot = get(), + ) + } + + single(named("QuizojiVoteUpdateProcessor")) { + QuizojiVoteUpdateProcessor( + votesDAO = get(), + bot = get(), + ) + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4d1ec3d6..ace6981e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,14 +5,19 @@ rootProject.name = "jprof_by_bot" include(":utils:dynamodb") include(":utils:aws-junit5") +include(":utils:tgbotapi-serialization") include(":votes") include(":votes:dynamodb") include(":votes:tgbotapi-extensions") include(":votes:voting-processor") +include(":dialogs") +include(":dialogs:dynamodb") include(":core") include(":jep") include(":youtube") include(":youtube:dynamodb") include(":kotlin") include(":kotlin:dynamodb") +include(":quizoji") +include(":quizoji:dynamodb") include(":runners:lambda") diff --git a/utils/tgbotapi-serialization/build.gradle.kts b/utils/tgbotapi-serialization/build.gradle.kts new file mode 100644 index 00000000..bd4ee064 --- /dev/null +++ b/utils/tgbotapi-serialization/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + kotlin("jvm") + kotlin("plugin.serialization") +} + +dependencies { + api(libs.tgbotapi.core) + implementation(libs.kotlinx.serialization.core) + + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.params) + testRuntimeOnly(libs.junit.jupiter.engine) +} diff --git a/utils/tgbotapi-serialization/src/main/kotlin/by/jprof/telegram/bot/utils/tgbotapi_serialization/TgBotAPI.kt b/utils/tgbotapi-serialization/src/main/kotlin/by/jprof/telegram/bot/utils/tgbotapi_serialization/TgBotAPI.kt new file mode 100644 index 00000000..34c96291 --- /dev/null +++ b/utils/tgbotapi-serialization/src/main/kotlin/by/jprof/telegram/bot/utils/tgbotapi_serialization/TgBotAPI.kt @@ -0,0 +1,36 @@ +package by.jprof.telegram.bot.utils.tgbotapi_serialization + +import by.jprof.telegram.bot.utils.tgbotapi_serialization.serializers.TextContentSerializer +import dev.inmo.tgbotapi.types.message.content.TextContent +import dev.inmo.tgbotapi.types.message.content.abstracts.MessageContent +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic + +object TgBotAPI { + val module = SerializersModule { + polymorphic(MessageContent::class) { + // subclass(AnimationContent::class, AnimationContentSerializer()) + // subclass(AudioContent::class, AudioContentSerializer()) + // subclass(AudioMediaGroupContent::class, AudioMediaGroupContentSerializer()) + // subclass(ContactContent::class, ContactContentSerializer()) + // subclass(DiceContent::class, DiceContentSerializer()) + // subclass(DocumentContent::class, DocumentContentSerializer()) + // subclass(DocumentMediaGroupContent::class, DocumentMediaGroupContentSerializer()) + // subclass(GameContent::class, GameContentSerializer()) + // subclass(InvoiceContent::class, InvoiceContentSerializer()) + // subclass(LocationContent::class, LocationContentSerializer()) + // subclass(MediaCollectionContent::class, MediaCollectionContentSerializer()) + // subclass(MediaContent::class, MediaContentSerializer()) + // subclass(MediaGroupContent::class, MediaGroupContentSerializer()) + // subclass(PhotoContent::class, PhotoContentSerializer()) + // subclass(PollContent::class, PollContentSerializer()) + // subclass(StickerContent::class, StickerContentSerializer()) + subclass(TextContent::class, TextContentSerializer()) + // subclass(VenueContent::class, VenueContentSerializer()) + // subclass(VideoContent::class, VideoContentSerializer()) + // subclass(VideoNoteContent::class, VideoNoteContentSerializer()) + // subclass(VisualMediaGroupContent::class, VisualMediaGroupContentSerializer()) + // subclass(VoiceContent::class, VoiceContentSerializer()) + } + } +} diff --git a/utils/tgbotapi-serialization/src/main/kotlin/by/jprof/telegram/bot/utils/tgbotapi_serialization/serializers/TextContentSerializer.kt b/utils/tgbotapi-serialization/src/main/kotlin/by/jprof/telegram/bot/utils/tgbotapi_serialization/serializers/TextContentSerializer.kt new file mode 100644 index 00000000..4148da68 --- /dev/null +++ b/utils/tgbotapi-serialization/src/main/kotlin/by/jprof/telegram/bot/utils/tgbotapi_serialization/serializers/TextContentSerializer.kt @@ -0,0 +1,30 @@ +package by.jprof.telegram.bot.utils.tgbotapi_serialization.serializers + +import by.jprof.telegram.bot.utils.tgbotapi_serialization.surrogates.TextContentSurrogate +import dev.inmo.tgbotapi.types.message.content.TextContent +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +internal class TextContentSerializer : KSerializer { + override val descriptor: SerialDescriptor = TextContentSurrogate.serializer().descriptor + + override fun serialize(encoder: Encoder, value: TextContent) { + val surrogate = TextContentSurrogate( + text = value.text, + textSources = value.textSources, + ) + + encoder.encodeSerializableValue(TextContentSurrogate.serializer(), surrogate) + } + + override fun deserialize(decoder: Decoder): TextContent { + val surrogate = decoder.decodeSerializableValue(TextContentSurrogate.serializer()) + + return TextContent( + text = surrogate.text, + textSources = surrogate.textSources, + ) + } +} diff --git a/utils/tgbotapi-serialization/src/main/kotlin/by/jprof/telegram/bot/utils/tgbotapi_serialization/surrogates/TextContentSurrogate.kt b/utils/tgbotapi-serialization/src/main/kotlin/by/jprof/telegram/bot/utils/tgbotapi_serialization/surrogates/TextContentSurrogate.kt new file mode 100644 index 00000000..f40e3a5e --- /dev/null +++ b/utils/tgbotapi-serialization/src/main/kotlin/by/jprof/telegram/bot/utils/tgbotapi_serialization/surrogates/TextContentSurrogate.kt @@ -0,0 +1,12 @@ +package by.jprof.telegram.bot.utils.tgbotapi_serialization.surrogates + +import dev.inmo.tgbotapi.types.MessageEntity.textsources.TextSourcesList +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("TextContent") +internal data class TextContentSurrogate( + val text: String, + val textSources: TextSourcesList = emptyList() +) diff --git a/utils/tgbotapi-serialization/src/test/kotlin/by/jprof/telegram/bot/utils/tgbotapi_serialization/TextContentSerializerTest.kt b/utils/tgbotapi-serialization/src/test/kotlin/by/jprof/telegram/bot/utils/tgbotapi_serialization/TextContentSerializerTest.kt new file mode 100644 index 00000000..640f06d8 --- /dev/null +++ b/utils/tgbotapi-serialization/src/test/kotlin/by/jprof/telegram/bot/utils/tgbotapi_serialization/TextContentSerializerTest.kt @@ -0,0 +1,40 @@ +package by.jprof.telegram.bot.utils.tgbotapi_serialization + +import dev.inmo.tgbotapi.types.MessageEntity.textsources.BotCommandTextSource +import dev.inmo.tgbotapi.types.MessageEntity.textsources.RegularTextSource +import dev.inmo.tgbotapi.types.message.content.TextContent +import dev.inmo.tgbotapi.types.message.content.abstracts.MessageContent +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +internal class TextContentSerializerTest { + private val json = Json { serializersModule = TgBotAPI.module } + private val textContent = TextContent( + text = "/start up", + textSources = listOf( + BotCommandTextSource(source = "/start"), + RegularTextSource(source = " up") + ) + ) + private val serialized = + "{\"type\":\"TextContent\",\"text\":\"/start up\",\"textSources\":[{\"type\":\"bot_command\",\"value\":{\"source\":\"/start\"}},{\"type\":\"regular\",\"value\":{\"source\":\" up\"}}]}" + + @Test + fun serialize() { + Assertions.assertEquals( + serialized, + json.encodeToString(textContent) + ) + } + + @Test + fun deserialize() { + Assertions.assertEquals( + textContent, + json.decodeFromString(serialized) + ) + } +} diff --git a/votes/tgbotapi-extensions/src/main/kotlin/by/jprof/telegram/bot/votes/tgbotapi_extensions/VotesExtensions.kt b/votes/tgbotapi-extensions/src/main/kotlin/by/jprof/telegram/bot/votes/tgbotapi_extensions/VotesExtensions.kt index e2c15b61..ad855fb7 100644 --- a/votes/tgbotapi-extensions/src/main/kotlin/by/jprof/telegram/bot/votes/tgbotapi_extensions/VotesExtensions.kt +++ b/votes/tgbotapi-extensions/src/main/kotlin/by/jprof/telegram/bot/votes/tgbotapi_extensions/VotesExtensions.kt @@ -16,3 +16,19 @@ fun Votes.toInlineKeyboardMarkup() = InlineKeyboardMarkup( } ) ) + +fun Votes.toInlineKeyboardMarkup(size: Int) = InlineKeyboardMarkup( + if (this.options.isEmpty()) { + listOf(emptyList()) + } else { + this + .options + .map { + CallbackDataInlineKeyboardButton( + text = "${this.count(it)} $it", + callbackData = "${this.id}:$it" + ) + } + .chunked(size) + } +) diff --git a/votes/tgbotapi-extensions/src/test/kotlin/by/jprof/telegram/bot/votes/tgbotapi_extensions/VotesExtensionsTest.kt b/votes/tgbotapi-extensions/src/test/kotlin/by/jprof/telegram/bot/votes/tgbotapi_extensions/VotesExtensionsTest.kt index e63803b7..e2c0a390 100644 --- a/votes/tgbotapi-extensions/src/test/kotlin/by/jprof/telegram/bot/votes/tgbotapi_extensions/VotesExtensionsTest.kt +++ b/votes/tgbotapi-extensions/src/test/kotlin/by/jprof/telegram/bot/votes/tgbotapi_extensions/VotesExtensionsTest.kt @@ -19,6 +19,12 @@ internal class VotesExtensionsTest { Assertions.assertEquals(keyboard, votes.toInlineKeyboardMarkup()) } + @ParameterizedTest + @MethodSource + fun toInlineKeyboardMarkupChunked(votes: Votes, size: Int, keyboard: InlineKeyboardMarkup) { + Assertions.assertEquals(keyboard, votes.toInlineKeyboardMarkup(size)) + } + private fun toInlineKeyboardMarkup(): Stream = sequence { yield( Arguments.of( @@ -72,4 +78,80 @@ internal class VotesExtensionsTest { ) ) }.asStream() + + private fun toInlineKeyboardMarkupChunked(): Stream = sequence { + yield( + Arguments.of( + Votes("test"), + 8, + InlineKeyboardMarkup( + listOf(emptyList()) + ) + ) + ) + yield( + Arguments.of( + Votes("test", listOf("1")), + 8, + InlineKeyboardMarkup( + listOf( + listOf( + CallbackDataInlineKeyboardButton("0 1", "test:1") + ) + ) + ) + ) + ) + yield( + Arguments.of( + Votes("test", listOf("1", "2")), + 8, + InlineKeyboardMarkup( + listOf( + listOf( + CallbackDataInlineKeyboardButton("0 1", "test:1"), + CallbackDataInlineKeyboardButton("0 2", "test:2"), + ) + ) + ) + ) + ) + yield( + Arguments.of( + Votes("test", listOf("1", "2")), + 1, + InlineKeyboardMarkup( + listOf( + listOf( + CallbackDataInlineKeyboardButton("0 1", "test:1"), + ), + listOf( + CallbackDataInlineKeyboardButton("0 2", "test:2"), + ) + ) + ) + ) + ) + yield( + Arguments.of( + Votes( + "test", + listOf("1", "2", "3"), + mapOf("user1" to "1", "user2" to "2", "user3" to "UNEXISTING_OPTION") + ), + 2, + InlineKeyboardMarkup( + listOf( + listOf( + CallbackDataInlineKeyboardButton("1 1", "test:1"), + CallbackDataInlineKeyboardButton("1 2", "test:2"), + ), + listOf( + CallbackDataInlineKeyboardButton("0 3", "test:3"), + ) + ) + ) + ) + ) + }.asStream() } diff --git a/votes/voting-processor/src/main/kotlin/by/jprof/telegram/bot/votes/voting_processor/VotingProcessor.kt b/votes/voting-processor/src/main/kotlin/by/jprof/telegram/bot/votes/voting_processor/VotingProcessor.kt index e0adce5c..9b4446de 100644 --- a/votes/voting-processor/src/main/kotlin/by/jprof/telegram/bot/votes/voting_processor/VotingProcessor.kt +++ b/votes/voting-processor/src/main/kotlin/by/jprof/telegram/bot/votes/voting_processor/VotingProcessor.kt @@ -7,6 +7,8 @@ import dev.inmo.tgbotapi.bot.RequestsExecutor import dev.inmo.tgbotapi.extensions.api.answers.answerCallbackQuery import dev.inmo.tgbotapi.extensions.api.edit.ReplyMarkup.editMessageReplyMarkup import dev.inmo.tgbotapi.types.CallbackQuery.CallbackQuery +import dev.inmo.tgbotapi.types.CallbackQuery.DataCallbackQuery +import dev.inmo.tgbotapi.types.CallbackQuery.InlineMessageIdDataCallbackQuery import dev.inmo.tgbotapi.types.CallbackQuery.MessageDataCallbackQuery import org.apache.logging.log4j.LogManager @@ -21,9 +23,9 @@ abstract class VotingProcessor( } suspend fun processCallbackQuery(callbackQuery: CallbackQuery) { - logger.debug("Processing callback query: {}", callbackQuery) + logger.debug("[{}] Processing callback query: {}", prefix, callbackQuery) - (callbackQuery as? MessageDataCallbackQuery)?.data?.takeIf { it.startsWith(prefix) }?.let { data -> + (callbackQuery as? DataCallbackQuery)?.data?.takeIf { it.startsWith(prefix) }?.let { data -> val (votesId, vote) = data.split(":").takeIf { it.size == 2 } ?: return val fromUserId = callbackQuery.user.id.chatId.toString() @@ -34,12 +36,28 @@ abstract class VotingProcessor( votesDAO.save(updatedVotes) bot.answerCallbackQuery(callbackQuery) - bot.editMessageReplyMarkup( - message = callbackQuery.message, - replyMarkup = updatedVotes.toInlineKeyboardMarkup() - ) + + when (callbackQuery) { + is MessageDataCallbackQuery -> { + bot.editMessageReplyMarkup( + message = callbackQuery.message, + replyMarkup = votesToInlineKeyboardMarkup(updatedVotes) + ) + } + is InlineMessageIdDataCallbackQuery -> { + bot.editMessageReplyMarkup( + inlineMessageId = callbackQuery.inlineMessageId, + replyMarkup = votesToInlineKeyboardMarkup(updatedVotes) + ) + } + else -> { + logger.error("Unknown callback query type: {}", callbackQuery::class.simpleName) + } + } } } + open fun votesToInlineKeyboardMarkup(votes: Votes) = votes.toInlineKeyboardMarkup() + protected fun String.toVotesID() = "$prefix-$this" } diff --git a/votes/voting-processor/src/test/kotlin/by/jprof/telegram/bot/votes/voting_processor/VotingProcessorTest.kt b/votes/voting-processor/src/test/kotlin/by/jprof/telegram/bot/votes/voting_processor/VotingProcessorTest.kt index 1752c0c9..da91f429 100644 --- a/votes/voting-processor/src/test/kotlin/by/jprof/telegram/bot/votes/voting_processor/VotingProcessorTest.kt +++ b/votes/voting-processor/src/test/kotlin/by/jprof/telegram/bot/votes/voting_processor/VotingProcessorTest.kt @@ -3,20 +3,24 @@ package by.jprof.telegram.bot.votes.voting_processor import by.jprof.telegram.bot.votes.dao.VotesDAO import by.jprof.telegram.bot.votes.model.Votes import by.jprof.telegram.bot.votes.tgbotapi_extensions.toInlineKeyboardMarkup +import com.soywiz.klock.DateTime import dev.inmo.tgbotapi.bot.RequestsExecutor import dev.inmo.tgbotapi.extensions.api.answers.answerCallbackQuery import dev.inmo.tgbotapi.extensions.api.edit.ReplyMarkup.editMessageReplyMarkup +import dev.inmo.tgbotapi.types.CallbackQuery.InlineMessageIdDataCallbackQuery import dev.inmo.tgbotapi.types.CallbackQuery.MessageDataCallbackQuery import dev.inmo.tgbotapi.types.CallbackQuery.MessageGameShortNameCallbackQuery import dev.inmo.tgbotapi.types.ChatId -import dev.inmo.tgbotapi.types.message.abstracts.ContentMessage +import dev.inmo.tgbotapi.types.CommonUser +import dev.inmo.tgbotapi.types.UserId +import dev.inmo.tgbotapi.types.chat.GroupChatImpl +import dev.inmo.tgbotapi.types.message.CommonGroupContentMessageImpl import dev.inmo.tgbotapi.types.message.content.TextContent import io.mockk.* import io.mockk.impl.annotations.MockK import io.mockk.junit5.MockKExtension import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -44,23 +48,29 @@ internal class VotingProcessorTest { } @Test - @Disabled - fun processCallbackQuery() = runBlocking { - val callbackQuery = mockk { - every { id } returns "" - every { user } returns mockk { - every { id } returns ChatId(42L) - } - every { chatInstance } returns "" - every { message.hint(ContentMessage::class) } returns mockk> { - every { messageId } returns 1L - every { content.hint(TextContent::class) } returns TextContent("") - every { chat } returns mockk { - every { id } returns ChatId(1L) - } - } - every { data } returns "TEST-42:+" - } + fun processMessageDataCallbackQuery() = runBlocking { + val message = CommonGroupContentMessageImpl( + chat = GroupChatImpl( + id = ChatId(1L), + title = "Test" + ), + messageId = 1L, + user = CommonUser(UserId(2L), "Test 2"), + date = DateTime.now(), + forwardInfo = null, + editDate = null, + replyTo = null, + replyMarkup = null, + content = TextContent(""), + senderBot = null, + ) + val callbackQuery = MessageDataCallbackQuery( + id = "", + user = CommonUser(UserId(1L), "Test 1"), + chatInstance = "", + message = message, + data = "TEST-42:+" + ) coEvery { votesDAO.get("TEST-42") } returns Votes("TEST-42", listOf("+", "-")) coEvery { votesDAO.save(any()) } just runs @@ -68,12 +78,12 @@ internal class VotingProcessorTest { sut.processCallbackQuery(callbackQuery) - coVerify(exactly = 1) { votesDAO.save(Votes("TEST-42", listOf("+", "-"), mapOf("42" to "+"))) } + coVerify(exactly = 1) { votesDAO.save(Votes("TEST-42", listOf("+", "-"), mapOf("1" to "+"))) } coVerify(exactly = 1) { bot.answerCallbackQuery(callbackQuery) } coVerify(exactly = 1) { bot.editMessageReplyMarkup( - message = callbackQuery.message, - replyMarkup = Votes("TEST-42", listOf("+", "-"), mapOf("42" to "+")).toInlineKeyboardMarkup() + message = message, + replyMarkup = Votes("TEST-42", listOf("+", "-"), mapOf("1" to "+")).toInlineKeyboardMarkup(), ) } @@ -81,13 +91,80 @@ internal class VotingProcessorTest { } @Test - @Disabled - fun processCallbackQueryForNewVotes() { - TODO() + fun processCallbackQueryForNewVotes() = runBlocking { + val message = CommonGroupContentMessageImpl( + chat = GroupChatImpl( + id = ChatId(1L), + title = "Test" + ), + messageId = 1L, + user = CommonUser(UserId(2L), "Test 2"), + date = DateTime.now(), + forwardInfo = null, + editDate = null, + replyTo = null, + replyMarkup = null, + content = TextContent(""), + senderBot = null, + ) + val callbackQuery = MessageDataCallbackQuery( + id = "", + user = CommonUser(UserId(1L), "Test 1"), + chatInstance = "", + message = message, + data = "TEST-42:+" + ) + + coEvery { votesDAO.get("TEST-42") } returns null + every { votesConstructor.invoke("TEST-42") } returns Votes("TEST-42", listOf("+", "-", "=")) + coEvery { votesDAO.save(any()) } just runs + coEvery { bot.answerCallbackQuery(callbackQuery) } returns true + + sut.processCallbackQuery(callbackQuery) + + coVerify(exactly = 1) { votesDAO.save(Votes("TEST-42", listOf("+", "-", "="), mapOf("1" to "+"))) } + coVerify(exactly = 1) { bot.answerCallbackQuery(callbackQuery) } + coVerify(exactly = 1) { + bot.editMessageReplyMarkup( + message = message, + replyMarkup = Votes("TEST-42", listOf("+", "-", "="), mapOf("1" to "+")).toInlineKeyboardMarkup(), + ) + } + + clearAllMocks() + } + + @Test + fun processInlineMessageIdDataCallbackQuery() = runBlocking { + val callbackQuery = InlineMessageIdDataCallbackQuery( + id = "", + user = CommonUser(UserId(1L), "Test 1"), + chatInstance = "", + inlineMessageId = "300", + data = "TEST-42:+" + ) + + coEvery { votesDAO.get("TEST-42") } returns null + every { votesConstructor.invoke("TEST-42") } returns Votes("TEST-42", listOf("+", "-")) + coEvery { votesDAO.save(any()) } just runs + coEvery { bot.answerCallbackQuery(callbackQuery) } returns true + + sut.processCallbackQuery(callbackQuery) + + coVerify(exactly = 1) { votesDAO.save(Votes("TEST-42", listOf("+", "-"), mapOf("1" to "+"))) } + coVerify(exactly = 1) { bot.answerCallbackQuery(callbackQuery) } + coVerify(exactly = 1) { + bot.editMessageReplyMarkup( + inlineMessageId = "300", + replyMarkup = Votes("TEST-42", listOf("+", "-"), mapOf("1" to "+")).toInlineKeyboardMarkup(), + ) + } + + clearAllMocks() } @Test - fun processNonMessageDataCallbackQuery() = runBlocking { + fun processUnknownCallbackQuery() = runBlocking { sut.processCallbackQuery( MessageGameShortNameCallbackQuery( id = "test", diff --git a/youtube/src/main/kotlin/by/jprof/telegram/bot/youtube/YouTubeUpdateProcessor.kt b/youtube/src/main/kotlin/by/jprof/telegram/bot/youtube/YouTubeUpdateProcessor.kt index d8ee00ab..8026737c 100644 --- a/youtube/src/main/kotlin/by/jprof/telegram/bot/youtube/YouTubeUpdateProcessor.kt +++ b/youtube/src/main/kotlin/by/jprof/telegram/bot/youtube/YouTubeUpdateProcessor.kt @@ -7,7 +7,6 @@ import by.jprof.telegram.bot.votes.tgbotapi_extensions.toInlineKeyboardMarkup import by.jprof.telegram.bot.votes.voting_processor.VotingProcessor import by.jprof.telegram.bot.youtube.dao.YouTubeChannelsWhitelistDAO import com.google.api.services.youtube.YouTube -import dev.inmo.tgbotapi.CommonAbstracts.justTextSources import dev.inmo.tgbotapi.bot.RequestsExecutor import dev.inmo.tgbotapi.extensions.api.send.reply import dev.inmo.tgbotapi.extensions.utils.formatting.boldMarkdownV2 @@ -69,8 +68,7 @@ class YouTubeUpdateProcessor( (message as? ContentMessage<*>)?.let { contentMessage -> (contentMessage.content as? TextContent)?.let { content -> content - .textEntities - .justTextSources() + .textSources .mapNotNull { (it as? URLTextSource)?.source ?: (it as? TextLinkTextSource)?.url }