Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions currencies/README.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
= Currencies

This feature extracts monetary amounts from the messages and converts them to well-known currencies, like EUR or USD.
21 changes: 21 additions & 0 deletions currencies/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<String> = 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)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package by.jprof.telegram.bot.currencies.model

data class MonetaryAmount(
val amount: Double,
val currency: String,
)
Original file line number Diff line number Diff line change
@@ -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$)"
}
Original file line number Diff line number Diff line change
@@ -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|ФУНТ|ФУНТОВ)"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package by.jprof.telegram.bot.currencies.parser

class GEL : MonetaryAmountParserBase() {
override val currency: String = "GEL"

override val currencyRegex: String = "(GEL|₾|ლ|ЛАРИ)"
}

Original file line number Diff line number Diff line change
@@ -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|КУН)"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package by.jprof.telegram.bot.currencies.parser

import by.jprof.telegram.bot.currencies.model.MonetaryAmount

typealias MonetaryAmountParser = (String) -> Set<MonetaryAmount>
Original file line number Diff line number Diff line change
@@ -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<MonetaryAmount> {
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 = "(?<amount${index ?: ""}>(\\d+\\.\\d+)|(\\d+))( *(?<K${index ?: ""}>[КK])|(?<M${index ?: ""}>[М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)
}
Original file line number Diff line number Diff line change
@@ -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<MonetaryAmountParser>
) {
companion object {
private val logger = LogManager.getLogger(MonetaryAmountParsingPipeline::class.java)!!
}

fun parse(message: String): List<MonetaryAmount> = parsers.flatMap {
try {
it(message)
} catch (e: Exception) {
logger.error("Exception in ${it::class.simpleName}", e)
emptyList()
}
}
}
Original file line number Diff line number Diff line change
@@ -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Ł|ЗЛ)"
}
Original file line number Diff line number Diff line change
@@ -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|СУМ|СЎМ)"
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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<Conversion> {
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()
}
}
Original file line number Diff line number Diff line change
@@ -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<MonetaryAmount>) {
assertEquals(expected, sut(message))
}

companion object {
@JvmStatic
fun parse(): Stream<Arguments> = sequence<Arguments> {
yield(Arguments.of("", emptySet<MonetaryAmount>()))
yield(Arguments.of("test", emptySet<MonetaryAmount>()))
yield(Arguments.of("10000CAD", setOf(MonetaryAmount(10000.0, "CAD"))))
yield(Arguments.of("10000EUR", emptySet<MonetaryAmount>()))
yield(Arguments.of("10000 CAD", setOf(MonetaryAmount(10000.0, "CAD"))))
yield(Arguments.of("10000 EUR", emptySet<MonetaryAmount>()))
yield(Arguments.of("10.000 CAD", setOf(MonetaryAmount(10.0, "CAD"))))
yield(Arguments.of("10.000 EUR", emptySet<MonetaryAmount>()))
yield(Arguments.of("10K CAD", setOf(MonetaryAmount(10000.0, "CAD"))))
yield(Arguments.of("10K EUR", emptySet<MonetaryAmount>()))
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<MonetaryAmount>()))
yield(Arguments.of("CAD 10K", setOf(MonetaryAmount(10000.0, "CAD"))))
yield(Arguments.of("EUR 10K", emptySet<MonetaryAmount>()))
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()
}
}
Original file line number Diff line number Diff line change
@@ -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<MonetaryAmount>) {
assertEquals(expected, sut(message))
}

companion object {
@JvmStatic
fun parse(): Stream<Arguments> = sequence<Arguments> {
yield(Arguments.of("", emptySet<MonetaryAmount>()))
yield(Arguments.of("test", emptySet<MonetaryAmount>()))
yield(Arguments.of("10000GBP", setOf(MonetaryAmount(10000.0, "GBP"))))
yield(Arguments.of("10000EUR", emptySet<MonetaryAmount>()))
yield(Arguments.of("10000 GBP", setOf(MonetaryAmount(10000.0, "GBP"))))
yield(Arguments.of("10000 EUR", emptySet<MonetaryAmount>()))
yield(Arguments.of("10.000 GBP", setOf(MonetaryAmount(10.0, "GBP"))))
yield(Arguments.of("10.000 EUR", emptySet<MonetaryAmount>()))
yield(Arguments.of("10K GBP", setOf(MonetaryAmount(10000.0, "GBP"))))
yield(Arguments.of("10K EUR", emptySet<MonetaryAmount>()))
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<MonetaryAmount>()))
yield(Arguments.of("GBP 10K", setOf(MonetaryAmount(10000.0, "GBP"))))
yield(Arguments.of("EUR 10K", emptySet<MonetaryAmount>()))
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()
}
}
Loading