513 lines
16 KiB
Rust
513 lines
16 KiB
Rust
pub mod admin;
|
|
pub mod bot_handler;
|
|
pub mod bot_manager;
|
|
pub mod botscript;
|
|
pub mod commands;
|
|
pub mod db;
|
|
pub mod handlers;
|
|
pub mod message_answerer;
|
|
pub mod mongodb_storage;
|
|
pub mod utils;
|
|
|
|
use bot_manager::BotManager;
|
|
use botscript::application::attach_user_application;
|
|
use botscript::{BotMessage, Runner, RunnerConfig, ScriptError, ScriptResult};
|
|
use db::application::Application;
|
|
use db::bots::BotInstance;
|
|
use db::callback_info::CallbackInfo;
|
|
use db::message_forward::MessageForward;
|
|
use handlers::admin::admin_handler;
|
|
use log::{error, info, warn};
|
|
use message_answerer::MessageAnswerer;
|
|
use std::sync::{Arc, Mutex, RwLock};
|
|
use utils::create_callback_button;
|
|
|
|
use crate::db::{CallDB, DB};
|
|
use crate::mongodb_storage::MongodbStorage;
|
|
|
|
use db::DbError;
|
|
use envconfig::Envconfig;
|
|
use serde::{Deserialize, Serialize};
|
|
use teloxide::dispatching::dialogue::serializer::Json;
|
|
use teloxide::dispatching::dialogue::{GetChatId, Serializer};
|
|
use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup};
|
|
use teloxide::{
|
|
payloads::SendMessageSetters,
|
|
prelude::*,
|
|
utils::{command::BotCommands, render::RenderMessageTextHelper},
|
|
};
|
|
|
|
type BotDialogue = Dialogue<State, MongodbStorage<Json>>;
|
|
|
|
#[derive(Envconfig, Clone)]
|
|
pub struct Config {
|
|
#[envconfig(from = "BOT_TOKEN")]
|
|
pub bot_token: String,
|
|
#[envconfig(from = "DATABASE_URL")]
|
|
pub db_url: String,
|
|
#[envconfig(from = "ADMIN_PASS")]
|
|
pub admin_password: String,
|
|
#[envconfig(from = "ADMIN_ID")]
|
|
pub admin_id: u64,
|
|
#[envconfig(from = "BOT_NAME")]
|
|
pub bot_name: String,
|
|
}
|
|
|
|
#[derive(BotCommands, Clone)]
|
|
#[command(rename_rule = "lowercase")]
|
|
enum UserCommands {
|
|
/// The first message of user
|
|
Start(String),
|
|
/// Shows this message.
|
|
Help,
|
|
}
|
|
|
|
trait LogMsg {
|
|
fn log(self) -> Self;
|
|
}
|
|
|
|
impl LogMsg for <teloxide::Bot as teloxide::prelude::Requester>::SendMessage {
|
|
fn log(self) -> Self {
|
|
info!("msg: {}", self.text);
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
|
|
pub enum State {
|
|
#[default]
|
|
Start,
|
|
Edit {
|
|
literal: String,
|
|
variant: Option<String>,
|
|
lang: String,
|
|
is_caption_set: bool,
|
|
},
|
|
EditButton,
|
|
MessageForwardReply,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
#[serde(tag = "type")]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Callback {
|
|
MoreInfo,
|
|
ProjectPage { id: u32 },
|
|
GoHome,
|
|
LeaveApplication,
|
|
AskQuestion, // Add this line for the new callback
|
|
}
|
|
|
|
type CallbackStore = CallbackInfo<Callback>;
|
|
|
|
#[derive(Clone)]
|
|
pub struct BotController {
|
|
pub bot: Bot,
|
|
pub db: DB,
|
|
pub runtime: Arc<Mutex<BotRuntime>>,
|
|
}
|
|
|
|
pub struct BotRuntime {
|
|
pub rc: RunnerConfig,
|
|
pub runner: Runner,
|
|
}
|
|
unsafe impl Send for BotRuntime {}
|
|
|
|
impl Drop for BotController {
|
|
fn drop(&mut self) {
|
|
info!("called drop for BotController");
|
|
}
|
|
}
|
|
|
|
const MAIN_BOT_SCRIPT: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/mainbot.js"));
|
|
|
|
impl BotController {
|
|
pub async fn new(config: &Config) -> ScriptResult<Self> {
|
|
Self::create(
|
|
&config.bot_token,
|
|
&config.db_url,
|
|
&config.bot_name,
|
|
MAIN_BOT_SCRIPT,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn create(token: &str, db_url: &str, name: &str, script: &str) -> ScriptResult<Self> {
|
|
let db = DB::init(db_url, name.to_owned()).await?;
|
|
|
|
Self::with_db(db, token, script).await
|
|
}
|
|
|
|
pub async fn with_db(mut db: DB, token: &str, script: &str) -> ScriptResult<Self> {
|
|
let bot = Bot::new(token);
|
|
|
|
let mut runner = Runner::init_with_db(&mut db)?;
|
|
runner.call_attacher(|c, o| attach_user_application(c, o, &db, &bot))??;
|
|
let rc = runner.init_config(script)?;
|
|
let runtime = Arc::new(Mutex::new(BotRuntime { rc, runner }));
|
|
|
|
Ok(Self { bot, db, runtime })
|
|
}
|
|
}
|
|
|
|
#[derive(thiserror::Error, Debug)]
|
|
pub enum BotError {
|
|
DBError(#[from] DbError),
|
|
TeloxideError(#[from] teloxide::RequestError),
|
|
// TODO: not a really good to hardcode types, better to extend it later
|
|
StorageError(#[from] mongodb_storage::MongodbStorageError<<Json as Serializer<State>>::Error>),
|
|
MsgTooOld(String),
|
|
BotLogicError(String),
|
|
AdminMisconfiguration(String),
|
|
ScriptError(#[from] ScriptError),
|
|
IoError(#[from] std::io::Error),
|
|
RwLockError(String),
|
|
}
|
|
|
|
pub type BotResult<T> = Result<T, BotError>;
|
|
|
|
impl std::fmt::Display for BotError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{:?}", self)
|
|
}
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
dotenvy::dotenv()?;
|
|
pretty_env_logger::init();
|
|
let config = Config::init_from_env()?;
|
|
|
|
let mut db = DB::init(&config.db_url, config.bot_name.to_owned()).await?;
|
|
|
|
BotInstance::restart_all(&mut db, false).await?;
|
|
let bm = BotManager::with(
|
|
async || {
|
|
let config = config.clone();
|
|
|
|
let mut db = DB::init(config.db_url, config.bot_name.to_owned())
|
|
.await
|
|
.unwrap();
|
|
let bi = BotInstance::new(
|
|
config.bot_name,
|
|
config.bot_token,
|
|
MAIN_BOT_SCRIPT.to_string(),
|
|
);
|
|
let instances = BotInstance::get_all(&mut db).await.unwrap();
|
|
BotInstance::restart_all(&mut db, false).await.unwrap();
|
|
std::iter::once(bi).chain(instances)
|
|
},
|
|
async |bi| vec![admin_handler()].into_iter(),
|
|
);
|
|
|
|
bm.dispatch(&mut db).await;
|
|
}
|
|
|
|
async fn botscript_command_handler(
|
|
bot: Bot,
|
|
mut db: DB,
|
|
bm: BotMessage,
|
|
msg: Message,
|
|
) -> BotResult<()> {
|
|
info!("Eval BM: {:?}", bm);
|
|
let buttons = bm
|
|
.resolve_buttons(&mut db)
|
|
.await?
|
|
.map(|buttons| InlineKeyboardMarkup {
|
|
inline_keyboard: buttons
|
|
.iter()
|
|
.map(|r| {
|
|
r.iter()
|
|
.map(|b| match b {
|
|
botscript::ButtonLayout::Callback {
|
|
name,
|
|
literal: _,
|
|
callback,
|
|
} => InlineKeyboardButton::callback(name, callback),
|
|
})
|
|
.collect()
|
|
})
|
|
.collect(),
|
|
});
|
|
let literal = bm.literal().map_or("", |s| s.as_str());
|
|
|
|
let ma = MessageAnswerer::new(&bot, &mut db, msg.chat.id.0);
|
|
ma.answer(literal, None, buttons).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn callback_handler(bot: Bot, mut db: DB, q: CallbackQuery) -> BotResult<()> {
|
|
bot.answer_callback_query(&q.id).await?;
|
|
|
|
let data = match q.data {
|
|
Some(ref data) => data,
|
|
None => {
|
|
// not really our case to handle
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
let callback = match CallbackStore::get_callback(&mut db, data).await? {
|
|
Some(callback) => callback,
|
|
None => {
|
|
warn!("Not found callback for data: {data}");
|
|
// doing this silently beacuse end user shouldn't know about backend internal data
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
match callback {
|
|
Callback::MoreInfo => {
|
|
let keyboard = Some(single_button_markup!(
|
|
create_callback_button("go_home", Callback::GoHome, &mut db).await?
|
|
));
|
|
|
|
let chat_id = q.chat_id().map(|i| i.0).unwrap_or(q.from.id.0 as i64);
|
|
let message_id = q.message.map_or_else(
|
|
|| {
|
|
Err(BotError::MsgTooOld(
|
|
"Failed to get message id, probably message too old".to_string(),
|
|
))
|
|
},
|
|
|m| Ok(m.id().0),
|
|
)?;
|
|
MessageAnswerer::new(&bot, &mut db, chat_id)
|
|
.replace_message(message_id, "more_info_msg", keyboard)
|
|
.await?
|
|
}
|
|
Callback::ProjectPage { id } => {
|
|
let nextproject = match db
|
|
.get_literal_value(&format!("project_{}_msg", id + 1))
|
|
.await?
|
|
.unwrap_or("emptyproject".into())
|
|
.as_str()
|
|
{
|
|
"end" | "empty" | "none" => None,
|
|
_ => Some(
|
|
create_callback_button(
|
|
"next_project",
|
|
Callback::ProjectPage { id: id + 1 },
|
|
&mut db,
|
|
)
|
|
.await?,
|
|
),
|
|
};
|
|
let prevproject = match id.wrapping_sub(1) {
|
|
0 => None,
|
|
_ => Some(
|
|
create_callback_button(
|
|
"prev_project",
|
|
Callback::ProjectPage {
|
|
id: id.wrapping_sub(1),
|
|
},
|
|
&mut db,
|
|
)
|
|
.await?,
|
|
),
|
|
};
|
|
let keyboard = buttons_markup!(
|
|
[prevproject, nextproject].into_iter().flatten(),
|
|
[create_callback_button("go_home", Callback::GoHome, &mut db).await?]
|
|
);
|
|
|
|
let chat_id = q.chat_id().map(|i| i.0).unwrap_or(q.from.id.0 as i64);
|
|
let message_id = q.message.map_or_else(
|
|
|| {
|
|
Err(BotError::MsgTooOld(
|
|
"Failed to get message id, probably message too old".to_string(),
|
|
))
|
|
},
|
|
|m| Ok(m.id().0),
|
|
)?;
|
|
MessageAnswerer::new(&bot, &mut db, chat_id)
|
|
.replace_message(message_id, &format!("project_{}_msg", id), Some(keyboard))
|
|
.await?
|
|
}
|
|
Callback::GoHome => {
|
|
let keyboard = make_start_buttons(&mut db).await?;
|
|
|
|
let chat_id = q.chat_id().map(|i| i.0).unwrap_or(q.from.id.0 as i64);
|
|
let message_id = q.message.map_or_else(
|
|
|| {
|
|
Err(BotError::MsgTooOld(
|
|
"Failed to get message id, probably message too old".to_string(),
|
|
))
|
|
},
|
|
|m| Ok(m.id().0),
|
|
)?;
|
|
MessageAnswerer::new(&bot, &mut db, chat_id)
|
|
.replace_message(message_id, "start", Some(keyboard))
|
|
.await?
|
|
}
|
|
Callback::LeaveApplication => {
|
|
let application = Application::new(q.from.clone()).store(&mut db).await?;
|
|
let msg = send_application_to_chat(&bot, &mut db, &application).await?;
|
|
|
|
let (chat_id, msg_id) = MessageAnswerer::new(&bot, &mut db, q.from.id.0 as i64)
|
|
.answer("left_application_msg", None, None)
|
|
.await?;
|
|
MessageForward::new(msg.chat.id.0, msg.id.0, chat_id, msg_id, false)
|
|
.store(&mut db)
|
|
.await?;
|
|
}
|
|
Callback::AskQuestion => {
|
|
MessageAnswerer::new(&bot, &mut db, q.from.id.0 as i64)
|
|
.answer("ask_question_msg", None, None)
|
|
.await?;
|
|
}
|
|
};
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_application_to_chat(
|
|
bot: &Bot,
|
|
db: &mut DB,
|
|
app: &Application<teloxide::types::User>,
|
|
) -> BotResult<Message> {
|
|
let chat_id: i64 = match db.get_literal_value("support_chat_id").await? {
|
|
Some(strcid) => match strcid.parse() {
|
|
Ok(cid) => cid,
|
|
Err(err) => {
|
|
notify_admin(&format!(
|
|
"Support chat_id should be a number. Got: {strcid}, err: {err}.\n\
|
|
Anyways, applied user: {:?}",
|
|
app.from
|
|
))
|
|
.await;
|
|
return Err(BotError::BotLogicError(format!("somewhere in bots logic support_chat_id literal not stored as a number, got: {strcid}")));
|
|
}
|
|
},
|
|
None => {
|
|
notify_admin(&format!(
|
|
"support_chat_id is not set!!!\nAnyways, applied user: {:?}",
|
|
app.from
|
|
))
|
|
.await;
|
|
return Err(BotError::AdminMisconfiguration(format!(
|
|
"admin forget to set support_chat_id"
|
|
)));
|
|
}
|
|
};
|
|
let msg = match db.get_literal_value("application_format").await? {
|
|
Some(msg) => msg
|
|
.replace("{user_id}", app.from.id.0.to_string().as_str())
|
|
.replace(
|
|
"{username}",
|
|
app.from
|
|
.username
|
|
.clone()
|
|
.unwrap_or("Username not set".to_string())
|
|
.as_str(),
|
|
),
|
|
None => {
|
|
notify_admin("format for support_chat_id is not set").await;
|
|
return Err(BotError::AdminMisconfiguration(format!(
|
|
"admin forget to set application_format"
|
|
)));
|
|
}
|
|
};
|
|
|
|
Ok(bot.send_message(ChatId(chat_id), msg).await?)
|
|
}
|
|
|
|
/// This is an emergent situation function, so it should not return any Result, but handle Results
|
|
/// on its own
|
|
async fn notify_admin(text: &str) {
|
|
let config = match Config::init_from_env() {
|
|
Ok(config) => config,
|
|
Err(err) => {
|
|
error!("notify_admin: Failed to get config from env, err: {err}");
|
|
return;
|
|
}
|
|
};
|
|
let bot = Bot::new(&config.bot_token);
|
|
match bot.send_message(UserId(config.admin_id), text).await {
|
|
Ok(_) => {}
|
|
Err(err) => {
|
|
error!("notify_admin: Failed to send message to admin, WHATS WRONG???, err: {err}");
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn user_command_handler(
|
|
mut db: DB,
|
|
bot: Bot,
|
|
msg: Message,
|
|
cmd: UserCommands,
|
|
) -> BotResult<()> {
|
|
let tguser = match msg.from.clone() {
|
|
Some(user) => user,
|
|
None => return Ok(()), // do nothing, cause its not usecase of function
|
|
};
|
|
let user = db
|
|
.get_or_init_user(tguser.id.0 as i64, &tguser.first_name)
|
|
.await?;
|
|
let user = update_user_tg(user, &tguser);
|
|
user.update_user(&mut db).await?;
|
|
info!(
|
|
"MSG: {}",
|
|
msg.html_text().unwrap_or("|EMPTY_MESSAGE|".into())
|
|
);
|
|
match cmd {
|
|
UserCommands::Start(meta) => {
|
|
if !meta.is_empty() {
|
|
user.insert_meta(&mut db, &meta).await?;
|
|
}
|
|
let variant = match meta.as_str() {
|
|
"" => None,
|
|
variant => Some(variant),
|
|
};
|
|
let mut db2 = db.clone();
|
|
MessageAnswerer::new(&bot, &mut db, msg.chat.id.0)
|
|
.answer("start", variant, Some(make_start_buttons(&mut db2).await?))
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
UserCommands::Help => {
|
|
bot.send_message(msg.chat.id, UserCommands::descriptions().to_string())
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn make_start_buttons(db: &mut DB) -> BotResult<InlineKeyboardMarkup> {
|
|
let mut buttons: Vec<Vec<InlineKeyboardButton>> = Vec::new();
|
|
buttons.push(vec![
|
|
create_callback_button("show_projects", Callback::ProjectPage { id: 1 }, db).await?,
|
|
]);
|
|
buttons.push(vec![
|
|
create_callback_button("more_info", Callback::MoreInfo, db).await?,
|
|
]);
|
|
buttons.push(vec![
|
|
create_callback_button("leave_application", Callback::LeaveApplication, db).await?,
|
|
]);
|
|
buttons.push(vec![
|
|
create_callback_button("ask_question", Callback::AskQuestion, db).await?,
|
|
]);
|
|
|
|
Ok(InlineKeyboardMarkup::new(buttons))
|
|
}
|
|
|
|
async fn echo(bot: Bot, msg: Message) -> BotResult<()> {
|
|
if let Some(photo) = msg.photo() {
|
|
info!("File ID: {}", photo[0].file.id);
|
|
}
|
|
bot.send_message(msg.chat.id, msg.html_text().unwrap_or("UNWRAP".into()))
|
|
.parse_mode(teloxide::types::ParseMode::Html)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn update_user_tg(user: db::User, tguser: &teloxide::types::User) -> db::User {
|
|
db::User {
|
|
first_name: tguser.first_name.clone(),
|
|
last_name: tguser.last_name.clone(),
|
|
username: tguser.username.clone(),
|
|
language_code: tguser.language_code.clone(),
|
|
..user
|
|
}
|
|
}
|