use std::{ collections::{HashMap, HashSet}, 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; use crate::{ bot::{filters::base::Filter, handlers::Handler}, utils::messages::get_message, }; lazy_static::lazy_static! { static ref SUPPORTED_CURS: Vec<&'static str> = vec![ "GBP", "HUF", "USD", "EUR", "CNY", "NOK", "UAH", "SEK", "CHF", "KRW", "JPY", "KZT", "PLN", "TRY", "AMD", "RSD", "THB" ]; static ref CONVERTION_ALIASES: HashMap<&'static str, &'static str> = HashMap::from( [ // GBP ("фунт", "GBP"), // USD ("бакс", "USD"), ("доллар", "USD"), ("$", "USD"), // EUR ("евро", "EUR"), // JPY ("иен", "JPY"), ("йен", "JPY"), // KRW ("вон", "KRW"), // CHF ("франк", "CHF"), // SEK ("крон", "CHF"), // CNY ("юан", "CNY"), // UAH ("гривн", "UAH"), ("гривен", "UAH"), ("грiвен", "UAH"), // KZT ("тенге", "KZT"), ("тэнге", "KZT"), // PLN ("злот", "PLN"), // TRY ("лир", "TRY"), // AMD ("драм", "AMD"), // RSD ("динар", "RSD"), // THB ("бат", "THB"), ] ); static ref CUR_REGEX: Regex = { #[allow(suspicious_double_ref_op)] let a = CONVERTION_ALIASES.keys() .copied() .chain(SUPPORTED_CURS.iter().copied()) .collect::>() .join("|"); Regex::new(format!(r"\s*(?P\d+([\.,]\d+)?)\s+((?P{a})[\p{{L}}]*)(\s+(в|to|in)\s+(?P{a})[\p{{L}}]*)?").as_str()).unwrap() }; } #[derive(Clone)] pub struct CurrencyTextFilter; #[derive(Clone)] pub struct CurrencyConverter { client: reqwest::Client, } impl CurrencyConverter { pub fn new() -> anyhow::Result { let client = reqwest::ClientBuilder::new() .timeout(Duration::from_secs(2)) .gzip(true) .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. impl Filter for CurrencyTextFilter { fn filter(&self, update: &Update) -> anyhow::Result { let Some(message) = get_message(update) else { return Ok(false); }; Ok(CUR_REGEX.is_match(message.text())) } } #[async_trait::async_trait] impl Handler for CurrencyConverter { async fn react(&self, _: &Client, update: &Update) -> anyhow::Result<()> { let Some(message) = get_message(update) else { return Ok(()); }; 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") .and_then(serde_json::Value::as_object) else { log::warn!("Can't get valutes fom response."); return Ok(()); }; let mut calucates = Vec::new(); 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") // Convert match to string. .map(|mtch| mtch.as_str()) // Parse it. .and_then(|val| val.parse::().ok()) else { continue; }; let cur_name = capture.name("cur_name").map(|mtch| mtch.as_str()); let Some(cur_name) = cur_name // We check if the value is an alias. .and_then(|val| CONVERTION_ALIASES.get(val).copied()) // get previous value if not. .or(cur_name) else { continue; }; // Now we want to know current nominal for this value. let Some(nominal) = valutes // We search for it using cur_name. .get(cur_name) .and_then(|info| info.get("Nominal")) .map(ToString::to_string) .and_then(|value| value.as_str().parse::().ok()) // If the name cannot be found, we continue. else { continue; }; // Now we want to know multiplier. let Some(multiplier) = valutes .get(cur_name) .and_then(|info| info.get("Value")) .map(ToString::to_string) .and_then(|value| value.as_str().parse::().ok()) else { 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} = {converted_value:.2} {target_name}

", )); } if !calucates.is_empty() { let mut bot_response = String::from("Полагаясь на текущий курс валют могу сказать следующее:\n\n"); for calc in calucates { bot_response.push_str(calc.as_str()); } message // We send it as silent, so we can filter this message later. .reply(InputMessage::html(bot_response).silent(true)) .await?; } Ok(()) } }