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
This commit is contained in:
gbrochar 2023-12-07 16:21:19 +01:00
parent fa17f8bb1c
commit 9253a14fa2
4 changed files with 517 additions and 0 deletions

8
.gitignore vendored
View File

@ -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

17
Cargo.toml Normal file
View File

@ -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"

View File

@ -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
```

482
src/main.rs Normal file
View File

@ -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<Kind>,
urls: Vec<Url>,
ids: Vec<MessageId>,
name: String,
date: NaiveDate,
loca: String,
}
#[derive(Clone, Serialize, Deserialize)]
struct Weekend {
url: Option<Url>,
id: Option<MessageId>,
date: NaiveDate,
events: Vec<Event>,
}
#[serde_as]
#[derive(Serialize, Deserialize)]
struct Index {
#[serde_as(as = "Vec<(_, _)>")]
hashmap: HashMap<NaiveDate, Weekend>,
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::<Vec<_>>().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<NaiveDate> {
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::<Vec<_>>().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<Event>) -> (String, Vec<MessageEntity>) {
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::<Vec<_>>();
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<NaiveDate, Weekend>) -> (String, Vec<MessageEntity>) {
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<Mutex<Index>>, 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<Mutex<Index>>, msg: Message) -> ResponseResult<()> {
if msg.text().unwrap().split(" ").collect::<Vec<_>>().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 <name with spaces> <locaWithoutSpaces>").await?;
}
respond(())
}
async fn choose_command_logic(bot: Bot, index: &Arc<Mutex<Index>>, 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<Mutex<Index>>, msg: Message) -> ResponseResult<()> {
if msg.text().unwrap().split(" ").collect::<Vec<_>>().len() == 2 {
match msg.text().unwrap().split(" ").collect::<Vec<_>>()[1].parse::<usize>() {
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 <u32>").await?;
}
respond(())
}
fn create_choose_message(events: Vec<Event>) -> String {
let mut msg = String::from("entered add state successfully\nusage:\nchoose <number>\nOR\nnew <name> <loca>\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<Mutex<Index>>, 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 <name> <loca>\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<DateReturn>),
Message(Message),
Void(()),
}
async fn add_command(bot: Bot, index: &Arc<Mutex<Index>>, 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<Mutex<Index>>, 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<dyn Error>> {
let bot = Bot::new(dotenv!("BOT_API_TOKEN"));
let index;
let channel_id = ChatId(dotenv!("CHANNEL_ID").parse::<i64>().unwrap_or_else(|_| 0));
let admin_id = ChatId(dotenv!("ADMIN_ID").parse::<i64>().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::<NaiveDate, Weekend>::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<Mutex<Index>>, 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(())
}