Files
s3bot/src/bot/handlers/basic/currency_converter.rs
T
s3rius 8d86ca5fae
/ docker_build (push) Successful in 3m36s
/ deploy_helm (push) Successful in 15s
Fixed currency converter.
Signed-off-by: Pavel Kirilin <s3riussan@gmail.com>
2026-07-16 15:30:41 +02:00

285 lines
8.7 KiB
Rust

use std::{
collections::{HashMap, HashSet},
sync::LazyLock,
time::{Duration, Instant},
};
use tokio::sync::Mutex;
use serde_json::Value;
static RATE_CACHE: LazyLock<Mutex<(Value, Instant)>> =
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::<Vec<_>>()
.join("|");
Regex::new(format!(r"\s*(?P<cur_value>\d+([\.,]\d+)?)\s+((?P<cur_name>{a})[\p{{L}}]*)(\s+(в|to|in)\s+(?P<target>{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<Self> {
let client = reqwest::ClientBuilder::new()
.timeout(Duration::from_secs(2))
.gzip(true)
.build()?;
Ok(Self { client })
}
async fn fetch_rates(&self) -> anyhow::Result<Value> {
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::<serde_json::Value>()
.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<bool> {
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::<f64>().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::<f64>().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::<f64>().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::<f64>().ok())
else {
continue;
};
let Some(target_multiplier) = info
.get("Value")
.map(ToString::to_string)
.and_then(|v| v.as_str().parse::<f64>().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!(
"<pre>{num_value} {cur_name} = {converted_value:.2} {target_name}</pre><br>",
));
}
if !calucates.is_empty() {
let mut bot_response =
String::from("<b>Полагаясь на текущий курс валют могу сказать следующее:</b>\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(())
}
}