Compare commits

...

48 Commits

Author SHA1 Message Date
Akulij
bee93b32d1 use BotController 2025-05-31 10:48:52 +05:00
Akulij
0265942449 enable Send for BotController 2025-05-31 10:48:15 +05:00
Akulij
e993a6c941 derive Clone on Config 2025-05-31 10:47:49 +05:00
Akulij
6dfe9b839d fix logic: move bot insertion to db to /deploy command, instead of function in bot_manager 2025-05-31 10:45:34 +05:00
Akulij
f27fb670bd create BotManager 2025-05-31 10:43:05 +05:00
Akulij
22025cde11 handle callbacks in script_handler 2025-05-31 10:35:52 +05:00
Akulij
3bd16a58cd add replace flag to BotMessage 2025-05-31 10:34:29 +05:00
Akulij
474795bd99 create RunnerConfig.get_callback_message 2025-05-31 10:34:06 +05:00
Akulij
fdf1e352a6 add buttons field to BotDialog 2025-05-31 10:33:26 +05:00
Akulij
39e376195c create BotInstance::update_script 2025-05-31 10:31:09 +05:00
Akulij
308b15ed69 create BotInstance::restart_one and restart_all 2025-05-31 10:30:39 +05:00
Akulij
102fae25c7 create BotInstance::get_by_name 2025-05-31 10:30:03 +05:00
Akulij
bc2397a32c derive Clone for BotInstance 2025-05-31 10:29:03 +05:00
Akulij
fde7087172 create /newscript command handler 2025-05-31 10:28:22 +05:00
Akulij
67ad3c2acd plug provided handlers in start_bot 2025-05-28 11:11:25 +05:00
Akulij
866a028de1 move out bot handlers to separate module 2025-05-28 11:10:32 +05:00
Akulij
e63187dcd3 rename botscript_command_handler to more correct handle_botmessage 2025-05-28 10:15:30 +05:00
Akulij
098cff72bd delete inspector in botscript handler 2025-05-28 10:14:14 +05:00
Akulij
d428c8798d make script_handler return a public type 2025-05-27 15:35:40 +05:00
Akulij
6eb6f2f454 fix: start bot with it's db name 2025-05-27 15:34:50 +05:00
Akulij
2fdd8a346d fix: instead of actually starting bot on /deploy, just put info in DB
reason: it's not a responsibility of /deploy command to store bot info
AND starting bot thread, isntead it is responsible only for storing info
in DB, as every other command does, and then bot will lazily start by
bot manager
2025-05-27 15:12:14 +05:00
Akulij
d10acc992a start bot instances in main 2025-05-27 14:21:36 +05:00
Akulij
8e3c647727 fix: handle io and RwLock error for bot manager 2025-05-27 14:20:02 +05:00
Akulij
8fed0daf4c extend BotController creation implementation 2025-05-27 14:17:29 +05:00
Akulij
e1b6b5aa10 derive Clone for BotController 2025-05-27 14:14:23 +05:00
Akulij
4a35243a4c store RunnerConfig in BotController as atomic rw lock for thread safety 2025-05-27 14:13:28 +05:00
Akulij
1757571f35 create bot_name field in Config 2025-05-27 14:11:33 +05:00
Akulij
77ba6dcfc5 define const MAIN_BOT_SCRIPT 2025-05-27 14:10:48 +05:00
Akulij
6ac3665dee fix: add bot_handler to compile tree 2025-05-27 04:17:26 +05:00
Akulij
ef5d74cf1c create bot_manager module to handle background bot start 2025-05-27 04:17:05 +05:00
Akulij
593316d541 cargo add lazy_static 2025-05-27 04:15:39 +05:00
Akulij
a136558681 create MongodbStorage::from_db initializer 2025-05-27 04:09:47 +05:00
Akulij
9e99064bc5 create DB.with_name method 2025-05-27 04:09:05 +05:00
Akulij
3acd168155 make Runner thread safe 2025-05-27 04:08:27 +05:00
Akulij
4be9c034c9 use variable db name instead of hardcoded one 2025-05-26 20:50:25 +05:00
Akulij
9bbf481002 derive Clone on bot and runner config 2025-05-26 20:48:11 +05:00
Akulij
4384431696 create RunnerConfig::init_with_db 2025-05-26 20:47:35 +05:00
Akulij
13a861e74b cargo add serde_json 2025-05-26 20:45:59 +05:00
Akulij
ff7f317ae5 create attach_db_obj for botscript 2025-05-26 20:44:34 +05:00
Akulij
aac968e408 add RawCallError to ScriptError 2025-05-26 20:43:04 +05:00
Akulij
cb7c888028 create RawCall trait, that will contain DB methods to call from script runtime 2025-05-26 20:22:46 +05:00
Akulij
a33d4b393c impl GetCollection for CallDB 2025-05-26 20:16:12 +05:00
Akulij
9c15b0a375 create DbCollection and GetCollection traits 2025-05-26 20:15:27 +05:00
Akulij
4c149b6922 create BotInstance collection 2025-05-26 20:12:46 +05:00
Akulij
a7433cd8cc create /deploy admin command 2025-05-26 20:10:12 +05:00
Akulij
bdb30c8d98 create default script for new bots 2025-05-26 20:08:55 +05:00
Akulij
d1c1b7500d create script_handler function that creates teloxide's handler for botscript dispatch 2025-05-26 20:06:07 +05:00
Akulij
d5dbaa0b75 refactor message answer and replace 2025-05-25 09:40:41 +05:00
17 changed files with 1725 additions and 884 deletions

8
Cargo.lock generated
View File

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

View File

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

View File

@ -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
View 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
View 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)
}

View File

@ -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
View 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
View 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(())
}
}

View File

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

View File

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

@ -0,0 +1 @@
pub mod admin;

File diff suppressed because it is too large Load Diff

302
src/message_answerer.rs Normal file
View 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))
}
}

View File

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