285 lines
8.7 KiB
Rust
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(())
|
|
}
|
|
}
|