init
This commit is contained in:
commit
2e392714f9
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
12
Cargo.toml
Normal file
12
Cargo.toml
Normal file
@ -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"
|
||||
1
rustfmt.toml
Normal file
1
rustfmt.toml
Normal file
@ -0,0 +1 @@
|
||||
tab_spaces = 2
|
||||
129
src/account.rs
Normal file
129
src/account.rs
Normal file
@ -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<S>(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<Self, Self::Err> {
|
||||
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: <name>:<telegram_id>")
|
||||
}
|
||||
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<AccountSettings>,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
fn settings_file(&self) -> PathBuf {
|
||||
PathBuf::from(format!("account_settings/{}.json", self.name))
|
||||
}
|
||||
|
||||
fn load_settings(&self) -> Result<AccountSettings, io::Error> {
|
||||
// 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<AccountInfo> 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
|
||||
}
|
||||
}
|
||||
147
src/main.rs
Normal file
147
src/main.rs
Normal file
@ -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<ConversationState>;
|
||||
type ConversationStorage = Arc<IdealConversationStorage>;
|
||||
type Conversation = Dialogue<ConversationState, IdealConversationStorage>;
|
||||
type DynamicError = Box<dyn std::error::Error + Send + Sync>;
|
||||
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 <name>:<telegram-id>
|
||||
#[arg(short = 'A', long, num_args=2.., value_parser = AccountInfo::from_str)]
|
||||
accounts: Vec<AccountInfo>,
|
||||
}
|
||||
|
||||
#[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<DynamicError> {
|
||||
use dptree::case;
|
||||
|
||||
let command_handler = teloxide::filter_command::<BotCommand, _>()
|
||||
.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!()
|
||||
}
|
||||
94
src/settings.rs
Normal file
94
src/settings.rs
Normal file
@ -0,0 +1,94 @@
|
||||
use rand::prelude::IndexedRandom;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AccountSettings {
|
||||
pub greetings: Vec<String>,
|
||||
pub message_skeletons: Vec<String>,
|
||||
pub names: Vec<Names>,
|
||||
pub places: Vec<Place>,
|
||||
}
|
||||
|
||||
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("<greeting> <name> -> <place>")
|
||||
}
|
||||
|
||||
pub fn names_of<S>(&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<Place> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
impl Names {
|
||||
pub fn new<S>(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<String>,
|
||||
}
|
||||
|
||||
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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user