diff --git a/Cargo.lock b/Cargo.lock index 58ca50b..1f37649 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,7 +39,7 @@ dependencies = [ [[package]] name = "am-rate-bot" -version = "0.22.0" +version = "0.23.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index a25056d..407c24a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "am-rate-bot" -version = "0.22.0" +version = "0.23.0" edition = "2021" authors = ["lucky"] diff --git a/src/bot.rs b/src/bot.rs index 92c0f6b..2978a0a 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,19 +1,25 @@ -use crate::generator::{generate_from_to_table, generate_src_table}; -use crate::sources::{Currency, Rate, RateType, Source}; -use crate::{Opts, DUNNO}; +use crate::{ + generator::{generate_conv_table, generate_src_table}, + sources::{Currency, Rate, RateType, Source}, + Opts, DUNNO, +}; use chrono::{DateTime, Utc}; -use std::collections::HashMap; -use std::env; -use std::sync::Arc; -use std::time::SystemTime; +use std::{collections::HashMap, env, str::FromStr, sync::Arc, time::SystemTime}; use strum::IntoEnumIterator; -use teloxide::adaptors::{ - throttle::{Limits, Throttle}, - DefaultParseMode, +use teloxide::{ + adaptors::{ + throttle::{Limits, Throttle}, + DefaultParseMode, + }, + prelude::*, + requests::RequesterExt, + types::{InputFile, ParseMode}, + update_listeners::webhooks, + utils::{ + command::{BotCommands, ParseError}, + html, + }, }; -use teloxide::types::{InputFile, ParseMode}; -use teloxide::update_listeners::webhooks; -use teloxide::{prelude::*, requests::RequesterExt, utils::command::BotCommands, utils::html}; use tokio::sync::Mutex; type Bot = DefaultParseMode>; @@ -26,7 +32,7 @@ const WELCOME_MSG: &str = "Meow!"; #[derive(Debug)] pub struct Storage { data: Mutex, - cache: Mutex, + cache: Mutex, } #[derive(Debug)] @@ -51,20 +57,20 @@ impl Data { } #[derive(Debug)] -pub struct CacheData { - from_to: HashMap, +pub struct Cache { + conv: HashMap, src: HashMap, } -impl CacheData { +impl Cache { const KEY_SEP: &'static str = "_"; fn clear(&mut self) { - self.from_to.clear(); + self.conv.clear(); self.src.clear(); } - fn add_from_to( + fn add_conv( &mut self, from: Currency, to: Currency, @@ -72,8 +78,8 @@ impl CacheData { is_inv: bool, value: String, ) { - self.from_to - .insert(self.format_from_to_key(from, to, rate_type, is_inv), value); + self.conv + .insert(self.format_conv_key(from, to, rate_type, is_inv), value); } fn add_src(&mut self, src: Source, rate_type: RateType, value: String) { @@ -88,15 +94,15 @@ impl CacheData { .join(Self::KEY_SEP) } - fn get_from_to( + fn get_conv( &self, from: Currency, to: Currency, rate_type: RateType, is_inv: bool, ) -> Option { - self.from_to - .get(&self.format_from_to_key(from, to, rate_type, is_inv)) + self.conv + .get(&self.format_conv_key(from, to, rate_type, is_inv)) .cloned() } @@ -104,7 +110,7 @@ impl CacheData { self.src.get(&self.format_src_key(src, rate_type)).cloned() } - fn format_from_to_key( + fn format_conv_key( &self, from: Currency, to: Currency, @@ -129,8 +135,8 @@ impl Storage { rates: HashMap::new(), updated_at: SystemTime::now(), }), - cache: Mutex::new(CacheData { - from_to: HashMap::new(), + cache: Mutex::new(Cache { + conv: HashMap::new(), src: HashMap::new(), }), }) @@ -156,7 +162,7 @@ impl Storage { cache.get_src(src, rate_type) } - pub async fn get_cache_from_to( + pub async fn get_cache_conv( &self, from: Currency, to: Currency, @@ -164,7 +170,7 @@ impl Storage { is_inv: bool, ) -> Option { let cache = self.cache.lock().await; - cache.get_from_to(from, to, rate_type, is_inv) + cache.get_conv(from, to, rate_type, is_inv) } pub async fn set_cache_src(&self, src: Source, rate_type: RateType, value: String) { @@ -172,7 +178,7 @@ impl Storage { cache.add_src(src, rate_type, value); } - pub async fn set_cache_from_to( + pub async fn set_cache_conv( &self, from: Currency, to: Currency, @@ -181,7 +187,7 @@ impl Storage { value: String, ) { let mut cache = self.cache.lock().await; - cache.add_from_to(from, to, rate_type, is_inv, value); + cache.add_conv(from, to, rate_type, is_inv, value); } pub async fn get_updated_at(&self) -> SystemTime { @@ -196,13 +202,13 @@ impl Storage { description = "These commands are supported:" )] enum Command { - #[command(description = "AMD/USD (֏ - $)")] + #[command(description = "USD ($)")] Usd, - #[command(description = "AMD/EUR (֏ - €)")] + #[command(description = "EUR (€)")] Eur, - #[command(description = "RUB/AMD (₽ - ֏)")] + #[command(description = "RUB (₽)")] Rub, - #[command(description = "AMD/GEL (֏ - ₾)")] + #[command(description = "GEL (₾)")] Gel, #[command(description = "RUB/USD (₽ - $)")] RubUsd, @@ -210,17 +216,17 @@ enum Command { RubEur, #[command(description = "USD/EUR ($ - €)")] UsdEur, - #[command(description = " ", parse_with = "split")] - FromTo { from: String, to: String }, + #[command(description = " ?", parse_with = parse_conv)] + Conv { from: Currency, to: Currency }, #[command(description = "")] Get { src: Source }, - #[command(description = "AMD/USD cash (֏ - $)")] + #[command(description = "USD cash ($)")] UsdCash, - #[command(description = "AMD/EUR cash (֏ - €)")] + #[command(description = "EUR cash (€)")] EurCash, - #[command(description = "RUB/AMD cash (₽ - ֏)")] + #[command(description = "RUB cash (₽)")] RubCash, - #[command(description = "AMD/GEL cash (֏ - ₾)")] + #[command(description = "GEL cash (₾)")] GelCash, #[command(description = "RUB/USD cash (₽ - $)")] RubUsdCash, @@ -228,18 +234,18 @@ enum Command { RubEurCash, #[command(description = "USD/EUR cash ($ - €)")] UsdEurCash, - #[command(description = " cash", parse_with = "split")] - FromToCash { from: String, to: String }, + #[command(description = " ? cash", parse_with = parse_conv)] + ConvCash { from: Currency, to: Currency }, #[command(description = " cash")] GetCash { src: Source }, - #[command(description = "list sources")] + #[command(description = "list sources", aliases = ["ls"])] List, - #[command(description = "bot status")] - Status, - #[command(description = "help")] + #[command(description = "bot info")] + Info, + #[command(description = "help", aliases = ["h", "?"])] Help, - #[command(description = "welcome")] - Start, + #[command(description = "welcome", hide)] + Start(String), } pub async fn run(db: Arc, opts: Opts) -> anyhow::Result<()> { @@ -304,18 +310,18 @@ async fn command( ) .await?; } - Command::Start => { - bot.send_message(msg.chat.id, WELCOME_MSG).await?; + Command::Start(s) => { + start_repl(s, bot, msg, db).await?; } Command::Usd | Command::UsdCash => { - from_to_repl( + conv_repl( Currency::default(), Currency::usd(), match cmd { Command::UsdCash => RateType::Cash, _ => RateType::NoCash, }, - 0, + false, bot, msg, db, @@ -323,14 +329,14 @@ async fn command( .await? } Command::Eur | Command::EurCash => { - from_to_repl( + conv_repl( Currency::default(), Currency::eur(), match cmd { Command::EurCash => RateType::Cash, _ => RateType::NoCash, }, - 0, + false, bot, msg, db, @@ -338,14 +344,14 @@ async fn command( .await? } Command::Rub | Command::RubCash => { - from_to_repl( + conv_repl( Currency::rub(), Currency::default(), match cmd { Command::RubCash => RateType::Cash, _ => RateType::NoCash, }, - 1, + true, bot, msg, db, @@ -353,14 +359,14 @@ async fn command( .await? } Command::Gel | Command::GelCash => { - from_to_repl( + conv_repl( Currency::default(), Currency::new("GEL"), match cmd { Command::GelCash => RateType::Cash, _ => RateType::NoCash, }, - 0, + false, bot, msg, db, @@ -368,14 +374,14 @@ async fn command( .await? } Command::RubUsd | Command::RubUsdCash => { - from_to_repl( + conv_repl( Currency::rub(), Currency::usd(), match cmd { Command::RubUsdCash => RateType::Cash, _ => RateType::NoCash, }, - 0, + false, bot, msg, db, @@ -383,14 +389,14 @@ async fn command( .await? } Command::RubEur | Command::RubEurCash => { - from_to_repl( + conv_repl( Currency::rub(), Currency::eur(), match cmd { Command::RubEurCash => RateType::Cash, _ => RateType::NoCash, }, - 0, + false, bot, msg, db, @@ -398,29 +404,29 @@ async fn command( .await? } Command::UsdEur | Command::UsdEurCash => { - from_to_repl( + conv_repl( Currency::usd(), Currency::eur(), match cmd { Command::UsdEurCash => RateType::Cash, _ => RateType::NoCash, }, - 0, + false, bot, msg, db, ) .await? } - Command::FromTo { ref from, ref to } | Command::FromToCash { ref from, ref to } => { - from_to_repl( - Currency::new(from), - Currency::new(to), + Command::Conv { ref from, ref to } | Command::ConvCash { ref from, ref to } => { + conv_repl( + from.clone(), + to.clone(), match cmd { - Command::FromToCash { .. } => RateType::Cash, + Command::ConvCash { .. } => RateType::Cash, _ => RateType::NoCash, }, - 0, + *to == Currency::default(), bot, msg, db, @@ -441,27 +447,89 @@ async fn command( .await?; } Command::List => { - let mut srcs = Source::iter() - .map(|v| v.to_string().to_lowercase()) - .collect::>(); - srcs.sort(); - bot.send_message(msg.chat.id, srcs.join(", ")).await?; + ls_repl(bot, msg).await?; + } + Command::Info => { + info_repl(bot, msg, db, opts).await?; } - Command::Status => { - const VERSION: &str = env!("CARGO_PKG_VERSION"); - let updated_at = db.get_updated_at().await; - let update_interval = opts.update_interval; - let lines = [ - format!("version: {VERSION}"), - format!("update_interval: {update_interval}"), - format!( - "updated_at: {}", - DateTime::::from(updated_at).format("%F %T %Z"), - ), - ]; - bot.send_message(msg.chat.id, lines.join("\n")).await?; + } + Ok(()) +} + +fn parse_conv(s: String) -> Result<(Currency, Currency), ParseError> { + if let Some((from, to)) = s.split_once('/') { + return Ok((Currency::new(from), Currency::new(to))); + } + let mut ws = s.split_whitespace(); + if let (Some(from), Some(to)) = (ws.next(), ws.next()) { + return Ok((Currency::new(from), Currency::new(to))); + } + Ok((Currency::default(), Currency::new(s))) +} + +async fn start_repl( + value: String, + bot: Bot, + msg: Message, + db: Arc, +) -> Result<(), Box> { + if value.is_empty() { + bot.send_message(msg.chat.id, WELCOME_MSG).await?; + return Ok(()); + } + let mut value = value.clone(); + let mut rate_type = RateType::NoCash; + if let Some((main, param)) = value.split_once(':') { + if let Ok(v) = RateType::from_str(param.trim()) { + rate_type = v; } + value = main.into(); + } + if let Ok(src) = Source::from_str(value.trim()) { + src_repl(src, rate_type, bot, msg, db).await?; + return Ok(()); } + let (from, to) = parse_conv(value).unwrap(); + conv_repl( + from.clone(), + to.clone(), + rate_type, + to == Currency::default(), + bot, + msg, + db, + ) + .await?; + Ok(()) +} + +async fn ls_repl(bot: Bot, msg: Message) -> Result<(), Box> { + let mut srcs = Source::iter() + .map(|v| v.to_string().to_lowercase()) + .collect::>(); + srcs.sort(); + bot.send_message(msg.chat.id, srcs.join(", ")).await?; + Ok(()) +} + +async fn info_repl( + bot: Bot, + msg: Message, + db: Arc, + opts: Opts, +) -> Result<(), Box> { + const VERSION: &str = env!("CARGO_PKG_VERSION"); + let updated_at = db.get_updated_at().await; + let update_interval = opts.update_interval; + let lines = [ + format!("version: {VERSION}"), + format!("update_interval: {update_interval}"), + format!( + "updated_at: {}", + DateTime::::from(updated_at).format("%F %T %Z"), + ), + ]; + bot.send_message(msg.chat.id, lines.join("\n")).await?; Ok(()) } @@ -487,31 +555,30 @@ async fn src_repl( Ok(()) } -async fn from_to_repl( +async fn conv_repl( mut from: Currency, mut to: Currency, rate_type: RateType, - inv: i32, + inv: bool, bot: Bot, msg: Message, db: Arc, ) -> Result<(), Box> { if from.is_empty() || to.is_empty() { - bot.send_message(msg.chat.id, DUNNO).await?; - return Ok(()); + return dunno_repl(bot, msg).await; } let rates = db.get_rates().await; for idx in 0..2 { - let is_inv = idx % 2 == inv; + let is_inv = idx % 2 == inv as usize; let cached = db - .get_cache_from_to(from.clone(), to.clone(), rate_type, is_inv) + .get_cache_conv(from.clone(), to.clone(), rate_type, is_inv) .await; let s = match cached { Some(s) => s, None => { log::debug!("empty cache from to"); - let s = generate_from_to_table(from.clone(), to.clone(), &rates, rate_type, is_inv); - db.set_cache_from_to(from.clone(), to.clone(), rate_type, is_inv, s.clone()) + let s = generate_conv_table(from.clone(), to.clone(), &rates, rate_type, is_inv); + db.set_cache_conv(from.clone(), to.clone(), rate_type, is_inv, s.clone()) .await; s } @@ -521,3 +588,11 @@ async fn from_to_repl( } Ok(()) } + +async fn dunno_repl( + bot: Bot, + msg: Message, +) -> Result<(), Box> { + bot.send_message(msg.chat.id, DUNNO).await?; + Ok(()) +} diff --git a/src/collector.rs b/src/collector.rs index 4707fbf..081446d 100644 --- a/src/collector.rs +++ b/src/collector.rs @@ -4,12 +4,11 @@ use crate::sources::{ unibank, unistream, vtb_am, Config, Currency, JsonResponse, LSoftResponse, Rate, RateType, RateTypeJsonResponse, Source, }; +use anyhow::bail; use reqwest::Client; use rust_decimal::Decimal; use rust_decimal_macros::dec; -use std::collections::HashMap; -use std::env; -use std::fmt::Debug; +use std::{collections::HashMap, env, fmt::Debug}; use strum::{EnumCount, IntoEnumIterator}; use tokio::sync::mpsc; @@ -513,14 +512,15 @@ async fn collect_ararat(client: &Client, config: &ararat::Config) -> anyhow::Res async fn collect_idpay(client: &Client, config: &idpay::Config) -> anyhow::Result> { let resp: idpay::Response = idpay::Response::get_rates(client, config).await?; let to = Currency::default(); + let from = Currency::rub(); let Some(rate) = resp .result .currency_rate .iter() - .filter(|v| v.iso_txt == Currency::rub()) + .filter(|v| v.iso_txt == from) .next() else { - Err(Error::NoRates)? + bail!(Error::NoRates); }; let mut rate_buy = None; let mut rate_sell = None; @@ -545,7 +545,6 @@ async fn collect_idpay(client: &Client, config: &idpay::Config) -> anyhow::Resul rate_buy_idbank = Some(sell - percent(config.commission_rate, sell)); } } - let from = rate.iso_txt.clone(); Ok(vec![ Rate { from: from.clone(), @@ -577,7 +576,7 @@ async fn collect_mir(client: &Client, config: &mir::Config) -> anyhow::Result dec!(0.0) { Some(dec!(1.0) / rate.value_sell) @@ -629,10 +628,10 @@ async fn collect_kwikpay(client: &Client, config: &kwikpay::Config) -> anyhow::R .filter(|v| v.rate_type == RateType::Cash && v.from == from) .next() else { - Err(Error::NoRates)? + bail!(Error::NoRates); }; let Some(buy) = rate.buy else { - Err(Error::NoRates)? + bail!(Error::NoRates); }; Ok(vec![Rate { from: from.clone(), @@ -655,10 +654,10 @@ async fn collect_unistream( .filter(|v| v.rate_type == RateType::Cash && v.from == from) .next() else { - Err(Error::NoRates)? + bail!(Error::NoRates); }; let Some(buy) = rate.buy else { - Err(Error::NoRates)? + bail!(Error::NoRates); }; let results = [ config.commission_rate_from_bank, diff --git a/src/generator.rs b/src/generator.rs index 80cdcd8..962e7de 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -1,9 +1,13 @@ -use crate::sources::{Currency, Rate, RateType, Source}; -use crate::DUNNO; +use crate::{ + sources::{Currency, Rate, RateType, Source}, + DUNNO, +}; use rust_decimal::{Decimal, RoundingStrategy}; use rust_decimal_macros::dec; -use std::collections::{HashMap, HashSet}; -use std::fmt::Write; +use std::{ + collections::{HashMap, HashSet}, + fmt::Write, +}; const RATE_DP: u32 = 4; const DIFF_DP: u32 = 2; @@ -93,12 +97,12 @@ fn dfs( visited.remove(&from); } -pub fn generate_from_to_table( +pub fn generate_conv_table( from: Currency, to: Currency, rates: &HashMap>, rate_type: RateType, - is_inv: bool, + inv: bool, ) -> String { if from.is_empty() || to.is_empty() { return DUNNO.into(); @@ -117,7 +121,7 @@ pub fn generate_from_to_table( let mut table = vec![]; let mut src_width: usize = 0; let mut rate_width: usize = 0; - let sort = if is_inv { + let sort = if inv { |a: Decimal, b: Decimal| a.partial_cmp(&b).expect("panic") } else { |a: Decimal, b: Decimal| b.partial_cmp(&a).expect("panic") @@ -125,7 +129,7 @@ pub fn generate_from_to_table( for (src, rates) in rates { let graph = build_graph(&rates, rate_type); let mut paths = find_all_paths(&graph, from.clone(), to.clone()); - if is_inv { + if inv { paths.iter_mut().for_each(|v| v.1 = dec!(1.0) / v.1); } paths.sort_by(|a, b| sort(a.1, b.1)); @@ -224,7 +228,7 @@ fn decimal_to_string(value: Decimal, dp: u32) -> String { pub fn generate_src_table( src: Source, rates: &HashMap>, - mut rate_type: RateType, + rate_type: RateType, ) -> String { let Some(rates) = rates.get(&src) else { return DUNNO.into(); @@ -238,6 +242,7 @@ pub fn generate_src_table( to: Currency, } + let mut rate_type = rate_type; if src == Source::CbAm { rate_type = RateType::Cb; } @@ -514,24 +519,21 @@ mod tests { let graph = build_graph(&rates, RateType::NoCash); let test_cases = get_test_cases(); for (from, to) in test_cases { - let mut paths = find_all_paths(&graph, from.clone(), to.clone()); - paths.sort_by(|a, b| b.1.partial_cmp(&a.1).expect("panic")); + let _ = find_all_paths(&graph, from.clone(), to.clone()); } Ok(()) } #[tokio::test] - async fn test_generate_from_to_table() -> anyhow::Result<()> { + async fn test_generate_conv_table() -> anyhow::Result<()> { let client = build_client()?; let config = load_src_config()?; let results = collect_all(&client, &config).await; let rates = filter_collection(results); let test_cases = get_test_cases(); for (from, to) in test_cases { - let _ = - generate_from_to_table(from.clone(), to.clone(), &rates, RateType::NoCash, false); - let _ = - generate_from_to_table(to.clone(), from.clone(), &rates, RateType::NoCash, true); + let _ = generate_conv_table(from.clone(), to.clone(), &rates, RateType::NoCash, false); + let _ = generate_conv_table(to.clone(), from.clone(), &rates, RateType::NoCash, true); } Ok(()) } diff --git a/src/main.rs b/src/main.rs index b30ef92..20131a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,5 @@ -use am_rate_bot::sources::Config; -use am_rate_bot::{bot, collector, Opts}; -use std::sync::Arc; -use std::time::Duration; -use std::{env, fs}; +use am_rate_bot::{bot, collector, sources::Config, Opts}; +use std::{env, fs, sync::Arc, time::Duration}; const ENV_SRC_CONFIG: &str = "SRC_CONFIG"; const ENV_REQWEST_TIMEOUT: &str = "REQWEST_TIMEOUT"; diff --git a/src/sources/hsbc.rs b/src/sources/hsbc.rs index eaf83fd..bc8a28e 100644 --- a/src/sources/hsbc.rs +++ b/src/sources/hsbc.rs @@ -36,16 +36,18 @@ impl Response { let sell = cells.next().ok_or(Error::Html)?.text(); let buy_cash = cells.next().ok_or(Error::Html)?.text(); let sell_cash = cells.next().ok_or(Error::Html)?.text(); + let to = Currency::default(); + let from = Currency::new(currency); rates.push(Rate { - from: Currency::new(¤cy), - to: Currency::default(), + from: from.clone(), + to: to.clone(), rate_type: RateType::NoCash, buy: buy.trim().parse().ok(), sell: sell.trim().parse().ok(), }); rates.push(Rate { - from: Currency::new(¤cy), - to: Currency::default(), + from, + to, rate_type: RateType::Cash, buy: buy_cash.trim().parse().ok(), sell: sell_cash.trim().parse().ok(), diff --git a/src/sources/mod.rs b/src/sources/mod.rs index 3b168ea..c34ab82 100644 --- a/src/sources/mod.rs +++ b/src/sources/mod.rs @@ -1,7 +1,7 @@ +use anyhow::bail; pub use lsoft::LSoftResponse; use rust_decimal::Decimal; -use serde::de::DeserializeOwned; -use serde::Deserialize; +use serde::{de::DeserializeOwned, Deserialize}; use std::fmt::Debug; pub mod acba; @@ -66,7 +66,7 @@ pub trait RateTypeJsonResponse { { match rate_type { RateType::NoCash | RateType::Cash => {} - _ => Err(Error::InvalidRateType)?, + _ => bail!(Error::InvalidRateType), }; let resp = client .get(format!("{}{}", config.rates_url(), rate_type as u8)) diff --git a/src/sources/vtb_am.rs b/src/sources/vtb_am.rs index 461bdd4..2e67ec0 100644 --- a/src/sources/vtb_am.rs +++ b/src/sources/vtb_am.rs @@ -50,7 +50,7 @@ impl Response { .ok_or(Error::Html)? .text(); rates.push(Rate { - from: Currency::new(¤cy), + from: Currency::new(currency), to: Currency::default(), rate_type: match idx { 0 => RateType::Cash,