This commit is contained in:
Leonard Steppy 2025-01-26 23:41:07 +01:00
commit 2e392714f9
6 changed files with 385 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
Cargo.lock

12
Cargo.toml Normal file
View 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
View File

@ -0,0 +1 @@
tab_spaces = 2

129
src/account.rs Normal file
View 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
View 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
View 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![],
}
}
}