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:
parent
fa17f8bb1c
commit
9253a14fa2
|
@ -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
|
||||
|
|
|
@ -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"
|
10
README.md
10
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
|
||||
```
|
|
@ -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(())
|
||||
}
|
Loading…
Reference in New Issue