From 8d86ca5fae9bd9ea24d2676c7cf997b5e9e3a057 Mon Sep 17 00:00:00 2001 From: Pavel Kirilin Date: Thu, 16 Jul 2026 14:48:01 +0200 Subject: [PATCH] Fixed currency converter. Signed-off-by: Pavel Kirilin --- .gitea/workflows/release.yaml | 4 +- Cargo.toml | 2 +- src/args.rs | 3 + src/bot/handlers/basic/currency_converter.rs | 117 +++++++++++++++---- src/bot/main.rs | 9 +- 5 files changed, 110 insertions(+), 25 deletions(-) diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index e7164c2..38d81db 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx @@ -38,7 +38,7 @@ jobs: needs: docker_build steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup helm uses: azure/setup-helm@v4.3.0 - name: Deploy diff --git a/Cargo.toml b/Cargo.toml index 81887f9..e30b5da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ lazy_static = "1.4.0" log = "0.4.17" rand = "0.8.5" rayon = "1.6.1" -regex = "1.7.1" +regex = { version = "1.7.1", features = ["unicode"] } reqwest = { version = "0.11.14", features = ["gzip", "json", "tokio-rustls"] } serde = { version = "1.0.152", features = ["derive"] } serde_json = "1.0.93" diff --git a/src/args.rs b/src/args.rs index 15ab61b..26cb605 100644 --- a/src/args.rs +++ b/src/args.rs @@ -92,6 +92,9 @@ pub struct BotConfig { value_delimiter = ',' )] pub currency_excluded_chats: Vec, + + #[arg(name = "dev-mode", long, default_value_t = false, env = "BOT_DEV_MODE")] + pub dev_mode: bool, } #[derive(Clone, Parser, Debug)] diff --git a/src/bot/handlers/basic/currency_converter.rs b/src/bot/handlers/basic/currency_converter.rs index 1b54c2f..aefbfcd 100644 --- a/src/bot/handlers/basic/currency_converter.rs +++ b/src/bot/handlers/basic/currency_converter.rs @@ -1,8 +1,17 @@ use std::{ collections::{HashMap, HashSet}, - time::Duration, + sync::LazyLock, + time::{Duration, Instant}, }; +use tokio::sync::Mutex; + +use serde_json::Value; + +static RATE_CACHE: LazyLock> = + LazyLock::new(|| Mutex::new((Value::Null, Instant::now()))); +static CACHE_TTL: Duration = Duration::from_secs(3600); + use grammers_client::{Client, InputMessage, Update}; use regex::Regex; @@ -39,6 +48,7 @@ lazy_static::lazy_static! { // USD ("бакс", "USD"), ("доллар", "USD"), + ("$", "USD"), // EUR ("евро", "EUR"), // JPY @@ -53,8 +63,7 @@ lazy_static::lazy_static! { // CNY ("юан", "CNY"), // UAH - ("гривна", "UAH"), - ("гривны", "UAH"), + ("гривн", "UAH"), ("гривен", "UAH"), ("грiвен", "UAH"), // KZT @@ -80,7 +89,7 @@ lazy_static::lazy_static! { .chain(SUPPORTED_CURS.iter().copied()) .collect::>() .join("|"); - Regex::new(format!(r"\s*(?P\d+([\.,]\d+)?)\s+(?P{a})").as_str()).unwrap() + Regex::new(format!(r"\s*(?P\d+([\.,]\d+)?)\s+((?P{a})[\p{{L}}]*)(\s+(в|to|in)\s+(?P{a})[\p{{L}}]*)?").as_str()).unwrap() }; } @@ -100,6 +109,31 @@ impl CurrencyConverter { .build()?; Ok(Self { client }) } + + async fn fetch_rates(&self) -> anyhow::Result { + let cached = { + let lock = RATE_CACHE.lock().await; + if lock.1.elapsed() < CACHE_TTL && lock.0 != Value::Null { + Some(lock.0.clone()) + } else { + None + } + }; + if let Some(resp) = cached { + return Ok(resp); + } + let resp = self + .client + .get("https://www.cbr-xml-daily.ru/daily_json.js") + .send() + .await? + .error_for_status()? + .json::() + .await?; + let mut lock = RATE_CACHE.lock().await; + *lock = (resp.clone(), Instant::now()); + Ok(resp) + } } /// This filter check if the message matches regex for currencies. @@ -118,14 +152,14 @@ impl Handler for CurrencyConverter { let Some(message) = get_message(update) else { return Ok(()); }; - let response = self - .client - .get("https://www.cbr-xml-daily.ru/daily_json.js") - .send() - .await? - .error_for_status()? - .json::() - .await?; + + let response = match self.fetch_rates().await { + Ok(r) => r, + Err(e) => { + log::warn!("Failed to fetch rates: {e}"); + return Ok(()); + } + }; let Some(valutes) = response .get("Valute") @@ -139,6 +173,7 @@ impl Handler for CurrencyConverter { let mut mapped = HashSet::new(); for capture in CUR_REGEX.captures_iter(message.text()) { + log::warn!("Captured: {:?}", capture); // We parse supplied value from message let Some(num_value) = capture .name("cur_value") @@ -158,13 +193,6 @@ impl Handler for CurrencyConverter { else { continue; }; - let fingerprint = format!("{num_value:.5} {cur_name}"); - // Check if we already processed this value. - if mapped.contains(&fingerprint) { - continue; - } - // Add a value to not calculate it again. - mapped.insert(fingerprint); // Now we want to know current nominal for this value. let Some(nominal) = valutes // We search for it using cur_name. @@ -176,6 +204,7 @@ impl Handler for CurrencyConverter { else { continue; }; + // Now we want to know multiplier. let Some(multiplier) = valutes .get(cur_name) @@ -186,9 +215,55 @@ impl Handler for CurrencyConverter { continue; }; + let mut converted_value = multiplier * num_value / nominal; + + let mut target_name = String::from("RUB"); + let target_cur_name = capture.name("target").map(|mtch| mtch.as_str()); + let target_cur_name = target_cur_name + .and_then(|val| CONVERTION_ALIASES.get(val).copied()) + .or(target_cur_name); + + if let Some(target_cur) = target_cur_name { + if target_cur == cur_name { + continue; + } + if let Some(info) = valutes.get(target_cur) { + let Some(target_nominal) = info + .get("Nominal") + .map(ToString::to_string) + .and_then(|v| v.as_str().parse::().ok()) + else { + continue; + }; + let Some(target_multiplier) = info + .get("Value") + .map(ToString::to_string) + .and_then(|v| v.as_str().parse::().ok()) + else { + continue; + }; + let Some(target_code) = info + .get("CharCode") + .and_then(|v| v.as_str()) + .map(ToString::to_string) + else { + continue; + }; + target_name = target_code; + converted_value = converted_value * target_nominal / target_multiplier; + } + } + + let fingerprint = format!("{num_value:.5} {cur_name} {target_name}"); + // Check if we already processed this value. + if mapped.contains(&fingerprint) { + continue; + } + // Add a value to not calculate it again. + mapped.insert(fingerprint); + calucates.push(format!( - "
{num_value} {cur_name} = {value:.2} RUB

", - value = multiplier * num_value / nominal, + "
{num_value} {cur_name} = {converted_value:.2} {target_name}

", )); } diff --git a/src/bot/main.rs b/src/bot/main.rs index 4ed422c..f4280af 100644 --- a/src/bot/main.rs +++ b/src/bot/main.rs @@ -100,7 +100,7 @@ async fn handle_with_log(handler: Box, client: Client, update_data: #[allow(clippy::too_many_lines)] async fn run(args: BotConfig, client: Client) -> anyhow::Result<()> { let me = client.get_me().await?; - let handlers: Vec = vec![ + let mut handlers: Vec = vec![ // Printing help. FilteredHandler::new(Help) .add_filter(ExcludedChatsFilter(args.excluded_chats.clone())) @@ -194,6 +194,13 @@ async fn run(args: BotConfig, client: Client) -> anyhow::Result<()> { .add_filter(TextFilter(&[".t"], TextMatchMethod::StartsWith)), ]; + // If dev mode is enabled only respond to messages from user himself. + if args.dev_mode { + for hander in handlers.iter_mut() { + *hander = hander.clone().add_filter(OnlyFromId(me.id())); + } + } + let mut errors_count = 0; loop {