From 2e392714f9186921a8a5fa30eb9fb58b8478e2be Mon Sep 17 00:00:00 2001 From: Steppy Date: Sun, 26 Jan 2025 23:41:07 +0100 Subject: [PATCH] init --- .gitignore | 2 + Cargo.toml | 12 ++++ rustfmt.toml | 1 + src/account.rs | 129 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 147 ++++++++++++++++++++++++++++++++++++++++++++++++ src/settings.rs | 94 +++++++++++++++++++++++++++++++ 6 files changed, 385 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 rustfmt.toml create mode 100644 src/account.rs create mode 100644 src/main.rs create mode 100644 src/settings.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d3c93b8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "online_notification_bot" +version = "0.1.0" +edition = "2021" + +[dependencies] +clap = { version = "4.5.27", features = ["derive"] } +teloxide = { version = "0.13.0", features = ["sqlite-storage-rustls", "rustls", "macros"] } +tokio = { version = "1",features = ["full"] } +serde = { version = "1.0.217", features = ["derive"] } +serde_json = "1.0.137" +rand = "0.9.0-beta.3" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..6f2e075 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +tab_spaces = 2 \ No newline at end of file diff --git a/src/account.rs b/src/account.rs new file mode 100644 index 0000000..8418664 --- /dev/null +++ b/src/account.rs @@ -0,0 +1,129 @@ +use crate::settings::AccountSettings; +use crate::TelegramId; +use std::error::Error; +use std::fmt::Display; +use std::fs::File; +use std::io::{BufWriter, ErrorKind}; +use std::ops::Deref; +use std::path::PathBuf; +use std::str::FromStr; +use std::{fs, io}; + +#[derive(Debug, Clone)] +pub struct AccountInfo { + pub name: String, + pub telegram_id: TelegramId, +} + +impl AccountInfo { + pub fn new(name: S, telegram_id: TelegramId) -> Self where S: ToString { + Self { + name: name.to_string(), + telegram_id, + } + } +} + +impl FromStr for AccountInfo { + type Err = AccountInfoParseError; + + fn from_str(s: &str) -> Result { + let (name, id_str) = s.rsplit_once(':').ok_or(AccountInfoParseError::InvalidFormat)?; + let telegram_id = id_str + .parse() + .map_err(|_| AccountInfoParseError::InvalidTelegramId { + id_string: id_str.to_owned(), + })?; + Ok(AccountInfo::new(name, telegram_id)) + } +} + +#[derive(Debug)] +pub enum AccountInfoParseError { + InvalidFormat, + InvalidTelegramId { id_string: String }, +} + +impl Display for AccountInfoParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AccountInfoParseError::InvalidFormat => { + write!(f, "Invalid format, please use: :") + } + AccountInfoParseError::InvalidTelegramId { id_string } => { + write!(f, "Invalid telegram id '{id_string}'") + } + } + } +} + +impl Error for AccountInfoParseError {} + +#[derive(Debug)] +pub struct Account { + pub info: AccountInfo, + settings: Option, +} + +impl Account { + fn settings_file(&self) -> PathBuf { + PathBuf::from(format!("account_settings/{}.json", self.name)) + } + + fn load_settings(&self) -> Result { + // return default settings, if file doesn't exist + match File::open(self.settings_file()) { + Ok(file) => Ok(serde_json::from_reader(file)?), + Err(e) => { + if e.kind() == ErrorKind::NotFound { + Ok(AccountSettings::default()) + } else { + Err(e) + } + } + } + } + + pub fn reload_settings(&mut self) -> Result<&mut AccountSettings, io::Error> { + Ok(self.settings.insert(self.load_settings()?)) + } + + pub fn settings(&mut self) -> Result<&mut AccountSettings, io::Error> { + if self.settings.is_some() { + //pattern match wouldn't work with the borrow checker here + Ok(self.settings.as_mut().unwrap()) + } else { + self.reload_settings() + } + } + + pub fn save_settings(&self) -> Result<(), io::Error> { + let settings_file = self.settings_file(); + //create parent directory if not existent yet + if let Some(settings_directory) = settings_file.parent() { + fs::create_dir_all(settings_directory)?; + } + + let file = File::create(settings_file)?; + let mut buf_writer = BufWriter::new(file); + serde_json::to_writer_pretty(&mut buf_writer, &self.settings)?; + Ok(()) + } +} + +impl From for Account { + fn from(info: AccountInfo) -> Self { + Self { + info, + settings: None, + } + } +} + +impl Deref for Account { + type Target = AccountInfo; + + fn deref(&self) -> &Self::Target { + &self.info + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..1f517d5 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,147 @@ +mod account; +mod settings; + +use crate::account::AccountInfo; +use clap::Parser; +use serde::{Deserialize, Serialize}; +use std::fmt::Debug; +use std::str::FromStr; +use std::sync::Arc; +use teloxide::dispatching::dialogue::serializer::Json; +use teloxide::dispatching::dialogue::{ErasedStorage, SqliteStorage, Storage}; +use teloxide::dispatching::UpdateHandler; +use teloxide::macros::BotCommands; +use teloxide::prelude::*; + +type TelegramId = i64; +type IdealConversationStorage = ErasedStorage; +type ConversationStorage = Arc; +type Conversation = Dialogue; +type DynamicError = Box; +type HandlerResult = Result<(), DynamicError>; + +/// Start the notification-bot with the specified accounts and bot-token +#[derive(Parser, Debug)] +#[command(version, about, long_about)] +struct CLIParameters { + /// The bot token + #[arg(short = 'T', long)] + bot_token: String, + /// All account in the group. Enter them in the format : + #[arg(short = 'A', long, num_args=2.., value_parser = AccountInfo::from_str)] + accounts: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +enum ConversationState { + #[default] + Start, + Menu, + NameEditAccountSelection, + NameListUpdate { + account_name: String, + }, + PlaceNotificationSelection, +} + +#[derive(Debug, Clone, BotCommands)] +#[command(rename_rule = "lowercase")] +enum BotCommand { + Start, +} + +#[tokio::main] +async fn main() { + let parameters = CLIParameters::parse(); + + let bot = Bot::new(parameters.bot_token); + let dialog_storage: ConversationStorage = SqliteStorage::open("conversations.db", Json) + .await + .expect("failed to initialize conversation storage") + .erase(); + Dispatcher::builder(bot, create_update_handler()) + .dependencies(dptree::deps![dialog_storage]) + .enable_ctrlc_handler() + .build() + .dispatch() + .await; +} + +fn create_update_handler() -> UpdateHandler { + use dptree::case; + + let command_handler = teloxide::filter_command::() + .branch(case![BotCommand::Start].endpoint(start_conversation)); + + let message_handler = Update::filter_message() + .branch(command_handler) + .branch(case![ConversationState::Start].endpoint(start_conversation)) + .branch(case![ConversationState::NameListUpdate { account_name }].endpoint(on_name_list_update)) + .endpoint(on_unexpected_message); + + let callback_query_handler = Update::filter_callback_query() + .branch(case![ConversationState::Menu].endpoint(on_menu_button)) + .branch( + case![ConversationState::NameEditAccountSelection].endpoint(on_name_edit_account_selection), + ) + .branch( + case![ConversationState::PlaceNotificationSelection] + .endpoint(on_place_notification_selection), + ); + + dptree::entry() + .branch(message_handler) + .branch(callback_query_handler) +} + +async fn start_conversation( + bot: Bot, + conversation: Conversation, + _message: Message, +) -> HandlerResult { + send_menu_message(bot, conversation).await?; + Ok(()) +} + +async fn send_menu_message(bot: Bot, conversation: Conversation) -> HandlerResult { + //TODO inline keyboard markup and messages + bot.send_message(conversation.chat_id(), "").await?; + conversation.update(ConversationState::Menu).await?; + Ok(()) +} + +async fn on_menu_button( + bot: Bot, + conversation: Conversation, + query: CallbackQuery, +) -> HandlerResult { + todo!() +} + +async fn on_name_edit_account_selection( + bot: Bot, + conversation: Conversation, + query: CallbackQuery, +) -> HandlerResult { + todo!() +} + +async fn on_place_notification_selection( + bot: Bot, + conversation: Conversation, + query: CallbackQuery, +) -> HandlerResult { + todo!() +} + +async fn on_name_list_update( + bot: Bot, + conversation: Conversation, + account_name: String, +) -> HandlerResult { + todo!() +} + +async fn on_unexpected_message(bot: Bot, conversation: Conversation) -> HandlerResult { + todo!() +} diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..b4c7cb6 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,94 @@ +use rand::prelude::IndexedRandom; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AccountSettings { + pub greetings: Vec, + pub message_skeletons: Vec, + pub names: Vec, + pub places: Vec, +} + +impl AccountSettings { + pub fn random_greeting(&self) -> &str { + self + .greetings + .choose(&mut rand::rng()) + .map(|s| s.as_str()) + .unwrap_or("Hey") + } + + pub fn random_message_skeleton(&self) -> &str { + self.message_skeletons.choose(&mut rand::rng()).map(|s| s.as_str()).unwrap_or(" -> ") + } + + pub fn names_of(&self, account_name: S) -> Names + where + S: ToString, + { + let account_name = account_name.to_string(); + self + .names + .iter() + .find(|names| names.account_name == account_name) + .cloned() + .unwrap_or_else(|| Names::new(account_name)) + } + + pub fn non_empty_places(&self) -> Vec { + let mut places = self.places.clone(); + if places.is_empty() { + places.push(Place::default()); + } + places + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Names { + pub account_name: String, + pub display_names: Vec, +} + +impl Names { + pub fn new(name: S) -> Self + where + S: ToString, + { + Self { + account_name: name.to_string(), + display_names: vec![], + } + } + + pub fn random_name(&self) -> &str { + self + .display_names + .choose(&mut rand::rng()) + .unwrap_or(&self.account_name) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Place { + pub name: String, + pub display_names: Vec, +} + +impl Place { + pub fn random_name(&self) -> &str { + self + .display_names + .choose(&mut rand::rng()) + .unwrap_or(&self.name) + } +} + +impl Default for Place { + fn default() -> Self { + Self { + name: "offline".to_string(), + display_names: vec![], + } + } +}