Compare commits
48 Commits
4548419946
...
bee93b32d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bee93b32d1 | ||
|
|
0265942449 | ||
|
|
e993a6c941 | ||
|
|
6dfe9b839d | ||
|
|
f27fb670bd | ||
|
|
22025cde11 | ||
|
|
3bd16a58cd | ||
|
|
474795bd99 | ||
|
|
fdf1e352a6 | ||
|
|
39e376195c | ||
|
|
308b15ed69 | ||
|
|
102fae25c7 | ||
|
|
bc2397a32c | ||
|
|
fde7087172 | ||
|
|
67ad3c2acd | ||
|
|
866a028de1 | ||
|
|
e63187dcd3 | ||
|
|
098cff72bd | ||
|
|
d428c8798d | ||
|
|
6eb6f2f454 | ||
|
|
2fdd8a346d | ||
|
|
d10acc992a | ||
|
|
8e3c647727 | ||
|
|
8fed0daf4c | ||
|
|
e1b6b5aa10 | ||
|
|
4a35243a4c | ||
|
|
1757571f35 | ||
|
|
77ba6dcfc5 | ||
|
|
6ac3665dee | ||
|
|
ef5d74cf1c | ||
|
|
593316d541 | ||
|
|
a136558681 | ||
|
|
9e99064bc5 | ||
|
|
3acd168155 | ||
|
|
4be9c034c9 | ||
|
|
9bbf481002 | ||
|
|
4384431696 | ||
|
|
13a861e74b | ||
|
|
ff7f317ae5 | ||
|
|
aac968e408 | ||
|
|
cb7c888028 | ||
|
|
a33d4b393c | ||
|
|
9c15b0a375 | ||
|
|
4c149b6922 | ||
|
|
a7433cd8cc | ||
|
|
bdb30c8d98 | ||
|
|
d1c1b7500d | ||
|
|
d5dbaa0b75 |
8
Cargo.lock
generated
8
Cargo.lock
generated
@ -916,11 +916,13 @@ dependencies = [
|
||||
"envconfig",
|
||||
"futures",
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"mongodb",
|
||||
"pretty_env_logger",
|
||||
"quickjs-rusty",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"teloxide",
|
||||
"thiserror 2.0.12",
|
||||
"tokio",
|
||||
@ -1436,6 +1438,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.171"
|
||||
|
||||
@ -15,11 +15,13 @@ enum_stringify = "0.6.3"
|
||||
envconfig = "0.11.0"
|
||||
futures = "0.3.31"
|
||||
itertools = "0.14.0"
|
||||
lazy_static = "1.5.0"
|
||||
log = "0.4.27"
|
||||
mongodb = "3.2.3"
|
||||
pretty_env_logger = "0.5.0"
|
||||
quickjs-rusty = "0.9.0"
|
||||
serde = { version = "1.0.219", features = ["derive", "serde_derive"] }
|
||||
serde_json = "1.0.140"
|
||||
teloxide = { version = "0.14.0", features = ["macros", "postgres-storage-nativetls"] }
|
||||
thiserror = "2.0.12"
|
||||
tokio = { version = "1.44.1", features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
58
default_script.js
Normal file
58
default_script.js
Normal file
@ -0,0 +1,58 @@
|
||||
// db - is set globally
|
||||
|
||||
const dialog = {
|
||||
commands: {
|
||||
start: {
|
||||
buttons: start_buttons, // default is `null`
|
||||
state: "start"
|
||||
},
|
||||
},
|
||||
buttons: {
|
||||
more_info: {},
|
||||
},
|
||||
stateful_msg_handlers: {
|
||||
start: {}, // everything is by default, so just send message `start`
|
||||
enter_name: {
|
||||
// name of the handler function. This field has a side effect:
|
||||
// when is set, no automatic sending of message, should be sent
|
||||
// manually in handler
|
||||
handler: enter_name,
|
||||
state: "none"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function enter_name() {}
|
||||
|
||||
const fmt = (number) => number.toString().padStart(2, '0');
|
||||
|
||||
const formatDate = (date) => {
|
||||
const [h, m, d, M, y] = [
|
||||
date.getHours(),
|
||||
date.getMinutes(),
|
||||
date.getDate(),
|
||||
date.getMonth(),
|
||||
date.getFullYear()
|
||||
];
|
||||
return `${fmt(h)}:${fmt(m)} ${fmt(d)}-${fmt(M + 1)}-${y}`
|
||||
};
|
||||
|
||||
function start_buttons() {
|
||||
const now = new Date();
|
||||
const dateFormated = formatDate(now);
|
||||
|
||||
// const user = db.find_one("users", {id: 1});
|
||||
|
||||
return [
|
||||
// [{name: {name: user.first_name}, callback_name: "no"}],
|
||||
[{name: {name: dateFormated}, callback_name: "no"}],
|
||||
[{name: {name: "Hello!"}, callback_name: "no"}],
|
||||
]
|
||||
}
|
||||
|
||||
const config = {
|
||||
version: 1.1,
|
||||
}
|
||||
|
||||
const c = {config: config, dialog: dialog}
|
||||
c
|
||||
35
src/admin.rs
35
src/admin.rs
@ -5,7 +5,8 @@ use teloxide::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
db::{CallDB, DB},
|
||||
bot_manager::DEFAULT_SCRIPT,
|
||||
db::{bots::BotInstance, CallDB, DB},
|
||||
BotResult,
|
||||
};
|
||||
use crate::{BotDialogue, LogMsg, State};
|
||||
@ -41,6 +42,8 @@ pub enum AdminCommands {
|
||||
Users,
|
||||
/// Cancel current action and sets user state to default
|
||||
Cancel,
|
||||
/// Create new instance of telegram bot
|
||||
Deploy { token: String },
|
||||
}
|
||||
|
||||
pub async fn admin_command_handler(
|
||||
@ -156,6 +159,36 @@ pub async fn admin_command_handler(
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
AdminCommands::Deploy { token } => {
|
||||
let bot_instance = {
|
||||
let botnew = Bot::new(&token);
|
||||
let name = match botnew.get_me().await {
|
||||
Ok(me) => me.username().to_string(),
|
||||
Err(teloxide::RequestError::Api(teloxide::ApiError::InvalidToken)) => {
|
||||
bot.send_message(msg.chat.id, "Error: bot token is invalid")
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
|
||||
let bi =
|
||||
BotInstance::new(name.clone(), token.to_string(), DEFAULT_SCRIPT.to_string())
|
||||
.store(&mut db)
|
||||
.await?;
|
||||
|
||||
bi
|
||||
};
|
||||
|
||||
bot.send_message(
|
||||
msg.chat.id,
|
||||
format!("Deployed bot with name: {}", bot_instance.name),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
162
src/bot_handler.rs
Normal file
162
src/bot_handler.rs
Normal file
@ -0,0 +1,162 @@
|
||||
use log::info;
|
||||
use std::{
|
||||
str::FromStr,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
use teloxide::{
|
||||
dispatching::{dialogue::GetChatId, UpdateFilterExt},
|
||||
dptree::{self, Handler},
|
||||
prelude::DependencyMap,
|
||||
types::{CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, Update},
|
||||
Bot,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
botscript::{self, BotMessage, RunnerConfig},
|
||||
commands::BotCommand,
|
||||
db::{CallDB, DB},
|
||||
message_answerer::MessageAnswerer,
|
||||
update_user_tg, BotError, BotResult,
|
||||
};
|
||||
|
||||
pub type BotHandler =
|
||||
Handler<'static, DependencyMap, BotResult<()>, teloxide::dispatching::DpHandlerDescription>;
|
||||
|
||||
pub fn script_handler(rc: Arc<RwLock<RunnerConfig>>) -> BotHandler {
|
||||
let crc = rc.clone();
|
||||
dptree::entry()
|
||||
.branch(
|
||||
Update::filter_message()
|
||||
// check if message is command
|
||||
.filter_map(|m: Message| m.text().and_then(|t| BotCommand::from_str(t).ok()))
|
||||
// check if command is presented in config
|
||||
.filter_map(move |bc: BotCommand| {
|
||||
let rc = std::sync::Arc::clone(&rc);
|
||||
let command = bc.command();
|
||||
|
||||
let rc = rc.read().expect("RwLock lock on commands map failed");
|
||||
|
||||
rc.get_command_message(command)
|
||||
})
|
||||
.endpoint(handle_botmessage),
|
||||
)
|
||||
.branch(
|
||||
Update::filter_callback_query()
|
||||
.filter_map(move |q: CallbackQuery| {
|
||||
q.data.and_then(|data| {
|
||||
let rc = std::sync::Arc::clone(&crc);
|
||||
let rc = rc.read().expect("RwLock lock on commands map failed");
|
||||
|
||||
rc.get_callback_message(&data)
|
||||
})
|
||||
})
|
||||
.endpoint(handle_callback),
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle_botmessage(bot: Bot, mut db: DB, bm: BotMessage, msg: Message) -> BotResult<()> {
|
||||
info!("Eval BM: {:?}", bm);
|
||||
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?;
|
||||
|
||||
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 handle_callback(bot: Bot, mut db: DB, bm: BotMessage, q: CallbackQuery) -> BotResult<()> {
|
||||
info!("Eval BM: {:?}", bm);
|
||||
let tguser = q.from.clone();
|
||||
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?;
|
||||
|
||||
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 (chat_id, msg_id) = {
|
||||
let chat_id = match q.chat_id() {
|
||||
Some(chat_id) => chat_id.0,
|
||||
None => tguser.id.0 as i64,
|
||||
};
|
||||
|
||||
let msg_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),
|
||||
);
|
||||
|
||||
(chat_id, msg_id)
|
||||
};
|
||||
|
||||
let ma = MessageAnswerer::new(&bot, &mut db, chat_id);
|
||||
match bm.is_replace() {
|
||||
true => {
|
||||
match msg_id {
|
||||
Ok(msg_id) => {
|
||||
ma.replace_message(msg_id, literal, buttons).await?;
|
||||
}
|
||||
Err(err) => {
|
||||
ma.answer(literal, None, buttons).await?;
|
||||
}
|
||||
};
|
||||
}
|
||||
false => {
|
||||
ma.answer(literal, None, buttons).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
240
src/bot_manager.rs
Normal file
240
src/bot_manager.rs
Normal file
@ -0,0 +1,240 @@
|
||||
use std::{collections::HashMap, future::Future, sync::RwLock, thread::JoinHandle, time::Duration};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use log::{error, info};
|
||||
use teloxide::{
|
||||
dispatching::dialogue::serializer::Json,
|
||||
dptree,
|
||||
prelude::{Dispatcher, Requester},
|
||||
Bot,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
bot_handler::{script_handler, BotHandler},
|
||||
db::{bots::BotInstance, DbError, DB},
|
||||
mongodb_storage::MongodbStorage,
|
||||
BotController, BotError, BotResult,
|
||||
};
|
||||
|
||||
pub struct BotRunner {
|
||||
controller: BotController,
|
||||
info: BotInfo,
|
||||
thread: Option<JoinHandle<BotResult<()>>>,
|
||||
}
|
||||
|
||||
unsafe impl Sync for BotRunner {}
|
||||
unsafe impl Send for BotRunner {}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BotInfo {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref BOT_POOL: RwLock<HashMap<String, BotRunner>> = RwLock::new(HashMap::new());
|
||||
}
|
||||
|
||||
pub static DEFAULT_SCRIPT: &str =
|
||||
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/default_script.js"));
|
||||
|
||||
pub struct BotManager<BIG, HM, BIS, HI, FBIS, FHI>
|
||||
where
|
||||
BIG: FnMut() -> FBIS,
|
||||
FBIS: Future<Output = BIS>,
|
||||
BIS: Iterator<Item = BotInstance>,
|
||||
HM: FnMut(BotInstance) -> FHI,
|
||||
FHI: Future<Output = HI>,
|
||||
HI: Iterator<Item = BotHandler>,
|
||||
{
|
||||
bot_pool: HashMap<String, BotRunner>,
|
||||
bi_getter: BIG,
|
||||
h_mapper: HM,
|
||||
}
|
||||
|
||||
impl<BIG, HM, BIS, HI, FBIS, FHI> BotManager<BIG, HM, BIS, HI, FBIS, FHI>
|
||||
where
|
||||
BIG: FnMut() -> FBIS,
|
||||
FBIS: Future<Output = BIS>,
|
||||
BIS: Iterator<Item = BotInstance>,
|
||||
HM: FnMut(BotInstance) -> FHI,
|
||||
FHI: Future<Output = HI>,
|
||||
HI: Iterator<Item = BotHandler>,
|
||||
{
|
||||
/// bi_getter - fnmut that returns iterator over BotInstance
|
||||
/// h_map - fnmut that returns iterator over handlers by BotInstance
|
||||
pub fn with(bi_getter: BIG, h_mapper: HM) -> Self {
|
||||
Self {
|
||||
bot_pool: Default::default(),
|
||||
bi_getter,
|
||||
h_mapper,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn dispatch(mut self, db: &mut DB) -> ! {
|
||||
loop {
|
||||
'biter: for bi in (self.bi_getter)().await {
|
||||
// removing handler to force restart
|
||||
// TODO: wait till all updates are processed in bot
|
||||
// Temporarly disabling code, because it's free of js runtime
|
||||
// spreads panic
|
||||
if bi.restart_flag {
|
||||
info!(
|
||||
"Trying to restart bot `{}`, new script: {}",
|
||||
bi.name, bi.script
|
||||
);
|
||||
let runner = self.bot_pool.remove(&bi.name);
|
||||
};
|
||||
// start, if not started
|
||||
let mut bot_runner = match self.bot_pool.remove(&bi.name) {
|
||||
Some(br) => br,
|
||||
None => {
|
||||
let handlers = (self.h_mapper)(bi.clone()).await;
|
||||
info!("NEW INSTANCE: Starting new instance! bot name: {}", bi.name);
|
||||
self.start_bot(bi, db, handlers.collect()).await.unwrap();
|
||||
continue 'biter;
|
||||
}
|
||||
};
|
||||
|
||||
// checking if thread is not finished, otherwise clearing handler
|
||||
bot_runner.thread = match bot_runner.thread {
|
||||
Some(thread) => {
|
||||
if thread.is_finished() {
|
||||
let err = thread.join();
|
||||
error!("Thread bot `{}` finished with error: {:?}", bi.name, err);
|
||||
None
|
||||
} else {
|
||||
Some(thread)
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
// checking if thread is running, otherwise start thread
|
||||
bot_runner.thread = match bot_runner.thread {
|
||||
Some(thread) => Some(thread),
|
||||
None => {
|
||||
let handlers = (self.h_mapper)(bi.clone()).await;
|
||||
let handler =
|
||||
script_handler_gen(bot_runner.controller.clone(), handlers.collect())
|
||||
.await;
|
||||
Some(
|
||||
spawn_bot_thread(bot_runner.controller.clone(), db, handler)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
};
|
||||
self.bot_pool.insert(bi.name.clone(), bot_runner);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_bot(
|
||||
&mut self,
|
||||
bi: BotInstance,
|
||||
db: &mut DB,
|
||||
plug_handlers: Vec<BotHandler>,
|
||||
) -> BotResult<BotInfo> {
|
||||
let mut db = db.clone().with_name(bi.name.clone());
|
||||
let controller = BotController::with_db(db.clone(), &bi.token, &bi.script).await?;
|
||||
|
||||
let handler = script_handler_gen(controller.clone(), plug_handlers).await;
|
||||
|
||||
let thread = spawn_bot_thread(controller.clone(), &mut db, handler).await?;
|
||||
|
||||
let info = BotInfo {
|
||||
name: bi.name.clone(),
|
||||
};
|
||||
let runner = BotRunner {
|
||||
controller,
|
||||
info: info.clone(),
|
||||
thread: Some(thread),
|
||||
};
|
||||
|
||||
self.bot_pool.insert(bi.name.clone(), runner);
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
}
|
||||
|
||||
async fn script_handler_gen(c: BotController, plug_handlers: Vec<BotHandler>) -> BotHandler {
|
||||
let handler = script_handler(c.rc.clone());
|
||||
// each handler will be added to dptree::entry()
|
||||
let handler = plug_handlers
|
||||
.into_iter()
|
||||
// as well as the script handler at the end
|
||||
.chain(std::iter::once(handler))
|
||||
.fold(dptree::entry(), |h, plug| h.branch(plug));
|
||||
handler
|
||||
}
|
||||
|
||||
pub async fn start_bot(
|
||||
bi: BotInstance,
|
||||
db: &mut DB,
|
||||
plug_handlers: Vec<BotHandler>,
|
||||
) -> BotResult<BotInfo> {
|
||||
let mut db = db.clone().with_name(bi.name.clone());
|
||||
let controller = BotController::with_db(db.clone(), &bi.token, &bi.script).await?;
|
||||
|
||||
let handler = script_handler(controller.rc.clone());
|
||||
// each handler will be added to dptree::entry()
|
||||
let handler = plug_handlers
|
||||
.into_iter()
|
||||
// as well as the script handler at the end
|
||||
.chain(std::iter::once(handler))
|
||||
.fold(dptree::entry(), |h, plug| h.branch(plug));
|
||||
|
||||
let thread = spawn_bot_thread(controller.clone(), &mut db, handler).await?;
|
||||
|
||||
let info = BotInfo {
|
||||
name: bi.name.clone(),
|
||||
};
|
||||
let runner = BotRunner {
|
||||
controller,
|
||||
info: info.clone(),
|
||||
thread: Some(thread),
|
||||
};
|
||||
|
||||
BOT_POOL
|
||||
.write()
|
||||
.map_or_else(
|
||||
|err| {
|
||||
Err(BotError::RwLockError(format!(
|
||||
"Failed to lock BOT_POOL because previous thread paniced, err: {err}"
|
||||
)))
|
||||
},
|
||||
Ok,
|
||||
)?
|
||||
.insert(bi.name.clone(), runner);
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub async fn spawn_bot_thread(
|
||||
bc: BotController,
|
||||
db: &mut DB,
|
||||
handler: BotHandler,
|
||||
) -> BotResult<JoinHandle<BotResult<()>>> {
|
||||
let state_mgr = MongodbStorage::from_db(db, Json)
|
||||
.await
|
||||
.map_err(DbError::from)?;
|
||||
let thread = std::thread::spawn(move || -> BotResult<()> {
|
||||
let state_mgr = state_mgr;
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
rt.block_on(
|
||||
Dispatcher::builder(bc.bot, handler)
|
||||
.dependencies(dptree::deps![bc.db, state_mgr])
|
||||
.build()
|
||||
.dispatch(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
Ok(thread)
|
||||
}
|
||||
@ -1,8 +1,13 @@
|
||||
pub mod db;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use crate::db::raw_calls::RawCallError;
|
||||
use crate::db::{CallDB, DbError, DB};
|
||||
use crate::utils::parcelable::{ParcelType, Parcelable, ParcelableError, ParcelableResult};
|
||||
use db::attach_db_obj;
|
||||
use futures::future::join_all;
|
||||
use futures::lock::MutexGuard;
|
||||
use itertools::Itertools;
|
||||
use quickjs_rusty::serde::from_js;
|
||||
use quickjs_rusty::utils::create_empty_object;
|
||||
@ -32,6 +37,10 @@ pub enum ScriptError {
|
||||
DBError(#[from] DbError),
|
||||
#[error("error resolving data: {0:?}")]
|
||||
ResolveError(#[from] ResolveError),
|
||||
#[error("error while calling db from runtime: {0:?}")]
|
||||
RawCallError(#[from] RawCallError),
|
||||
#[error("error while locking mutex: {0:?}")]
|
||||
MutexError(String),
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
@ -249,7 +258,7 @@ fn print(s: String) {
|
||||
println!("{s}");
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct BotConfig {
|
||||
version: f64,
|
||||
}
|
||||
@ -470,6 +479,8 @@ pub struct Button {
|
||||
pub struct BotMessage {
|
||||
// buttons: Vec<Button>
|
||||
literal: Option<String>,
|
||||
#[serde(default)]
|
||||
replace: bool,
|
||||
buttons: Option<KeyboardDefinition>,
|
||||
state: Option<String>,
|
||||
|
||||
@ -483,6 +494,10 @@ impl BotMessage {
|
||||
..self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_replace(&self) -> bool {
|
||||
self.replace
|
||||
}
|
||||
}
|
||||
|
||||
impl BotMessage {
|
||||
@ -550,9 +565,10 @@ impl Parcelable<BotFunction> for BotMessage {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct BotDialog {
|
||||
pub commands: HashMap<String, BotMessage>,
|
||||
pub buttons: HashMap<String, BotMessage>,
|
||||
stateful_msg_handlers: HashMap<String, BotMessage>,
|
||||
}
|
||||
|
||||
@ -560,6 +576,7 @@ impl Parcelable<BotFunction> for BotDialog {
|
||||
fn get_field(&mut self, name: &str) -> Result<ParcelType<BotFunction>, ParcelableError> {
|
||||
match name {
|
||||
"commands" => Ok(ParcelType::Parcelable(&mut self.commands)),
|
||||
"buttons" => Ok(ParcelType::Parcelable(&mut self.buttons)),
|
||||
"stateful_msg_handlers" => Ok(ParcelType::Parcelable(&mut self.stateful_msg_handlers)),
|
||||
field => Err(ParcelableError::FieldError(format!(
|
||||
"tried to get field {field}, but this field does not exists or private"
|
||||
@ -568,7 +585,7 @@ impl Parcelable<BotFunction> for BotDialog {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct RunnerConfig {
|
||||
config: BotConfig,
|
||||
pub dialog: BotDialog,
|
||||
@ -581,6 +598,12 @@ impl RunnerConfig {
|
||||
|
||||
bm.map(|bm| bm.fill_literal(command.to_string()))
|
||||
}
|
||||
|
||||
pub fn get_callback_message(&self, callback: &str) -> Option<BotMessage> {
|
||||
let bm = self.dialog.buttons.get(callback).cloned();
|
||||
|
||||
bm.map(|bm| bm.fill_literal(callback.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Parcelable<BotFunction> for RunnerConfig {
|
||||
@ -594,8 +617,9 @@ impl Parcelable<BotFunction> for RunnerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Runner {
|
||||
context: Context,
|
||||
context: Arc<Mutex<Context>>,
|
||||
}
|
||||
|
||||
impl Runner {
|
||||
@ -608,11 +632,36 @@ impl Runner {
|
||||
None::<bool>
|
||||
})?;
|
||||
|
||||
Ok(Runner { context })
|
||||
Ok(Runner {
|
||||
context: Arc::new(Mutex::new(context)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn init_with_db(db: &mut DB) -> ScriptResult<Self> {
|
||||
let context = Context::new(None)?;
|
||||
let mut global = context.global()?;
|
||||
attach_db_obj(&context, &mut global, db)?;
|
||||
|
||||
context.add_callback("print", |a: String| {
|
||||
print(a);
|
||||
|
||||
None::<bool>
|
||||
})?;
|
||||
|
||||
Ok(Runner {
|
||||
context: Arc::new(Mutex::new(context)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_script(&self, content: &str) -> ScriptResult<JsValue> {
|
||||
let ctx = &self.context;
|
||||
let ctx = match self.context.lock() {
|
||||
Ok(ctx) => ctx,
|
||||
Err(err) => {
|
||||
return Err(ScriptError::MutexError(format!(
|
||||
"can't lock js Context mutex, err: {err}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let val = ctx.eval(content, false)?;
|
||||
|
||||
|
||||
50
src/botscript/db.rs
Normal file
50
src/botscript/db.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use std::sync::RwLock;
|
||||
|
||||
use quickjs_rusty::context::Context;
|
||||
|
||||
use quickjs_rusty::serde::{from_js, to_js};
|
||||
use quickjs_rusty::{utils::create_empty_object, OwnedJsObject, OwnedJsValue as JsValue};
|
||||
|
||||
use crate::db::raw_calls::RawCall;
|
||||
use crate::db::DB;
|
||||
|
||||
use super::ScriptError;
|
||||
|
||||
pub fn attach_db_obj(c: &Context, o: &mut OwnedJsObject, db: &DB) -> Result<(), ScriptError> {
|
||||
let dbobj = JsValue::new(o.context(), create_empty_object(o.context())?)
|
||||
.try_into_object()
|
||||
.expect("the created object was not an object :/");
|
||||
|
||||
let db: std::sync::Arc<RwLock<DB>> = std::sync::Arc::new(RwLock::new(db.clone()));
|
||||
let dbbox = Box::new(db);
|
||||
let db: &'static _ = Box::leak(dbbox);
|
||||
|
||||
let find_one = c.create_callback(
|
||||
|collection: String, q: OwnedJsObject| -> Result<_, ScriptError> {
|
||||
let query: serde_json::Value = match from_js(q.context(), &q) {
|
||||
Ok(q) => q,
|
||||
Err(_) => todo!(),
|
||||
};
|
||||
let db = db.clone();
|
||||
|
||||
let value = futures::executor::block_on(
|
||||
db.write()
|
||||
.expect("failed to gain write acces to db (probably RwLock is poisoned)")
|
||||
.find_one(&collection, query),
|
||||
)?;
|
||||
|
||||
let ret = match value {
|
||||
Some(v) => Some(to_js(q.context(), &v)?),
|
||||
None => None,
|
||||
};
|
||||
Ok(ret)
|
||||
},
|
||||
)?;
|
||||
let find_one = JsValue::from((unsafe { c.context_raw() }, find_one));
|
||||
|
||||
dbobj.set_property("find_one", find_one)?;
|
||||
|
||||
o.set_property("db", dbobj.into_value())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
93
src/db/bots.rs
Normal file
93
src/db/bots.rs
Normal file
@ -0,0 +1,93 @@
|
||||
use bson::doc;
|
||||
use bson::oid::ObjectId;
|
||||
use chrono::{DateTime, FixedOffset, Local};
|
||||
use futures::StreamExt;
|
||||
use futures::TryStreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::DbCollection;
|
||||
use super::DbResult;
|
||||
use crate::db::GetCollection;
|
||||
use crate::query_call_consume;
|
||||
use crate::CallDB;
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||
pub struct BotInstance {
|
||||
pub _id: bson::oid::ObjectId,
|
||||
pub name: String,
|
||||
pub token: String,
|
||||
pub script: String,
|
||||
pub restart_flag: bool,
|
||||
pub created_at: DateTime<FixedOffset>,
|
||||
}
|
||||
|
||||
impl DbCollection for BotInstance {
|
||||
const COLLECTION: &str = "bots";
|
||||
}
|
||||
|
||||
impl BotInstance {
|
||||
pub fn new(name: String, token: String, script: String) -> Self {
|
||||
Self {
|
||||
_id: Default::default(),
|
||||
name,
|
||||
token,
|
||||
script,
|
||||
restart_flag: false,
|
||||
created_at: Local::now().into(),
|
||||
}
|
||||
}
|
||||
|
||||
query_call_consume!(store, self, db, Self, {
|
||||
let bi = db.get_collection::<Self>().await;
|
||||
|
||||
bi.insert_one(&self).await?;
|
||||
|
||||
Ok(self)
|
||||
});
|
||||
|
||||
pub async fn get_all<D: CallDB>(db: &mut D) -> DbResult<Vec<Self>> {
|
||||
let bi = db.get_collection::<Self>().await;
|
||||
|
||||
Ok(bi.find(doc! {}).await?.try_collect().await?)
|
||||
}
|
||||
|
||||
pub async fn get_by_name<D: CallDB>(db: &mut D, name: &str) -> DbResult<Option<Self>> {
|
||||
let bi = db.get_collection::<Self>().await;
|
||||
|
||||
Ok(bi.find_one(doc! {"name": name}).await?)
|
||||
}
|
||||
|
||||
pub async fn restart_one<D: CallDB>(db: &mut D, name: &str, restart: bool) -> DbResult<()> {
|
||||
let bi = db.get_collection::<Self>().await;
|
||||
|
||||
bi.update_one(
|
||||
doc! {"name": name},
|
||||
doc! { "$set": { "restart_flag": restart } },
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn restart_all<D: CallDB>(db: &mut D, restart: bool) -> DbResult<()> {
|
||||
let bi = db.get_collection::<Self>().await;
|
||||
|
||||
bi.update_many(doc! {}, doc! { "$set": { "restart_flag": restart } })
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_script<D: CallDB>(db: &mut D, name: &str, script: &str) -> DbResult<()> {
|
||||
let bi = db.get_collection::<Self>().await;
|
||||
|
||||
bi.update_one(
|
||||
doc! {"name": name},
|
||||
doc! { "$set": {
|
||||
"script": script,
|
||||
"restart_flag": true,
|
||||
}
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
pub mod application;
|
||||
pub mod bots;
|
||||
pub mod callback_info;
|
||||
pub mod message_forward;
|
||||
pub mod raw_calls;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@ -11,7 +13,7 @@ use futures::stream::TryStreamExt;
|
||||
|
||||
use mongodb::options::IndexOptions;
|
||||
use mongodb::{bson::doc, options::ClientOptions, Client};
|
||||
use mongodb::{Database, IndexModel};
|
||||
use mongodb::{Collection, Database, IndexModel};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(EnumStringify)]
|
||||
@ -140,14 +142,15 @@ pub struct Media {
|
||||
#[derive(Clone)]
|
||||
pub struct DB {
|
||||
client: Client,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl DB {
|
||||
pub async fn new<S: Into<String>>(db_url: S) -> DbResult<Self> {
|
||||
pub async fn new<S: Into<String>>(db_url: S, name: String) -> DbResult<Self> {
|
||||
let options = ClientOptions::parse(db_url.into()).await?;
|
||||
let client = Client::with_options(options)?;
|
||||
|
||||
Ok(DB { client })
|
||||
Ok(DB { client, name })
|
||||
}
|
||||
|
||||
pub async fn migrate(&mut self) -> DbResult<()> {
|
||||
@ -184,18 +187,38 @@ impl DB {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn init<S: Into<String>>(db_url: S) -> DbResult<Self> {
|
||||
let mut db = Self::new(db_url).await?;
|
||||
pub async fn init<S: Into<String>>(db_url: S, name: String) -> DbResult<Self> {
|
||||
let mut db = Self::new(db_url, name).await?;
|
||||
db.migrate().await?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
pub fn with_name(self, name: String) -> Self {
|
||||
Self { name, ..self }
|
||||
}
|
||||
}
|
||||
|
||||
pub trait DbCollection {
|
||||
const COLLECTION: &str;
|
||||
}
|
||||
|
||||
pub trait GetCollection {
|
||||
async fn get_collection<C: DbCollection + Send + Sync>(&mut self) -> Collection<C>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CallDB for DB {
|
||||
async fn get_database(&mut self) -> Database {
|
||||
self.client.database("gongbot")
|
||||
self.client.database(&self.name)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: CallDB> GetCollection for T {
|
||||
async fn get_collection<C: DbCollection + Send + Sync>(&mut self) -> Collection<C> {
|
||||
self.get_database()
|
||||
.await
|
||||
.collection(<C as DbCollection>::COLLECTION)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
38
src/db/raw_calls.rs
Normal file
38
src/db/raw_calls.rs
Normal file
@ -0,0 +1,38 @@
|
||||
use mongodb::Database;
|
||||
|
||||
use super::CallDB;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum RawCallError {
|
||||
#[error("error while processing mongodb query: {0}")]
|
||||
MongodbError(#[from] mongodb::error::Error),
|
||||
#[error("error while buildint bson's query document: {0}")]
|
||||
DocumentError(#[from] mongodb::bson::extjson::de::Error),
|
||||
#[error("error when expected map: {0}")]
|
||||
NotAMapError(String),
|
||||
}
|
||||
pub type RawCallResult<T> = Result<T, RawCallError>;
|
||||
|
||||
pub trait RawCall {
|
||||
async fn get_database(&mut self) -> Database;
|
||||
async fn find_one(&mut self, collection: &str, query: Value) -> RawCallResult<Option<Value>> {
|
||||
let db = self.get_database().await;
|
||||
let value = db.collection::<Value>(collection);
|
||||
|
||||
let map = match query {
|
||||
Value::Object(map) => map,
|
||||
_ => return Err(RawCallError::NotAMapError("query is not a map".to_string())),
|
||||
};
|
||||
|
||||
let doc = map.try_into()?;
|
||||
let ret = value.find_one(doc).await?;
|
||||
Ok(ret)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: CallDB> RawCall for T {
|
||||
async fn get_database(&mut self) -> Database {
|
||||
CallDB::get_database(self).await
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,7 @@ async fn setup_db() -> DB {
|
||||
dotenvy::dotenv().unwrap();
|
||||
let db_url = std::env::var("DATABASE_URL").unwrap();
|
||||
|
||||
DB::new(db_url).await.unwrap()
|
||||
DB::new(db_url, "gongbot".to_string()).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
532
src/handlers/admin.rs
Normal file
532
src/handlers/admin.rs
Normal file
@ -0,0 +1,532 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use itertools::Itertools;
|
||||
use log::{info, warn};
|
||||
use std::time::Duration;
|
||||
use teloxide::dispatching::dialogue::serializer::Json;
|
||||
use teloxide::net::Download;
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::sugar::request::RequestReplyExt;
|
||||
use teloxide::types::{MediaKind, MessageId, MessageKind, ParseMode};
|
||||
use teloxide::utils::render::RenderMessageTextHelper;
|
||||
use teloxide::{dptree, types::Update};
|
||||
|
||||
use futures::StreamExt;
|
||||
|
||||
use crate::admin::{admin_command_handler, AdminCommands};
|
||||
use crate::bot_handler::BotHandler;
|
||||
use crate::db::bots::BotInstance;
|
||||
use crate::db::message_forward::MessageForward;
|
||||
use crate::db::{CallDB, DB};
|
||||
use crate::mongodb_storage::MongodbStorage;
|
||||
use crate::{BotDialogue, BotError, BotResult, CallbackStore, State};
|
||||
|
||||
pub fn admin_handler() -> BotHandler {
|
||||
dptree::entry()
|
||||
.branch(
|
||||
Update::filter_callback_query()
|
||||
.filter_async(async |q: CallbackQuery, mut db: DB| {
|
||||
let tguser = q.from.clone();
|
||||
let user = db
|
||||
.get_or_init_user(tguser.id.0 as i64, &tguser.first_name)
|
||||
.await;
|
||||
user.map(|u| u.is_admin).unwrap_or(false)
|
||||
})
|
||||
.enter_dialogue::<CallbackQuery, MongodbStorage<Json>, State>()
|
||||
.branch(dptree::case![State::EditButton].endpoint(button_edit_callback)),
|
||||
)
|
||||
.branch(command_handler())
|
||||
.branch(
|
||||
Update::filter_message()
|
||||
.filter_async(async |msg: Message, mut db: DB| {
|
||||
let tguser = match msg.from.clone() {
|
||||
Some(user) => user,
|
||||
None => return false, // do nothing, cause its not usecase of function
|
||||
};
|
||||
let user = db
|
||||
.get_or_init_user(tguser.id.0 as i64, &tguser.first_name)
|
||||
.await;
|
||||
user.map(|u| u.is_admin).unwrap_or(false)
|
||||
})
|
||||
.enter_dialogue::<Message, MongodbStorage<Json>, State>()
|
||||
.branch(
|
||||
Update::filter_message()
|
||||
.filter(|msg: Message| {
|
||||
msg.text().unwrap_or("").to_lowercase().as_str() == "edit"
|
||||
})
|
||||
.endpoint(edit_msg_cmd_handler),
|
||||
)
|
||||
.branch(
|
||||
Update::filter_message()
|
||||
.filter_map(|msg: Message| {
|
||||
let text = msg.caption().unwrap_or("");
|
||||
let mut parts = text.split_whitespace();
|
||||
let cmd = parts.next().unwrap_or("");
|
||||
let arg = parts.next().unwrap_or("");
|
||||
|
||||
match cmd.to_lowercase().as_str() == "/newscript" {
|
||||
true => Some(arg.to_string()),
|
||||
false => None,
|
||||
}
|
||||
})
|
||||
.endpoint(newscript_handler),
|
||||
)
|
||||
.branch(
|
||||
Update::filter_message()
|
||||
.filter(|msg: Message| msg.reply_to_message().is_some())
|
||||
.filter(|state: State| matches!(state, State::Start))
|
||||
.endpoint(support_reply_handler),
|
||||
)
|
||||
.branch(
|
||||
dptree::case![State::Edit {
|
||||
literal,
|
||||
variant,
|
||||
lang,
|
||||
is_caption_set
|
||||
}]
|
||||
.endpoint(edit_msg_handler),
|
||||
),
|
||||
)
|
||||
.branch(
|
||||
Update::filter_message()
|
||||
.enter_dialogue::<Message, MongodbStorage<Json>, State>()
|
||||
.branch(dptree::case![State::MessageForwardReply].endpoint(user_reply_to_support)),
|
||||
)
|
||||
}
|
||||
async fn newscript_handler(bot: Bot, mut db: DB, msg: Message, name: String) -> BotResult<()> {
|
||||
let script = match msg.kind {
|
||||
MessageKind::Common(message) => {
|
||||
match message.media_kind {
|
||||
MediaKind::Document(media_document) => {
|
||||
let doc = media_document.document;
|
||||
let file = bot.get_file(doc.file.id).await?;
|
||||
let mut stream = bot.download_file_stream(&file.path);
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
while let Some(bytes) = stream.next().await {
|
||||
let mut bytes = bytes.unwrap().to_vec();
|
||||
buf.append(&mut bytes);
|
||||
}
|
||||
let script = match String::from_utf8(buf) {
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
warn!("Failed to parse buf to string, err: {err}");
|
||||
bot.send_message(msg.chat.id, format!("Failed to Convert file to script: file is not UTF-8, err: {err}")).await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
script
|
||||
}
|
||||
_ => todo!(),
|
||||
}
|
||||
}
|
||||
_ => todo!(),
|
||||
};
|
||||
|
||||
match BotInstance::get_by_name(&mut db, &name).await? {
|
||||
Some(bi) => bi,
|
||||
None => {
|
||||
bot.send_message(
|
||||
msg.chat.id,
|
||||
format!("Failed to set script, possibly bots name is incorrent"),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
BotInstance::update_script(&mut db, &name, &script).await?;
|
||||
|
||||
bot.send_message(msg.chat.id, "New script is set!").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn button_edit_callback(
|
||||
bot: Bot,
|
||||
mut db: DB,
|
||||
dialogue: BotDialogue,
|
||||
q: CallbackQuery,
|
||||
) -> BotResult<()> {
|
||||
bot.answer_callback_query(&q.id).await?;
|
||||
|
||||
let id = match q.data {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
bot.send_message(q.from.id, "Not compatible callback to edit text on")
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let ci = match CallbackStore::get(&mut db, &id).await? {
|
||||
Some(ci) => ci,
|
||||
None => {
|
||||
bot.send_message(
|
||||
q.from.id,
|
||||
"Can't get button information. Maybe created not by this bot or message too old",
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let literal = match ci.literal {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
bot.send_message(
|
||||
q.from.id,
|
||||
"This button is not editable (probably text is generated)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let lang = "ru".to_string();
|
||||
dialogue
|
||||
.update(State::Edit {
|
||||
literal,
|
||||
variant: None,
|
||||
lang,
|
||||
is_caption_set: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
bot.send_message(q.from.id, "Send text of button").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command_handler() -> BotHandler {
|
||||
Update::filter_message()
|
||||
.filter_async(async |msg: Message, mut db: DB| {
|
||||
let tguser = match msg.from.clone() {
|
||||
Some(user) => user,
|
||||
None => return false, // do nothing, cause its not usecase of function
|
||||
};
|
||||
let user = db
|
||||
.get_or_init_user(tguser.id.0 as i64, &tguser.first_name)
|
||||
.await;
|
||||
user.map(|u| u.is_admin).unwrap_or(false)
|
||||
})
|
||||
.filter_command::<AdminCommands>()
|
||||
.enter_dialogue::<Message, MongodbStorage<Json>, State>()
|
||||
.endpoint(admin_command_handler)
|
||||
}
|
||||
|
||||
async fn edit_msg_cmd_handler(
|
||||
bot: Bot,
|
||||
mut db: DB,
|
||||
dialogue: BotDialogue,
|
||||
msg: Message,
|
||||
) -> BotResult<()> {
|
||||
match msg.reply_to_message() {
|
||||
Some(replied) => {
|
||||
let msgid = replied.id;
|
||||
// look for message in db and set text
|
||||
let literal = match db.get_message_literal(msg.chat.id.0, msgid.0).await? {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
bot.send_message(msg.chat.id, "No such message found to edit. Look if you replying bot's message and this message is supposed to be editable").await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// TODO: language selector will be implemented in future 😈
|
||||
let lang = "ru".to_string();
|
||||
dialogue
|
||||
.update(State::Edit {
|
||||
literal,
|
||||
variant: None,
|
||||
lang,
|
||||
is_caption_set: false,
|
||||
})
|
||||
.await?;
|
||||
bot.send_message(
|
||||
msg.chat.id,
|
||||
"Ok, now you have to send message text (formatting supported)\n\
|
||||
<b>Notice:</b> if this message supposed to replace message (tg shows them as edited) \
|
||||
or be raplaced, do NOT send message with multiple media, only single photo, video etc. \
|
||||
To get more information about why, see in /why_media_group",
|
||||
).parse_mode(ParseMode::Html)
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
bot.send_message(msg.chat.id, "You have to reply to message to edit it")
|
||||
.await?;
|
||||
}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn support_reply_handler(
|
||||
bot: Bot,
|
||||
mut db: DB,
|
||||
msg: Message,
|
||||
state_mgr: std::sync::Arc<MongodbStorage<Json>>,
|
||||
) -> BotResult<()> {
|
||||
use teloxide::utils::render::Renderer;
|
||||
|
||||
let rm = match msg.reply_to_message() {
|
||||
Some(rm) => rm,
|
||||
None => {
|
||||
return Err(BotError::BotLogicError(
|
||||
"support_reply_handler should not be called when no message is replied".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let (chat_id, message_id) = (rm.chat.id.0, rm.id.0);
|
||||
let mf = match MessageForward::get(&mut db, chat_id, message_id).await? {
|
||||
Some(mf) => mf,
|
||||
None => {
|
||||
bot.send_message(msg.chat.id, "No forwarded message found for your reply")
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let text = match msg.kind {
|
||||
MessageKind::Common(message_common) => match message_common.media_kind {
|
||||
MediaKind::Text(media_text) => {
|
||||
Renderer::new(&media_text.text, &media_text.entities).as_html()
|
||||
}
|
||||
_ => {
|
||||
bot.send_message(msg.chat.id, "Only text messages currently supported!")
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
// can't hapen because we already have check for reply
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
let msg = bot
|
||||
.send_message(ChatId(mf.source_chat_id), text)
|
||||
.parse_mode(ParseMode::Html);
|
||||
let msg = match mf.reply {
|
||||
false => msg,
|
||||
true => msg.reply_to(MessageId(mf.source_message_id)),
|
||||
};
|
||||
msg.await?;
|
||||
|
||||
let user_dialogue = BotDialogue::new(state_mgr, ChatId(mf.source_chat_id));
|
||||
user_dialogue.update(State::MessageForwardReply).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn edit_msg_handler(
|
||||
bot: Bot,
|
||||
mut db: DB,
|
||||
dialogue: BotDialogue,
|
||||
(literal, variant, lang, is_caption_set): (String, Option<String>, String, bool),
|
||||
msg: Message,
|
||||
) -> BotResult<()> {
|
||||
use teloxide::utils::render::Renderer;
|
||||
|
||||
let chat_id = msg.chat.id;
|
||||
info!("Type: {:#?}", msg.kind);
|
||||
let msg = if let MessageKind::Common(msg) = msg.kind {
|
||||
msg
|
||||
} else {
|
||||
info!("Not a Common, somehow");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Some(variant) = variant {
|
||||
if let MediaKind::Text(text) = msg.media_kind {
|
||||
let html_text = Renderer::new(&text.text, &text.entities).as_html();
|
||||
|
||||
db.set_literal_alternative(&literal, &variant, &html_text)
|
||||
.await?;
|
||||
bot.send_message(chat_id, "Updated text of variant!")
|
||||
.await?;
|
||||
|
||||
dialogue.exit().await?;
|
||||
return Ok(());
|
||||
} else {
|
||||
bot.send_message(
|
||||
chat_id,
|
||||
"On variants only text alternating supported. Try to send text only",
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
match msg.media_kind {
|
||||
MediaKind::Text(text) => {
|
||||
db.drop_media(&literal).await?;
|
||||
if is_caption_set {
|
||||
return Ok(());
|
||||
};
|
||||
let html_text = Renderer::new(&text.text, &text.entities).as_html();
|
||||
db.set_literal(&literal, &html_text).await?;
|
||||
bot.send_message(chat_id, "Updated text of message!")
|
||||
.await?;
|
||||
dialogue.exit().await?;
|
||||
}
|
||||
MediaKind::Photo(photo) => {
|
||||
let group = photo.media_group_id;
|
||||
if let Some(group) = group.clone() {
|
||||
db.drop_media_except(&literal, &group).await?;
|
||||
} else {
|
||||
db.drop_media(&literal).await?;
|
||||
}
|
||||
let file_id = photo.photo[0].file.id.clone();
|
||||
db.add_media(&literal, "photo", &file_id, group.as_deref())
|
||||
.await?;
|
||||
match photo.caption {
|
||||
Some(text) => {
|
||||
let html_text = Renderer::new(&text, &photo.caption_entities).as_html();
|
||||
db.set_literal(&literal, &html_text).await?;
|
||||
bot.send_message(chat_id, "Updated photo caption!").await?;
|
||||
}
|
||||
None => {
|
||||
// if it is a first message in group,
|
||||
// or just a photo without caption (unwrap_or case),
|
||||
// set text empty
|
||||
if !db
|
||||
.is_media_group_exists(group.as_deref().unwrap_or(""))
|
||||
.await?
|
||||
{
|
||||
db.set_literal(&literal, "").await?;
|
||||
bot.send_message(chat_id, "Set photo without caption")
|
||||
.await?;
|
||||
};
|
||||
}
|
||||
}
|
||||
// Some workaround because Telegram's group system
|
||||
// is not easily and obviously handled with this
|
||||
// code architecture, but probably there is a solution.
|
||||
//
|
||||
// So, this code will just wait for all media group
|
||||
// updates to be processed
|
||||
dialogue
|
||||
.update(State::Edit {
|
||||
literal,
|
||||
variant: None,
|
||||
lang,
|
||||
is_caption_set: true,
|
||||
})
|
||||
.await?;
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
dialogue.exit().await.unwrap_or(());
|
||||
});
|
||||
}
|
||||
MediaKind::Video(video) => {
|
||||
let group = video.media_group_id;
|
||||
if let Some(group) = group.clone() {
|
||||
db.drop_media_except(&literal, &group).await?;
|
||||
} else {
|
||||
db.drop_media(&literal).await?;
|
||||
}
|
||||
let file_id = video.video.file.id;
|
||||
db.add_media(&literal, "video", &file_id, group.as_deref())
|
||||
.await?;
|
||||
match video.caption {
|
||||
Some(text) => {
|
||||
let html_text = Renderer::new(&text, &video.caption_entities).as_html();
|
||||
db.set_literal(&literal, &html_text).await?;
|
||||
bot.send_message(chat_id, "Updated video caption!").await?;
|
||||
}
|
||||
None => {
|
||||
// if it is a first message in group,
|
||||
// or just a video without caption (unwrap_or case),
|
||||
// set text empty
|
||||
if !db
|
||||
.is_media_group_exists(group.as_deref().unwrap_or(""))
|
||||
.await?
|
||||
{
|
||||
db.set_literal(&literal, "").await?;
|
||||
bot.send_message(chat_id, "Set video without caption")
|
||||
.await?;
|
||||
};
|
||||
}
|
||||
}
|
||||
// Some workaround because Telegram's group system
|
||||
// is not easily and obviously handled with this
|
||||
// code architecture, but probably there is a solution.
|
||||
//
|
||||
// So, this code will just wait for all media group
|
||||
// updates to be processed
|
||||
dialogue
|
||||
.update(State::Edit {
|
||||
literal,
|
||||
variant: None,
|
||||
lang,
|
||||
is_caption_set: true,
|
||||
})
|
||||
.await?;
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
dialogue.exit().await.unwrap_or(());
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
bot.send_message(chat_id, "this type of message is not supported yet")
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn user_reply_to_support(bot: Bot, mut db: DB, msg: Message) -> BotResult<()> {
|
||||
let (source_chat_id, source_message_id) = (msg.chat.id.0, msg.id.0);
|
||||
let text = match msg.html_text() {
|
||||
Some(text) => text,
|
||||
// TODO: come up with better idea than just ignoring (say something to user)
|
||||
None => return Ok(()),
|
||||
};
|
||||
let scid =
|
||||
db.get_literal_value("support_chat_id")
|
||||
.await?
|
||||
.ok_or(BotError::AdminMisconfiguration(
|
||||
"support_chat_id is not set".to_string(),
|
||||
))?;
|
||||
let support_chat_id = match scid.parse::<i64>() {
|
||||
Ok(cid) => cid,
|
||||
Err(parseerr) => {
|
||||
return Err(BotError::BotLogicError(format!(
|
||||
"source_chat_id, got: {scid}, expected: i64, err: {parseerr}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let user = msg.from.ok_or(BotError::BotLogicError(
|
||||
"Unable to get user somehow:/".to_string(),
|
||||
))?;
|
||||
let parts = [
|
||||
Some(user.first_name),
|
||||
user.last_name,
|
||||
user.username.map(|un| format!("(@{un})")),
|
||||
];
|
||||
#[allow(unstable_name_collisions)]
|
||||
let userformat: String = parts
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.intersperse(" ".to_string())
|
||||
.collect();
|
||||
let msgtext = format!("From: {userformat}\nMessage:\n{text}");
|
||||
|
||||
// TODO: fix bug: parse mode's purpose is to display user-formated text in right way,
|
||||
// but there is a bug: user can inject html code with his first/last/user name
|
||||
// it's not harmful, only visible to support, but still need a fix
|
||||
let sentmsg = bot
|
||||
.send_message(ChatId(support_chat_id), msgtext)
|
||||
.parse_mode(ParseMode::Html)
|
||||
.await?;
|
||||
MessageForward::new(
|
||||
sentmsg.chat.id.0,
|
||||
sentmsg.id.0,
|
||||
source_chat_id,
|
||||
source_message_id,
|
||||
true,
|
||||
)
|
||||
.store(&mut db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
1
src/handlers/mod.rs
Normal file
1
src/handlers/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod admin;
|
||||
981
src/main.rs
981
src/main.rs
File diff suppressed because it is too large
Load Diff
302
src/message_answerer.rs
Normal file
302
src/message_answerer.rs
Normal file
@ -0,0 +1,302 @@
|
||||
use log::{info, warn};
|
||||
use teloxide::prelude::*;
|
||||
use teloxide::types::{
|
||||
InputFile, InputMedia, InputMediaPhoto, InputMediaVideo, MessageId, ParseMode,
|
||||
};
|
||||
use teloxide::{
|
||||
types::{ChatId, InlineKeyboardMarkup},
|
||||
Bot,
|
||||
};
|
||||
|
||||
use crate::db::Media;
|
||||
use crate::{
|
||||
db::{CallDB, DB},
|
||||
notify_admin, BotResult,
|
||||
};
|
||||
|
||||
macro_rules! send_media {
|
||||
($self:ident, $method:ident, $chat_id:expr, $file_id: expr, $text: expr, $keyboard: expr) => {{
|
||||
let msg = $self
|
||||
.bot
|
||||
.$method(ChatId($chat_id), InputFile::file_id($file_id.to_string()));
|
||||
let msg = match $text.as_str() {
|
||||
"" => msg,
|
||||
text => msg.caption(text),
|
||||
};
|
||||
let msg = match $keyboard {
|
||||
Some(kbd) => msg.reply_markup(kbd),
|
||||
None => msg,
|
||||
};
|
||||
let msg = msg.parse_mode(teloxide::types::ParseMode::Html);
|
||||
|
||||
let msg = msg.await?;
|
||||
Ok((msg.chat.id.0, msg.id.0))
|
||||
}};
|
||||
}
|
||||
|
||||
pub struct MessageAnswerer<'a> {
|
||||
bot: &'a Bot,
|
||||
chat_id: i64,
|
||||
db: &'a mut DB,
|
||||
}
|
||||
|
||||
impl<'a> MessageAnswerer<'a> {
|
||||
pub fn new(bot: &'a Bot, db: &'a mut DB, chat_id: i64) -> Self {
|
||||
Self { bot, chat_id, db }
|
||||
}
|
||||
|
||||
async fn get_text(
|
||||
&mut self,
|
||||
literal: &str,
|
||||
variant: Option<&str>,
|
||||
is_replace: bool,
|
||||
) -> BotResult<String> {
|
||||
let variant_text = match variant {
|
||||
Some(variant) => {
|
||||
let value = self
|
||||
.db
|
||||
.get_literal_alternative_value(literal, variant)
|
||||
.await?;
|
||||
if value.is_none() && !is_replace {
|
||||
notify_admin(&format!("variant {variant} for literal {literal} is not found! falling back to just literal")).await;
|
||||
}
|
||||
value
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let text = match variant_text {
|
||||
Some(text) => text,
|
||||
None => self
|
||||
.db
|
||||
.get_literal_value(literal)
|
||||
.await?
|
||||
.unwrap_or("Please, set content of this message".into()),
|
||||
};
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub async fn answer(
|
||||
mut self,
|
||||
literal: &str,
|
||||
variant: Option<&str>,
|
||||
keyboard: Option<InlineKeyboardMarkup>,
|
||||
) -> BotResult<(i64, i32)> {
|
||||
let text = self.get_text(literal, variant, false).await?;
|
||||
self.answer_inner(text, literal, variant, keyboard).await
|
||||
}
|
||||
|
||||
async fn answer_inner(
|
||||
mut self,
|
||||
text: String,
|
||||
literal: &str,
|
||||
variant: Option<&str>,
|
||||
keyboard: Option<InlineKeyboardMarkup>,
|
||||
) -> BotResult<(i64, i32)> {
|
||||
let media = self.db.get_media(literal).await?;
|
||||
let (chat_id, msg_id) = match media.len() {
|
||||
// just a text
|
||||
0 => self.send_message(text, keyboard).await?,
|
||||
// single media
|
||||
1 => self.send_media(&media[0], text, keyboard).await?,
|
||||
// >= 2, should use media group
|
||||
_ => self.send_media_group(media, text).await?,
|
||||
};
|
||||
self.store_message_info(msg_id, literal, variant).await?;
|
||||
Ok((chat_id, msg_id))
|
||||
}
|
||||
|
||||
pub async fn replace_message(
|
||||
mut self,
|
||||
message_id: i32,
|
||||
literal: &str,
|
||||
keyboard: Option<InlineKeyboardMarkup>,
|
||||
) -> BotResult<()> {
|
||||
let variant = self
|
||||
.db
|
||||
.get_message(self.chat_id, message_id)
|
||||
.await?
|
||||
.and_then(|m| m.variant);
|
||||
let text = self.get_text(literal, variant.as_deref(), true).await?;
|
||||
let media = self.db.get_media(literal).await?;
|
||||
let (chat_id, msg_id) = match media.len() {
|
||||
// just a text
|
||||
0 => {
|
||||
let msg =
|
||||
self.bot
|
||||
.edit_message_text(ChatId(self.chat_id), MessageId(message_id), &text);
|
||||
let msg = match keyboard {
|
||||
Some(ref kbd) => msg.reply_markup(kbd.clone()),
|
||||
None => msg,
|
||||
};
|
||||
let msg = msg.parse_mode(teloxide::types::ParseMode::Html);
|
||||
info!("ENTS: {:?}", msg.entities);
|
||||
let msg = match msg.await {
|
||||
Ok(msg) => msg,
|
||||
Err(teloxide::RequestError::Api(teloxide::ApiError::Unknown(errtext)))
|
||||
if errtext.as_str()
|
||||
== "Bad Request: there is no text in the message to edit" =>
|
||||
{
|
||||
// fallback to sending message
|
||||
warn!("Fallback into sending message instead of editing because it contains media");
|
||||
self.answer_inner(text, literal, variant.as_deref(), keyboard)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
(msg.chat.id.0, msg.id.0)
|
||||
}
|
||||
// single media
|
||||
1 => {
|
||||
let media = &media[0]; // safe, cause we just checked len
|
||||
let input_file = InputFile::file_id(media.file_id.to_string());
|
||||
let media = match media.media_type.as_str() {
|
||||
"photo" => InputMedia::Photo(teloxide::types::InputMediaPhoto::new(input_file)),
|
||||
"video" => InputMedia::Video(teloxide::types::InputMediaVideo::new(input_file)),
|
||||
_ => todo!(),
|
||||
};
|
||||
self.bot
|
||||
.edit_message_media(ChatId(self.chat_id), MessageId(message_id), media)
|
||||
.await?;
|
||||
|
||||
let msg = self
|
||||
.bot
|
||||
.edit_message_caption(ChatId(self.chat_id), MessageId(message_id));
|
||||
let msg = match text.as_str() {
|
||||
"" => msg,
|
||||
text => msg.caption(text),
|
||||
};
|
||||
let msg = match keyboard {
|
||||
Some(kbd) => msg.reply_markup(kbd),
|
||||
None => msg,
|
||||
};
|
||||
|
||||
let msg = msg.parse_mode(teloxide::types::ParseMode::Html);
|
||||
let msg = msg.await?;
|
||||
|
||||
(msg.chat.id.0, msg.id.0)
|
||||
}
|
||||
// >= 2, should use media group
|
||||
_ => {
|
||||
todo!();
|
||||
}
|
||||
};
|
||||
|
||||
self.store_message_info(msg_id, literal, variant.as_deref())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn store_message_info(
|
||||
&mut self,
|
||||
message_id: i32,
|
||||
literal: &str,
|
||||
variant: Option<&str>,
|
||||
) -> BotResult<()> {
|
||||
match variant {
|
||||
Some(variant) => {
|
||||
self.db
|
||||
.set_message_literal_variant(self.chat_id, message_id, literal, variant)
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
self.db
|
||||
.set_message_literal(self.chat_id, message_id, literal)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_message(
|
||||
&self,
|
||||
text: String,
|
||||
keyboard: Option<InlineKeyboardMarkup>,
|
||||
) -> BotResult<(i64, i32)> {
|
||||
let msg = self.bot.send_message(ChatId(self.chat_id), text);
|
||||
let msg = match keyboard {
|
||||
Some(kbd) => msg.reply_markup(kbd),
|
||||
None => msg,
|
||||
};
|
||||
let msg = msg.parse_mode(teloxide::types::ParseMode::Html);
|
||||
info!("ENTS: {:?}", msg.entities);
|
||||
let msg = msg.await?;
|
||||
|
||||
Ok((msg.chat.id.0, msg.id.0))
|
||||
}
|
||||
|
||||
async fn send_media(
|
||||
&self,
|
||||
media: &Media,
|
||||
text: String,
|
||||
keyboard: Option<InlineKeyboardMarkup>,
|
||||
) -> BotResult<(i64, i32)> {
|
||||
match media.media_type.as_str() {
|
||||
"photo" => {
|
||||
send_media!(
|
||||
self,
|
||||
send_photo,
|
||||
self.chat_id,
|
||||
media.file_id,
|
||||
text,
|
||||
keyboard
|
||||
)
|
||||
}
|
||||
"video" => {
|
||||
send_media!(
|
||||
self,
|
||||
send_video,
|
||||
self.chat_id,
|
||||
media.file_id,
|
||||
text,
|
||||
keyboard
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_media_group(&self, media: Vec<Media>, text: String) -> BotResult<(i64, i32)> {
|
||||
let media: Vec<InputMedia> = media
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, m)| {
|
||||
let ifile = InputFile::file_id(m.file_id);
|
||||
let caption = if i == 0 {
|
||||
match text.as_str() {
|
||||
"" => None,
|
||||
text => Some(text.to_string()),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match m.media_type.as_str() {
|
||||
"photo" => InputMedia::Photo(InputMediaPhoto {
|
||||
caption,
|
||||
parse_mode: Some(ParseMode::Html),
|
||||
..InputMediaPhoto::new(ifile)
|
||||
}),
|
||||
"video" => InputMedia::Video(InputMediaVideo {
|
||||
caption,
|
||||
parse_mode: Some(ParseMode::Html),
|
||||
..InputMediaVideo::new(ifile)
|
||||
}),
|
||||
_ => {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let msg = self.bot.send_media_group(ChatId(self.chat_id), media);
|
||||
|
||||
let msg = msg.await?;
|
||||
|
||||
Ok((msg[0].chat.id.0, msg[0].id.0))
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,8 @@ use mongodb::Database;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use teloxide::dispatching::dialogue::{Serializer, Storage};
|
||||
|
||||
use crate::db::{CallDB, DB};
|
||||
|
||||
pub struct MongodbStorage<S> {
|
||||
database: Database,
|
||||
serializer: S,
|
||||
@ -28,6 +30,13 @@ impl<S> MongodbStorage<S> {
|
||||
serializer,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn from_db(db: &mut DB, serializer: S) -> Result<Arc<Self>, mongodb::error::Error> {
|
||||
Ok(Arc::new(Self {
|
||||
database: CallDB::get_database(db).await,
|
||||
serializer,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user