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