diff --git a/currencies/README.adoc b/currencies/README.adoc new file mode 100644 index 00000000..2675615f --- /dev/null +++ b/currencies/README.adoc @@ -0,0 +1,3 @@ += Currencies + +This feature extracts monetary amounts from the messages and converts them to well-known currencies, like EUR or USD. diff --git a/currencies/build.gradle.kts b/currencies/build.gradle.kts new file mode 100644 index 00000000..1e0248e6 --- /dev/null +++ b/currencies/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + kotlin("jvm") + kotlin("plugin.serialization") +} + +dependencies { + implementation(platform(libs.ktor.bom)) + + api(project.projects.core) + api(libs.tgbotapi.core) + implementation(libs.tgbotapi.extensions.api) + implementation(libs.log4j.api) + implementation(libs.ktor.client.apache) + implementation(libs.ktor.client.serialization) + + 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/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/CurrenciesUpdateProcessor.kt b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/CurrenciesUpdateProcessor.kt new file mode 100644 index 00000000..0922d6e6 --- /dev/null +++ b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/CurrenciesUpdateProcessor.kt @@ -0,0 +1,57 @@ +package by.jprof.telegram.bot.currencies + +import by.jprof.telegram.bot.core.UpdateProcessor +import by.jprof.telegram.bot.currencies.parser.MonetaryAmountParsingPipeline +import by.jprof.telegram.bot.currencies.rates.ExchangeRateClient +import dev.inmo.tgbotapi.bot.RequestsExecutor +import dev.inmo.tgbotapi.extensions.api.send.reply +import dev.inmo.tgbotapi.types.ParseMode.MarkdownV2ParseMode +import dev.inmo.tgbotapi.types.message.abstracts.ContentMessage +import dev.inmo.tgbotapi.types.message.content.TextContent +import dev.inmo.tgbotapi.types.update.CallbackQueryUpdate +import dev.inmo.tgbotapi.types.update.MessageUpdate +import dev.inmo.tgbotapi.types.update.abstracts.Update +import dev.inmo.tgbotapi.utils.extensions.escapeMarkdownV2Common +import org.apache.logging.log4j.LogManager + +class CurrenciesUpdateProcessor( + private val monetaryAmountParsingPipeline: MonetaryAmountParsingPipeline, + private val exchangeRateClient: ExchangeRateClient, + private val targetCurrencies: List = listOf("EUR", "USD"), + private val bot: RequestsExecutor, +) : UpdateProcessor { + companion object { + private val logger = LogManager.getLogger(CurrenciesUpdateProcessor::class.java)!! + } + + override suspend fun process(update: Update) { + val update = update as? MessageUpdate ?: return + val message = update.data as? ContentMessage<*> ?: return + val content = message.content as? TextContent ?: return + + val monetaryAmounts = monetaryAmountParsingPipeline.parse(content.text) + + logger.debug("Parsed monetary amounts: {}", monetaryAmounts) + + val conversions = targetCurrencies.flatMap { to -> + monetaryAmounts.mapNotNull { + exchangeRateClient.convert(it.amount, it.currency, to) + } + }.groupBy { it.query.amount to it.query.from } + + val reply = conversions + .map { (query, conversions) -> + val (amount, from) = query + val results = conversions + .sortedBy { it.query.to } + .joinToString(", ") { "%.2f %s".format(it.result, it.query.to).escapeMarkdownV2Common() } + + "**${"%.0f %s".format(amount, from).escapeMarkdownV2Common()}**: %s".format(results) + } + .joinToString("\n") + + if (reply.isNotEmpty()) { + bot.reply(to = message, text = reply, parseMode = MarkdownV2ParseMode) + } + } +} diff --git a/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/model/MonetaryAmount.kt b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/model/MonetaryAmount.kt new file mode 100644 index 00000000..0ae97dc6 --- /dev/null +++ b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/model/MonetaryAmount.kt @@ -0,0 +1,6 @@ +package by.jprof.telegram.bot.currencies.model + +data class MonetaryAmount( + val amount: Double, + val currency: String, +) diff --git a/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/CAD.kt b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/CAD.kt new file mode 100644 index 00000000..006f2b99 --- /dev/null +++ b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/CAD.kt @@ -0,0 +1,8 @@ +package by.jprof.telegram.bot.currencies.parser + + +class CAD : MonetaryAmountParserBase() { + override val currency: String = "CAD" + + override val currencyRegex: String = "(CAD|CA$|Can$|C$)" +} diff --git a/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/GBP.kt b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/GBP.kt new file mode 100644 index 00000000..449a827d --- /dev/null +++ b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/GBP.kt @@ -0,0 +1,7 @@ +package by.jprof.telegram.bot.currencies.parser + +class GBP : MonetaryAmountParserBase() { + override val currency: String = "GBP" + + override val currencyRegex: String = "(GBP|£|POUND|ФУНТ|ФУНТОВ)" +} diff --git a/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/GEL.kt b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/GEL.kt new file mode 100644 index 00000000..05e12caa --- /dev/null +++ b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/GEL.kt @@ -0,0 +1,8 @@ +package by.jprof.telegram.bot.currencies.parser + +class GEL : MonetaryAmountParserBase() { + override val currency: String = "GEL" + + override val currencyRegex: String = "(GEL|₾|ლ|ЛАРИ)" +} + diff --git a/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/HRK.kt b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/HRK.kt new file mode 100644 index 00000000..e8097733 --- /dev/null +++ b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/HRK.kt @@ -0,0 +1,7 @@ +package by.jprof.telegram.bot.currencies.parser + +class HRK : MonetaryAmountParserBase() { + override val currency: String = "HRK" + + override val currencyRegex: String = "(HRK|KN|KUNA|KUN|КУН)" +} diff --git a/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/MonetaryAmountParser.kt b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/MonetaryAmountParser.kt new file mode 100644 index 00000000..83ef6fd0 --- /dev/null +++ b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/MonetaryAmountParser.kt @@ -0,0 +1,5 @@ +package by.jprof.telegram.bot.currencies.parser + +import by.jprof.telegram.bot.currencies.model.MonetaryAmount + +typealias MonetaryAmountParser = (String) -> Set diff --git a/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/MonetaryAmountParserBase.kt b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/MonetaryAmountParserBase.kt new file mode 100644 index 00000000..b4414e3f --- /dev/null +++ b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/MonetaryAmountParserBase.kt @@ -0,0 +1,52 @@ +package by.jprof.telegram.bot.currencies.parser + +import by.jprof.telegram.bot.currencies.model.MonetaryAmount + +abstract class MonetaryAmountParserBase : MonetaryAmountParser { + companion object { + private const val RANGE = "(-|–|—|―|‒|\\.\\.|\\.\\.\\.|…)" + } + + override fun invoke(message: String): Set { + return ( + listOf(r1, r2) + .flatMap { it.findAll(message) } + .mapNotNull { + val rawAmount = it.groups["amount"]?.value ?: return@mapNotNull null + val isK = it.groups["K"] != null + val isM = it.groups["M"] != null + val amount = rawAmount.toDouble().run { if (isK) this * 1000 else if (isM) this * 1000000 else this } + + MonetaryAmount(amount, currency) + } + listOf(r3, r4) + .flatMap { it.findAll(message) } + .mapNotNull { + val rawAmount1 = it.groups["amount1"]?.value ?: return@mapNotNull null + val rawAmount2 = it.groups["amount2"]?.value ?: return@mapNotNull null + val isK = it.groups["K1"] != null || it.groups["K2"] != null + val isM = it.groups["M1"] != null || it.groups["M2"] != null + + val amount1 = rawAmount1.toDouble().run { if (isK) this * 1000 else if (isM) this * 1000000 else this } + val amount2 = rawAmount2.toDouble().run { if (isK) this * 1000 else if (isM) this * 1000000 else this } + + listOf(MonetaryAmount(amount1, currency), MonetaryAmount(amount2, currency)) + }.flatten() + ).toSet() + } + + protected abstract val currency: String + + protected abstract val currencyRegex: String + + private fun amount(index: Int? = null): String = "(?(\\d+\\.\\d+)|(\\d+))( *(?[КK])|(?[МM]))?" + + private val r1 get() = "${amount()} *$currencyRegex".toRegex() + + private val r2 get() = "$currencyRegex *${amount()}".toRegex() + + private val r3 get() = "${amount(1)} *$RANGE *${amount(2)} *$currencyRegex".toRegex() + + private val r4 get() = "$currencyRegex *${amount(1)} *$RANGE *${amount(2)}".toRegex() + + private fun String.toRegex() = this.toRegex(RegexOption.IGNORE_CASE) +} diff --git a/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/MonetaryAmountParsingPipeline.kt b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/MonetaryAmountParsingPipeline.kt new file mode 100644 index 00000000..ccab4022 --- /dev/null +++ b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/MonetaryAmountParsingPipeline.kt @@ -0,0 +1,21 @@ +package by.jprof.telegram.bot.currencies.parser + +import by.jprof.telegram.bot.currencies.model.MonetaryAmount +import org.apache.logging.log4j.LogManager + +class MonetaryAmountParsingPipeline( + private val parsers: List +) { + companion object { + private val logger = LogManager.getLogger(MonetaryAmountParsingPipeline::class.java)!! + } + + fun parse(message: String): List = parsers.flatMap { + try { + it(message) + } catch (e: Exception) { + logger.error("Exception in ${it::class.simpleName}", e) + emptyList() + } + } +} diff --git a/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/PLN.kt b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/PLN.kt new file mode 100644 index 00000000..f131da59 --- /dev/null +++ b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/PLN.kt @@ -0,0 +1,7 @@ +package by.jprof.telegram.bot.currencies.parser + +class PLN : MonetaryAmountParserBase() { + override val currency: String = "PLN" + + override val currencyRegex: String = "(PLN|ZL|ZŁ|ЗЛ)" +} diff --git a/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/UZS.kt b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/UZS.kt new file mode 100644 index 00000000..9a603b75 --- /dev/null +++ b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/parser/UZS.kt @@ -0,0 +1,7 @@ +package by.jprof.telegram.bot.currencies.parser + +class UZS : MonetaryAmountParserBase() { + override val currency: String = "UZS" + + override val currencyRegex: String = "(UZS|SO'M|SOM|СУМ|СЎМ)" +} diff --git a/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/rates/Conversion.kt b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/rates/Conversion.kt new file mode 100644 index 00000000..9dd68894 --- /dev/null +++ b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/rates/Conversion.kt @@ -0,0 +1,16 @@ +package by.jprof.telegram.bot.currencies.rates + +import kotlinx.serialization.Serializable + +@Serializable +data class Conversion( + val query: Query, + val result: Double, +) + +@Serializable +data class Query( + val amount: Double, + val from: String, + val to: String, +) diff --git a/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/rates/ExchangeRateClient.kt b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/rates/ExchangeRateClient.kt new file mode 100644 index 00000000..aacd673a --- /dev/null +++ b/currencies/src/main/kotlin/by/jprof/telegram/bot/currencies/rates/ExchangeRateClient.kt @@ -0,0 +1,45 @@ +package by.jprof.telegram.bot.currencies.rates + +import by.jprof.telegram.bot.currencies.CurrenciesUpdateProcessor +import io.ktor.client.HttpClient +import io.ktor.client.engine.apache.Apache +import io.ktor.client.features.json.JsonFeature +import io.ktor.client.features.json.serializer.KotlinxSerializer +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.url +import kotlinx.serialization.json.Json +import org.apache.logging.log4j.LogManager +import java.io.Closeable + +class ExchangeRateClient : Closeable { + companion object { + private val logger = LogManager.getLogger(ExchangeRateClient::class.java)!! + } + + private val client = HttpClient(Apache) { + install(JsonFeature) { + serializer = KotlinxSerializer( + Json { + ignoreUnknownKeys = true + } + ) + } + } + + suspend fun convert(amount: Double, from: String, to: String): Conversion? = try { + client.get { + url("https://api.exchangerate.host/convert") + parameter("amount", amount) + parameter("from", from) + parameter("to", to) + } + } catch (e: Exception) { + logger.error("Conversion exception", e) + null + } + + override fun close() { + client.close() + } +} diff --git a/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/CADTest.kt b/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/CADTest.kt new file mode 100644 index 00000000..98a6d99b --- /dev/null +++ b/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/CADTest.kt @@ -0,0 +1,51 @@ +package by.jprof.telegram.bot.currencies.parser + +import by.jprof.telegram.bot.currencies.model.MonetaryAmount +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream +import kotlin.streams.asStream + +internal class CADTest { + private val sut = CAD() + + @ParameterizedTest + @MethodSource + fun parse(message: String, expected: Set) { + assertEquals(expected, sut(message)) + } + + companion object { + @JvmStatic + fun parse(): Stream = sequence { + yield(Arguments.of("", emptySet())) + yield(Arguments.of("test", emptySet())) + yield(Arguments.of("10000CAD", setOf(MonetaryAmount(10000.0, "CAD")))) + yield(Arguments.of("10000EUR", emptySet())) + yield(Arguments.of("10000 CAD", setOf(MonetaryAmount(10000.0, "CAD")))) + yield(Arguments.of("10000 EUR", emptySet())) + yield(Arguments.of("10.000 CAD", setOf(MonetaryAmount(10.0, "CAD")))) + yield(Arguments.of("10.000 EUR", emptySet())) + yield(Arguments.of("10K CAD", setOf(MonetaryAmount(10000.0, "CAD")))) + yield(Arguments.of("10K EUR", emptySet())) + yield(Arguments.of("10 K CAD", setOf(MonetaryAmount(10000.0, "CAD")))) + yield(Arguments.of("10 K CAD", setOf(MonetaryAmount(10000.0, "CAD")))) + yield(Arguments.of("CAD 10000", setOf(MonetaryAmount(10000.0, "CAD")))) + yield(Arguments.of("EUR 10000", emptySet())) + yield(Arguments.of("CAD 10K", setOf(MonetaryAmount(10000.0, "CAD")))) + yield(Arguments.of("EUR 10K", emptySet())) + yield(Arguments.of("CAD 10K", setOf(MonetaryAmount(10000.0, "CAD")))) + yield(Arguments.of("CAD 123456.789", setOf(MonetaryAmount(123456.789, "CAD")))) + // yield(Arguments.of("10000 CA$", setOf(MonetaryAmount(10000.0, "CAD")))) + // yield(Arguments.of("10000 Can$", setOf(MonetaryAmount(10000.0, "CAD")))) + // yield(Arguments.of("10000 C$", setOf(MonetaryAmount(10000.0, "CAD")))) + yield(Arguments.of("5000-10000 CAD", setOf(MonetaryAmount(5000.0, "CAD"), MonetaryAmount(10000.0, "CAD")))) + yield(Arguments.of("5000 — 10000 CAD", setOf(MonetaryAmount(5000.0, "CAD"), MonetaryAmount(10000.0, "CAD")))) + yield(Arguments.of("5-10К CAD", setOf(MonetaryAmount(5000.0, "CAD"), MonetaryAmount(10000.0, "CAD")))) + yield(Arguments.of("5-10 К CAD", setOf(MonetaryAmount(5000.0, "CAD"), MonetaryAmount(10000.0, "CAD")))) + yield(Arguments.of("CAD 5000 — 10000", setOf(MonetaryAmount(5000.0, "CAD"), MonetaryAmount(10000.0, "CAD")))) + }.asStream() + } +} diff --git a/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/GBPTest.kt b/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/GBPTest.kt new file mode 100644 index 00000000..636fe83a --- /dev/null +++ b/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/GBPTest.kt @@ -0,0 +1,51 @@ +package by.jprof.telegram.bot.currencies.parser + +import by.jprof.telegram.bot.currencies.model.MonetaryAmount +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream +import kotlin.streams.asStream + +internal class GBPTest { + private val sut = GBP() + + @ParameterizedTest + @MethodSource + fun parse(message: String, expected: Set) { + assertEquals(expected, sut(message)) + } + + companion object { + @JvmStatic + fun parse(): Stream = sequence { + yield(Arguments.of("", emptySet())) + yield(Arguments.of("test", emptySet())) + yield(Arguments.of("10000GBP", setOf(MonetaryAmount(10000.0, "GBP")))) + yield(Arguments.of("10000EUR", emptySet())) + yield(Arguments.of("10000 GBP", setOf(MonetaryAmount(10000.0, "GBP")))) + yield(Arguments.of("10000 EUR", emptySet())) + yield(Arguments.of("10.000 GBP", setOf(MonetaryAmount(10.0, "GBP")))) + yield(Arguments.of("10.000 EUR", emptySet())) + yield(Arguments.of("10K GBP", setOf(MonetaryAmount(10000.0, "GBP")))) + yield(Arguments.of("10K EUR", emptySet())) + yield(Arguments.of("10 K GBP", setOf(MonetaryAmount(10000.0, "GBP")))) + yield(Arguments.of("10 K GBP", setOf(MonetaryAmount(10000.0, "GBP")))) + yield(Arguments.of("GBP 10000", setOf(MonetaryAmount(10000.0, "GBP")))) + yield(Arguments.of("EUR 10000", emptySet())) + yield(Arguments.of("GBP 10K", setOf(MonetaryAmount(10000.0, "GBP")))) + yield(Arguments.of("EUR 10K", emptySet())) + yield(Arguments.of("GBP 10K", setOf(MonetaryAmount(10000.0, "GBP")))) + yield(Arguments.of("GBP 123456.789", setOf(MonetaryAmount(123456.789, "GBP")))) + yield(Arguments.of("10000 £", setOf(MonetaryAmount(10000.0, "GBP")))) + yield(Arguments.of("10000 фунтов", setOf(MonetaryAmount(10000.0, "GBP")))) + yield(Arguments.of("10000 pounds", setOf(MonetaryAmount(10000.0, "GBP")))) + yield(Arguments.of("5000-10000 GBP", setOf(MonetaryAmount(5000.0, "GBP"), MonetaryAmount(10000.0, "GBP")))) + yield(Arguments.of("5000 — 10000 GBP", setOf(MonetaryAmount(5000.0, "GBP"), MonetaryAmount(10000.0, "GBP")))) + yield(Arguments.of("5-10К GBP", setOf(MonetaryAmount(5000.0, "GBP"), MonetaryAmount(10000.0, "GBP")))) + yield(Arguments.of("5-10 К GBP", setOf(MonetaryAmount(5000.0, "GBP"), MonetaryAmount(10000.0, "GBP")))) + yield(Arguments.of("GBP 5000 — 10000", setOf(MonetaryAmount(5000.0, "GBP"), MonetaryAmount(10000.0, "GBP")))) + }.asStream() + } +} diff --git a/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/GELTest.kt b/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/GELTest.kt new file mode 100644 index 00000000..b9780b97 --- /dev/null +++ b/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/GELTest.kt @@ -0,0 +1,51 @@ +package by.jprof.telegram.bot.currencies.parser + +import by.jprof.telegram.bot.currencies.model.MonetaryAmount +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream +import kotlin.streams.asStream + +internal class GELTest { + private val sut = GEL() + + @ParameterizedTest + @MethodSource + fun parse(message: String, expected: Set) { + assertEquals(expected, sut(message)) + } + + companion object { + @JvmStatic + fun parse(): Stream = sequence { + yield(Arguments.of("", emptySet())) + yield(Arguments.of("test", emptySet())) + yield(Arguments.of("10000GEL", setOf(MonetaryAmount(10000.0, "GEL")))) + yield(Arguments.of("10000EUR", emptySet())) + yield(Arguments.of("10000 GEL", setOf(MonetaryAmount(10000.0, "GEL")))) + yield(Arguments.of("10000 EUR", emptySet())) + yield(Arguments.of("10.000 GEL", setOf(MonetaryAmount(10.0, "GEL")))) + yield(Arguments.of("10.000 EUR", emptySet())) + yield(Arguments.of("10K GEL", setOf(MonetaryAmount(10000.0, "GEL")))) + yield(Arguments.of("10K EUR", emptySet())) + yield(Arguments.of("10 K GEL", setOf(MonetaryAmount(10000.0, "GEL")))) + yield(Arguments.of("10 K GEL", setOf(MonetaryAmount(10000.0, "GEL")))) + yield(Arguments.of("GEL 10000", setOf(MonetaryAmount(10000.0, "GEL")))) + yield(Arguments.of("EUR 10000", emptySet())) + yield(Arguments.of("GEL 10K", setOf(MonetaryAmount(10000.0, "GEL")))) + yield(Arguments.of("EUR 10K", emptySet())) + yield(Arguments.of("GEL 10K", setOf(MonetaryAmount(10000.0, "GEL")))) + yield(Arguments.of("GEL 123456.789", setOf(MonetaryAmount(123456.789, "GEL")))) + yield(Arguments.of("10000 ₾", setOf(MonetaryAmount(10000.0, "GEL")))) + yield(Arguments.of("10000 ლ", setOf(MonetaryAmount(10000.0, "GEL")))) + yield(Arguments.of("10000 лари", setOf(MonetaryAmount(10000.0, "GEL")))) + yield(Arguments.of("5000-10000 GEL", setOf(MonetaryAmount(5000.0, "GEL"), MonetaryAmount(10000.0, "GEL")))) + yield(Arguments.of("5000 — 10000 GEL", setOf(MonetaryAmount(5000.0, "GEL"), MonetaryAmount(10000.0, "GEL")))) + yield(Arguments.of("5-10К GEL", setOf(MonetaryAmount(5000.0, "GEL"), MonetaryAmount(10000.0, "GEL")))) + yield(Arguments.of("5-10 К GEL", setOf(MonetaryAmount(5000.0, "GEL"), MonetaryAmount(10000.0, "GEL")))) + yield(Arguments.of("GEL 5000 — 10000", setOf(MonetaryAmount(5000.0, "GEL"), MonetaryAmount(10000.0, "GEL")))) + }.asStream() + } +} diff --git a/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/HRKTest.kt b/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/HRKTest.kt new file mode 100644 index 00000000..73b87161 --- /dev/null +++ b/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/HRKTest.kt @@ -0,0 +1,51 @@ +package by.jprof.telegram.bot.currencies.parser + +import by.jprof.telegram.bot.currencies.model.MonetaryAmount +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream +import kotlin.streams.asStream + +internal class HRKTest { + private val sut = HRK() + + @ParameterizedTest + @MethodSource + fun parse(message: String, expected: Set) { + assertEquals(expected, sut(message)) + } + + companion object { + @JvmStatic + fun parse(): Stream = sequence { + yield(Arguments.of("", emptySet())) + yield(Arguments.of("test", emptySet())) + yield(Arguments.of("10000HRK", setOf(MonetaryAmount(10000.0, "HRK")))) + yield(Arguments.of("10000EUR", emptySet())) + yield(Arguments.of("10000 HRK", setOf(MonetaryAmount(10000.0, "HRK")))) + yield(Arguments.of("10000 EUR", emptySet())) + yield(Arguments.of("10.000 HRK", setOf(MonetaryAmount(10.0, "HRK")))) + yield(Arguments.of("10.000 EUR", emptySet())) + yield(Arguments.of("10K HRK", setOf(MonetaryAmount(10000.0, "HRK")))) + yield(Arguments.of("10K EUR", emptySet())) + yield(Arguments.of("10 K HRK", setOf(MonetaryAmount(10000.0, "HRK")))) + yield(Arguments.of("10 K HRK", setOf(MonetaryAmount(10000.0, "HRK")))) + yield(Arguments.of("HRK 10000", setOf(MonetaryAmount(10000.0, "HRK")))) + yield(Arguments.of("EUR 10000", emptySet())) + yield(Arguments.of("HRK 10K", setOf(MonetaryAmount(10000.0, "HRK")))) + yield(Arguments.of("EUR 10K", emptySet())) + yield(Arguments.of("HRK 10K", setOf(MonetaryAmount(10000.0, "HRK")))) + yield(Arguments.of("HRK 123456.789", setOf(MonetaryAmount(123456.789, "HRK")))) + yield(Arguments.of("10000 Kn", setOf(MonetaryAmount(10000.0, "HRK")))) + yield(Arguments.of("10000 куна", setOf(MonetaryAmount(10000.0, "HRK")))) + yield(Arguments.of("10000 кун", setOf(MonetaryAmount(10000.0, "HRK")))) + yield(Arguments.of("5000-10000 HRK", setOf(MonetaryAmount(5000.0, "HRK"), MonetaryAmount(10000.0, "HRK")))) + yield(Arguments.of("5000 — 10000 HRK", setOf(MonetaryAmount(5000.0, "HRK"), MonetaryAmount(10000.0, "HRK")))) + yield(Arguments.of("5-10К HRK", setOf(MonetaryAmount(5000.0, "HRK"), MonetaryAmount(10000.0, "HRK")))) + yield(Arguments.of("5-10 К HRK", setOf(MonetaryAmount(5000.0, "HRK"), MonetaryAmount(10000.0, "HRK")))) + yield(Arguments.of("HRK 5000 — 10000", setOf(MonetaryAmount(5000.0, "HRK"), MonetaryAmount(10000.0, "HRK")))) + }.asStream() + } +} diff --git a/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/MonetaryAmountParsingPipelineTest.kt b/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/MonetaryAmountParsingPipelineTest.kt new file mode 100644 index 00000000..9c52669c --- /dev/null +++ b/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/MonetaryAmountParsingPipelineTest.kt @@ -0,0 +1,38 @@ +package by.jprof.telegram.bot.currencies.parser + +import by.jprof.telegram.bot.currencies.model.MonetaryAmount +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test + +import org.junit.jupiter.api.Assertions.* + +internal class MonetaryAmountParsingPipelineTest { + @Test + fun parse() { + val message = "test" + val monetaryAmountParser1 = mockk { + every { this@mockk.invoke(message) } returns setOf(MonetaryAmount(42.0, "EUR")) + } + val monetaryAmountParser2 = mockk { + every { this@mockk.invoke(message) } returns emptySet() + } + val monetaryAmountParser3 = mockk { + every { this@mockk.invoke(message) } throws IllegalArgumentException() + } + val sut = MonetaryAmountParsingPipeline( + parsers = listOf(monetaryAmountParser1, monetaryAmountParser2, monetaryAmountParser3) + ) + + assertEquals( + listOf(MonetaryAmount(42.0, "EUR")), + sut.parse(message) + ) + + verify(exactly = 1) { monetaryAmountParser1.invoke(message) } + verify(exactly = 1) { monetaryAmountParser2.invoke(message) } + verify(exactly = 1) { monetaryAmountParser3.invoke(message) } + } +} diff --git a/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/PLNTest.kt b/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/PLNTest.kt new file mode 100644 index 00000000..d23c532a --- /dev/null +++ b/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/PLNTest.kt @@ -0,0 +1,54 @@ +package by.jprof.telegram.bot.currencies.parser + +import by.jprof.telegram.bot.currencies.model.MonetaryAmount +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream +import kotlin.streams.asStream + +internal class PLNTest { + private val sut = PLN() + + @ParameterizedTest + @MethodSource + fun parse(message: String, expected: Set) { + assertEquals(expected, sut(message)) + } + + companion object { + @JvmStatic + fun parse(): Stream = sequence { + yield(Arguments.of("", emptySet())) + yield(Arguments.of("test", emptySet())) + yield(Arguments.of("10000PLN", setOf(MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("10000EUR", emptySet())) + yield(Arguments.of("10000 PLN", setOf(MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("10000 EUR", emptySet())) + yield(Arguments.of("10.000 PLN", setOf(MonetaryAmount(10.0, "PLN")))) + yield(Arguments.of("10.000 EUR", emptySet())) + yield(Arguments.of("10K PLN", setOf(MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("10K EUR", emptySet())) + yield(Arguments.of("10 K PLN", setOf(MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("10 K PLN", setOf(MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("PLN 10000", setOf(MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("EUR 10000", emptySet())) + yield(Arguments.of("PLN 10K", setOf(MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("EUR 10K", emptySet())) + yield(Arguments.of("PLN 10K", setOf(MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("PLN 123456.789", setOf(MonetaryAmount(123456.789, "PLN")))) + yield(Arguments.of("10000 zl", setOf(MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("10000 zł", setOf(MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("10000 ZŁ", setOf(MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("10000 злотых", setOf(MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("10000 зл", setOf(MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("1 злотый", setOf(MonetaryAmount(1.0, "PLN")))) + yield(Arguments.of("5000-10000 PLN", setOf(MonetaryAmount(5000.0, "PLN"), MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("5000 — 10000 PLN", setOf(MonetaryAmount(5000.0, "PLN"), MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("5-10К PLN", setOf(MonetaryAmount(5000.0, "PLN"), MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("5-10 К PLN", setOf(MonetaryAmount(5000.0, "PLN"), MonetaryAmount(10000.0, "PLN")))) + yield(Arguments.of("PLN 5000 — 10000", setOf(MonetaryAmount(5000.0, "PLN"), MonetaryAmount(10000.0, "PLN")))) + }.asStream() + } +} diff --git a/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/UZSTest.kt b/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/UZSTest.kt new file mode 100644 index 00000000..02ea5ea8 --- /dev/null +++ b/currencies/src/test/kotlin/by/jprof/telegram/bot/currencies/parser/UZSTest.kt @@ -0,0 +1,51 @@ +package by.jprof.telegram.bot.currencies.parser + +import by.jprof.telegram.bot.currencies.model.MonetaryAmount +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream +import kotlin.streams.asStream + +internal class UZSTest { + private val sut = UZS() + + @ParameterizedTest + @MethodSource + fun parse(message: String, expected: Set) { + assertEquals(expected, sut(message)) + } + + companion object { + @JvmStatic + fun parse(): Stream = sequence { + yield(Arguments.of("", emptySet())) + yield(Arguments.of("test", emptySet())) + yield(Arguments.of("10000UZS", setOf(MonetaryAmount(10000.0, "UZS")))) + yield(Arguments.of("10000EUR", emptySet())) + yield(Arguments.of("10000 UZS", setOf(MonetaryAmount(10000.0, "UZS")))) + yield(Arguments.of("10000 EUR", emptySet())) + yield(Arguments.of("10.000 UZS", setOf(MonetaryAmount(10.0, "UZS")))) + yield(Arguments.of("10.000 EUR", emptySet())) + yield(Arguments.of("10K UZS", setOf(MonetaryAmount(10000.0, "UZS")))) + yield(Arguments.of("10K EUR", emptySet())) + yield(Arguments.of("10 K UZS", setOf(MonetaryAmount(10000.0, "UZS")))) + yield(Arguments.of("10 K UZS", setOf(MonetaryAmount(10000.0, "UZS")))) + yield(Arguments.of("UZS 10000", setOf(MonetaryAmount(10000.0, "UZS")))) + yield(Arguments.of("EUR 10000", emptySet())) + yield(Arguments.of("UZS 10K", setOf(MonetaryAmount(10000.0, "UZS")))) + yield(Arguments.of("EUR 10K", emptySet())) + yield(Arguments.of("UZS 10K", setOf(MonetaryAmount(10000.0, "UZS")))) + yield(Arguments.of("UZS 123456.789", setOf(MonetaryAmount(123456.789, "UZS")))) + yield(Arguments.of("10000 So'm", setOf(MonetaryAmount(10000.0, "UZS")))) + yield(Arguments.of("10000 сўм", setOf(MonetaryAmount(10000.0, "UZS")))) + yield(Arguments.of("10000 сум", setOf(MonetaryAmount(10000.0, "UZS")))) + yield(Arguments.of("5000-10000 UZS", setOf(MonetaryAmount(5000.0, "UZS"), MonetaryAmount(10000.0, "UZS")))) + yield(Arguments.of("5000 — 10000 UZS", setOf(MonetaryAmount(5000.0, "UZS"), MonetaryAmount(10000.0, "UZS")))) + yield(Arguments.of("5-10К UZS", setOf(MonetaryAmount(5000.0, "UZS"), MonetaryAmount(10000.0, "UZS")))) + yield(Arguments.of("5-10 К UZS", setOf(MonetaryAmount(5000.0, "UZS"), MonetaryAmount(10000.0, "UZS")))) + yield(Arguments.of("UZS 5000 — 10000", setOf(MonetaryAmount(5000.0, "UZS"), MonetaryAmount(10000.0, "UZS")))) + }.asStream() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4dddf7ac..2ec6182a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,8 @@ awssdk = "2.17.56" koin = "3.1.2" +ktor = "1.6.8" + kotlinx-serialization = "1.3.0" jackson = "2.13.0" @@ -36,6 +38,10 @@ sfn = { group = "software.amazon.awssdk", name = "sfn", version.ref = "awssdk" } koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "koin" } +ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" } +ktor-client-apache = { group = "io.ktor", name = "ktor-client-apache" } +ktor-client-serialization = { group = "io.ktor", name = "ktor-client-serialization" } + 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" } diff --git a/runners/lambda/build.gradle.kts b/runners/lambda/build.gradle.kts index 9efaddfe..f4be591b 100644 --- a/runners/lambda/build.gradle.kts +++ b/runners/lambda/build.gradle.kts @@ -19,4 +19,5 @@ dependencies { implementation(project.projects.eval) implementation(project.projects.pins.dynamodb) implementation(project.projects.pins.sfn) + implementation(project.projects.currencies) } diff --git a/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/JProf.kt b/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/JProf.kt index 464e06ae..f1304373 100644 --- a/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/JProf.kt +++ b/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/JProf.kt @@ -1,6 +1,7 @@ package by.jprof.telegram.bot.runners.lambda import by.jprof.telegram.bot.core.UpdateProcessingPipeline +import by.jprof.telegram.bot.runners.lambda.config.currenciesModule import by.jprof.telegram.bot.runners.lambda.config.databaseModule import by.jprof.telegram.bot.runners.lambda.config.envModule import by.jprof.telegram.bot.runners.lambda.config.jsonModule @@ -49,6 +50,7 @@ class JProf : RequestHandler, K youtubeModule, pipelineModule, sfnModule, + currenciesModule, ) } } diff --git a/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/config/currencies.kt b/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/config/currencies.kt new file mode 100644 index 00000000..98d4fa30 --- /dev/null +++ b/runners/lambda/src/main/kotlin/by/jprof/telegram/bot/runners/lambda/config/currencies.kt @@ -0,0 +1,32 @@ +package by.jprof.telegram.bot.runners.lambda.config + +import by.jprof.telegram.bot.currencies.parser.CAD +import by.jprof.telegram.bot.currencies.parser.GBP +import by.jprof.telegram.bot.currencies.parser.GEL +import by.jprof.telegram.bot.currencies.parser.HRK +import by.jprof.telegram.bot.currencies.parser.MonetaryAmountParser +import by.jprof.telegram.bot.currencies.parser.MonetaryAmountParsingPipeline +import by.jprof.telegram.bot.currencies.parser.PLN +import by.jprof.telegram.bot.currencies.parser.UZS +import by.jprof.telegram.bot.currencies.rates.ExchangeRateClient +import org.koin.core.qualifier.named +import org.koin.dsl.module + +val currenciesModule = module { + single(named("PLN")) { PLN() } + single(named("HRK")) { HRK() } + single(named("CAD")) { CAD() } + single(named("GEL")) { GEL() } + single(named("UZS")) { UZS() } + single(named("GBP")) { GBP() } + + single { + MonetaryAmountParsingPipeline( + parsers = getAll(), + ) + } + + single { + ExchangeRateClient() + } +} 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 338803e5..41e1603f 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 @@ -2,6 +2,8 @@ package by.jprof.telegram.bot.runners.lambda.config import by.jprof.telegram.bot.core.UpdateProcessingPipeline import by.jprof.telegram.bot.core.UpdateProcessor +import by.jprof.telegram.bot.currencies.CurrenciesUpdateProcessor +import by.jprof.telegram.bot.currencies.parser.MonetaryAmountParsingPipeline import by.jprof.telegram.bot.eval.EvalUpdateProcessor import by.jprof.telegram.bot.jep.JEPUpdateProcessor import by.jprof.telegram.bot.jep.JsoupJEPSummary @@ -113,4 +115,12 @@ val pipelineModule = module { pinDAO = get(), ) } + + single(named("CurrenciesUpdateProcessor")) { + CurrenciesUpdateProcessor( + monetaryAmountParsingPipeline = get(), + exchangeRateClient = get(), + bot = get(), + ) + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3b5fe134..9a92a6bb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,3 +29,4 @@ include(":pins:unpin") include(":pins:dynamodb") include(":pins:sfn") include(":runners:lambda") +include(":currencies")