From 9253a14fa20d32a2ca49a2dd443b4aeec88fcdb1 Mon Sep 17 00:00:00 2001 From: gbrochar Date: Thu, 7 Dec 2023 16:21:19 +0100 Subject: [PATCH] feat: initial version features: - add, new, choose commands - index message and weekend messages generation on the fly - loading/saving index from data.json file - basic conversation --- .gitignore | 8 + Cargo.toml | 17 ++ README.md | 10 ++ src/main.rs | 482 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 517 insertions(+) create mode 100644 Cargo.toml create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore index 62bd1a4..2b62647 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +.env +data.json + # ---> Rust # Generated by Cargo # will have compiled files and executables @@ -10,3 +13,8 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk + + +# Added by cargo + +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..dd8bd06 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "index_bot" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +teloxide = { version = "0.12", features = ["macros"] } +tokio = { version = "1.8", features = ["rt-multi-thread", "macros"] } +url = "2.3" +chrono = { version = "0.4.31", features = ["serde"] } +serde = "1.0.193" +serde_with = "3.4.0" +serde_json = "1.0.108" +dotenv = "0.15.0" +dotenv_codegen = "0.15.0" diff --git a/README.md b/README.md index 3edc1ec..fa17528 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,12 @@ # index_bot +Telegram bot to index messages for events of any kind and post them in a telegram channel. + +⚠️ This software erases any content in `data.json` file in the folder it's ran. This file is used for storage. + +please populate `.env` file as follow: +``` +BOT_API_TOKEN=123456789:abcdefghijklmnopqrstuvwxyz +CHANNEL_ID=-100123456789 +ADMIN_ID=123456789 +``` \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..94c850f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,482 @@ +#[macro_use] +extern crate dotenv_codegen; + +use std::sync::Arc; +use std::fmt; +use std::collections::HashMap; +use std::error::Error; +use std::fs::{OpenOptions, File}; +use std::io::{BufWriter, Write, BufReader}; +use serde::{ Serialize, Deserialize }; +use serde_with::serde_as; +use serde_json; +use chrono::{ NaiveDate, Weekday, Datelike, Days }; +use chrono::offset::Local; +use url::Url; +use tokio::sync::Mutex; +use teloxide::{prelude::*, types::MessageEntity, types::MessageId}; + +#[derive(Clone, Serialize, Deserialize)] +enum Kind { + None, + Text, + Fly, + TextFly, + _Gps, +} + +#[derive(Clone, PartialEq, Serialize, Deserialize)] +enum State { + Base, + New, + Add, + _Del +} + +impl fmt::Display for Kind { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Kind::None => write!(f, "💀 error 💀"), + Kind::Text => write!(f, "ℹ info ℹ️"), + Kind::Fly => write!(f, "🖼️ fly 🖼️"), + Kind::TextFly => write!(f, "😳 fly+info 😳"), + Kind::_Gps => write!(f, "🛰️ gps 🛰️"), + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +struct Event { + kind: Vec, + urls: Vec, + ids: Vec, + name: String, + date: NaiveDate, + loca: String, +} + +#[derive(Clone, Serialize, Deserialize)] +struct Weekend { + url: Option, + id: Option, + date: NaiveDate, + events: Vec, +} + +#[serde_as] +#[derive(Serialize, Deserialize)] +struct Index { + #[serde_as(as = "Vec<(_, _)>")] + hashmap: HashMap, + url: Url, + id: MessageId, + state: State, + ram: Option<(NaiveDate, Message)>, + admin_id: ChatId, + channel_id: ChatId, +} + +fn telegram_len(str: &str) -> usize { + str.encode_utf16().collect::>().len() +} + + +// This function takes a day and a month, create a date with current or next year and quantize to nearest Saturday +fn get_weekend_date(day: u32, month: u32) -> Option { + let now = Local::now(); + let current_year = now.year(); + let date = NaiveDate::from_ymd_opt(current_year, month, day); + if let None = date { + return None; + } + let mut date = date.unwrap(); + if std::cmp::max(date, now.date_naive()) != date { + date = NaiveDate::from_ymd_opt(current_year + 1, month, day).unwrap(); + } + date = match date.weekday() { + Weekday::Mon => date.checked_sub_days(Days::new(2)).unwrap(), + Weekday::Tue => date.checked_sub_days(Days::new(3)).unwrap(), + Weekday::Wed => date.checked_add_days(Days::new(3)).unwrap(), + Weekday::Thu => date.checked_add_days(Days::new(2)).unwrap(), + Weekday::Fri => date.checked_add_days(Days::new(1)).unwrap(), + Weekday::Sat => date.checked_add_days(Days::new(0)).unwrap(), + Weekday::Sun => date.checked_sub_days(Days::new(1)).unwrap(), + }; + Some(date) +} + +fn parse_date(string: &str) -> Result<(u32, u32), &'static str> { + match string.split('/').collect::>().as_slice() { + [day, month] => + Ok((day.parse().unwrap_or_default(), month.parse().unwrap_or_default())), + _ => Err("usage: add DD/MM"), + } +} + +#[cfg(test)] +mod tests { + use crate::get_weekend_date; + use crate::parse_date; + use chrono::*; + + #[test] + fn weekend_date() { + let date = get_weekend_date(30, 11).unwrap(); + assert_eq!(date.day(), 2); + assert_eq!(date.month(), 12); + } + + #[test] + fn parse() { + assert_eq!(parse_date("30/11"), Ok((30, 11))); + assert_eq!(parse_date("04/11"), Ok((4, 11))); + assert_eq!(parse_date("30/04"), Ok((30, 4))); + assert_eq!(parse_date("04/04"), Ok((4, 4))); + } + + #[test] + fn parse_and_we() { + let date = parse_date("04/11").unwrap(); + let date = get_weekend_date(date.0, date.1).unwrap(); + assert_eq!(date.day(), 2); + assert_eq!(date.month(), 11); + + } +} + +fn create_weekend_msg(events: Vec) -> (String, Vec) { + let mut entities = vec![]; + let mut str = String::from(format!("🔅INDEX {}/{}🔅\n", events[0].date.day0() + 1, events[0].date.month0() + 1)); + + for event in events.iter() { + let mut event_str = format!("🍃 {} ", event.name); + for j in 0..event.urls.len() { + let entity_str = format!("{} ", event.kind[j]); + entities.push(MessageEntity { + kind: teloxide::types::MessageEntityKind::TextLink { + url: event.urls[j].clone() + }, + offset: telegram_len(&str) + telegram_len(&event_str) + 1, + length: telegram_len(&entity_str) - 1 + }); + event_str = format!("{}{}", event_str, entity_str); + } + str = format!("{}\n{}(area {})", str, event_str, event.loca); + } + + (str, entities) +} + +fn create_event(msg: &Message, sent_msg: Message, date: NaiveDate, kind: Kind) -> Event { + let mut name = msg.text().unwrap().split(" ").collect::>(); + let loca = name.pop().unwrap().to_string(); + name.remove(0); + let name = name.join(" "); + + Event { + kind: vec![kind], + urls: vec![sent_msg.url().unwrap()], + ids: vec![sent_msg.id], + name, + loca, + date, + } +} + +fn create_new_weekend(date: NaiveDate) -> Weekend { + Weekend { + url: None, + id: None, + date, + events: vec![], + } +} + +fn create_index_msg(index: HashMap) -> (String, Vec) { + let mut msg = format!("Index à jour\n\n"); + let mut entities = vec![]; + for (key, value) in index { + let line_str = format!("Index du {}\n", key); + entities.push(MessageEntity { + kind: teloxide::types::MessageEntityKind::TextLink { + url: value.url.unwrap().clone() + }, + offset: telegram_len(&msg), + length: telegram_len(&line_str) - 1 + }); + msg = format!("{}{}", msg, line_str); + + } + (msg, entities) +} + +async fn new_command_logic(bot: Bot, index: &Arc>, msg: Message) -> ResponseResult<()> { + let index = index.clone(); + let mut index = index.lock().await; + if index.state == State::Add || index.state == State::New { + index.state = State::Base; + let date = index.ram.clone().unwrap().0; + let info = index.ram.clone().unwrap().1; + + let mut kind = match info.text() { + Some(_) => Kind::Text, + None => Kind::None, + }; + + kind = match (info.photo(), info.caption(), &kind) { + (Some(_), Some(_), _) => Kind::TextFly, + (Some(_), None, _) => Kind::Fly, + (None, _, _) => kind, + }; + + let event = create_event(&msg, info, date, kind); + match index.hashmap.get_mut(&date) { + Some(weekend) => { + weekend.events.push(event); + }, + None => { + index.hashmap.insert(date, create_new_weekend(date)); + let new_index_msg = bot.send_message(index.channel_id, "Index en cours de chargement... 🤪").await?; + index.hashmap.get_mut(&date).unwrap().url = Some(new_index_msg.url().unwrap()); + index.hashmap.get_mut(&date).unwrap().id = Some(new_index_msg.id); + } + }; + let weekend = index.hashmap.get(&date).unwrap(); + let weekend_msg = create_weekend_msg(weekend.events.clone()); + bot.edit_message_text(index.channel_id, weekend.id.unwrap(), weekend_msg.0).entities(weekend_msg.1).await?; + let index_msg = create_index_msg(index.hashmap.clone()); + bot.edit_message_text(index.channel_id, index.id, index_msg.0).entities(index_msg.1).await?; + } else { + bot.send_message(index.admin_id, "error: you must be in Add or New state to use new command").await?; + } + respond(()) +} + +async fn new_command(bot: Bot, index: &Arc>, msg: Message) -> ResponseResult<()> { + if msg.text().unwrap().split(" ").collect::>().len() >= 3 { + new_command_logic(bot, index, msg).await?; + } else { + let index = index.clone(); + let index = index.lock().await; + bot.send_message(index.admin_id, "usage: new ").await?; + } + respond(()) +} + +async fn choose_command_logic(bot: Bot, index: &Arc>, event_number: usize) -> ResponseResult<()> { + let index = index.clone(); + let mut index = index.lock().await; + let channel_id = index.channel_id; + if index.state == State::Add { + let date = index.ram.clone().unwrap().0; + let info = index.ram.clone().unwrap().1; + + if let Some(weekend) = index.hashmap.get_mut(&date) { + if weekend.events.len() >= event_number && event_number > 0 { + let mut kind = match info.text() { + Some(_) => Kind::Text, + None => Kind::None, + }; + + kind = match (info.photo(), &kind) { + (Some(_), Kind::Text) => Kind::TextFly, + (Some(_), Kind::None) => Kind::Fly, + (Some(_), _) => unreachable!(), + (None, _) => kind, + }; + weekend.events[event_number - 1].kind.push(kind); + weekend.events[event_number - 1].urls.push(info.url().unwrap()); + weekend.events[event_number - 1].ids.push(info.id); + let weekend_msg = create_weekend_msg(weekend.events.clone()); + bot.edit_message_text(channel_id, weekend.id.unwrap(), weekend_msg.0).entities(weekend_msg.1).await?; + let index_msg = create_index_msg(index.hashmap.clone()); + bot.edit_message_text(channel_id, index.id, index_msg.0).entities(index_msg.1).await?; + index.state = State::Base; + } else { + bot.send_message(index.admin_id, "error: invalid number, please retry choose or new").await?; + } + }; + } else { + bot.send_message(index.admin_id, "error: you must be in Add state to use choose command").await?; + } + respond(()) +} + +async fn choose_command(bot: Bot, index: &Arc>, msg: Message) -> ResponseResult<()> { + if msg.text().unwrap().split(" ").collect::>().len() == 2 { + match msg.text().unwrap().split(" ").collect::>()[1].parse::() { + Ok(event_number) => DateReturn::Void(choose_command_logic(bot, index, event_number).await?), + Err(e) => { + let index = index.clone(); + let index = index.lock().await; + DateReturn::Message(bot.send_message(index.admin_id, e.to_string()).await?) + }, + }; + } else { + let index = index.clone(); + let index = index.lock().await; + bot.send_message(index.admin_id, "usage: choose ").await?; + } + respond(()) +} + +fn create_choose_message(events: Vec) -> String { + let mut msg = String::from("entered add state successfully\nusage:\nchoose \nOR\nnew \n\n"); + + for (i, elem) in events.iter().enumerate() { + msg = format!("{msg} [{}] {} (area {})\n", i + 1, elem.name, elem.loca); + } + + msg +} + +async fn add_command_logic(bot: Bot, index: &Arc>, msg: Message, date: NaiveDate) -> ResponseResult<()> { + let index = index.clone(); + let mut index = index.lock().await; + let channel_id = index.channel_id; + if index.state == State::Base { + let mut state = State::New; + let info_admin_dm = msg.reply_to_message().unwrap(); + let info = bot.forward_message(index.channel_id, index.admin_id, info_admin_dm.id).await?; + index.ram = Some((date, info)); + match index.hashmap.get_mut(&date) { + Some(weekend) => { + state = State::Add; + let choose_msg = create_choose_message(weekend.events.clone()); + bot.send_message(index.admin_id, choose_msg).await?; + }, + None => { + index.hashmap.insert(date, create_new_weekend(date)); + let weekend = index.hashmap.get_mut(&date).unwrap(); + let new_index_msg = bot.send_message(channel_id, "Index en cours de chargement... 🤪").await?; + weekend.url = Some(new_index_msg.url().unwrap()); + weekend.id = Some(new_index_msg.id); + bot.send_message(index.admin_id, "entered add state successfully\nusage:\nnew \n").await?; + } + }; + index.state = state; + } else { + bot.send_message(index.admin_id, "error: you must be in Base state to use add command").await?; + } + respond(()) +} + +enum DateReturn { + DateReturn(Box), + Message(Message), + Void(()), +} + +async fn add_command(bot: Bot, index: &Arc>, msg: Message) -> ResponseResult<()> { + let admin_id = index.clone(); + let lock = admin_id.lock().await; + let admin_id = lock.admin_id; + drop(lock); + if let Some(_) = msg.reply_to_message() { + if let Some(arg) = msg.text().unwrap().split(" ").last() { + match parse_date(arg) { + Ok((day, month)) => DateReturn::DateReturn(Box::new(match get_weekend_date(day, month) { + Some(date) => DateReturn::Void(add_command_logic(bot, index, msg.clone(), date).await?), + None => DateReturn::Message(bot.send_message(admin_id, "error: date is not valid").await?) + })), + Err(e) => DateReturn::Message(bot.send_message(admin_id, e).await?), + }; + } else { + bot.send_message(admin_id, "usage: add DD/MM").await?; + } + } else { + bot.send_message(admin_id, "error: You must reply to an info for the add command to work").await?; + } + respond(()) +} + +async fn default_command(bot: Bot, admin_id: ChatId) -> ResponseResult<()> { + bot.send_message(admin_id, "info/fly detecte, help pour la liste des commandes").await?; + respond(()) +} + +async fn handler(bot: Bot, index: Arc>, msg: Message) -> ResponseResult<()> { + let admin_id = index.clone(); + let lock = admin_id.lock().await; + let admin_id = lock.admin_id; + drop(lock); + if msg.chat.id == admin_id { + if let Some(msg_content) = msg.text() { + if let Some(command) = msg_content.split(" ").next() { + match command { + "add" => add_command(bot, &index, msg).await?, + "choose" => choose_command(bot, &index, msg).await?, + "new" => new_command(bot, &index, msg).await?, + _ => default_command(bot, admin_id).await?, + + } + } + } + } + let index = index.clone(); + let index = index.lock().await; + let index = Index { + hashmap: index.hashmap.clone(), + url: index.url.clone(), + id: index.id, + state: index.state.clone(), + ram: index.ram.clone(), + admin_id: index.admin_id, + channel_id: index.channel_id, + }; + + let file = OpenOptions::new().write(true).create(true).truncate(true).open("data.json").unwrap(); + let mut writer = BufWriter::new(file); + let _ = serde_json::to_writer(&mut writer, &index); + writer.flush()?; + + + respond(()) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let bot = Bot::new(dotenv!("BOT_API_TOKEN")); + let index; + let channel_id = ChatId(dotenv!("CHANNEL_ID").parse::().unwrap_or_else(|_| 0)); + let admin_id = ChatId(dotenv!("ADMIN_ID").parse::().unwrap_or_else(|_| 0)); + let file = File::open("data.json"); + match file { + Ok(file) => { + let reader = BufReader::new(file); + index = Arc::new(Mutex::new(serde_json::from_reader(reader)?)); + }, + Err(_) => { + let index_msg = bot.send_message(channel_id, "INDEX AUTOMATISE\n").await?; + bot.pin_chat_message(channel_id, index_msg.id).await?; + index = Arc::new(Mutex::new(Index { + hashmap: HashMap::::new(), + url: index_msg.url().unwrap(), + id: index_msg.id, + state: State::Base, + ram: None, + admin_id: admin_id, + channel_id: channel_id + })); + } + } + + bot.send_message(admin_id, "THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.").await?; + bot.send_message(admin_id, "info: l'app vient de se lancer\n").await?; + + let handler = Update::filter_message().endpoint( + |bot: Bot, index: Arc>, msg: Message| async move { + handler(bot, index, msg).await + } + ); + + Dispatcher::builder(bot, handler) + // Pass the shared state to the handler as a dependency. + .dependencies(dptree::deps![index]) + .enable_ctrlc_handler() + .build() + .dispatch() + .await; + + Ok(()) +} \ No newline at end of file