Merge pull request 'migration to JS engine' (#1) from dev into main
Some checks failed
Build && Deploy / cargo build (push) Failing after 1m17s

Reviewed-on: #1
This commit is contained in:
akulij 2025-05-31 08:49:52 +00:00
commit f7318f3661
25 changed files with 3159 additions and 848 deletions

View File

@ -1,5 +1,8 @@
name: Build && Deploy name: Build && Deploy
on: [push] on:
push:
branches:
- main
jobs: jobs:
build: build:

189
Cargo.lock generated
View File

@ -60,6 +60,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "anyhow"
version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]] [[package]]
name = "aquamarine" name = "aquamarine"
version = "0.6.0" version = "0.6.0"
@ -133,6 +139,26 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bindgen"
version = "0.71.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3"
dependencies = [
"bitflags 2.9.0",
"cexpr",
"clang-sys",
"itertools 0.10.5",
"log",
"prettyplease",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn 2.0.100",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@ -220,9 +246,20 @@ version = "1.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a" checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a"
dependencies = [ dependencies = [
"jobserver",
"libc",
"shlex", "shlex",
] ]
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.0" version = "1.0.0"
@ -265,6 +302,17 @@ dependencies = [
"phf_codegen", "phf_codegen",
] ]
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@ -309,6 +357,15 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "copy_dir"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "543d1dd138ef086e2ff05e3a48cf9da045da2033d16f8538fd76b86cd49b2ca3"
dependencies = [
"walkdir",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@ -840,6 +897,12 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "glob"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
[[package]] [[package]]
name = "gongbotrs" name = "gongbotrs"
version = "0.1.0" version = "0.1.0"
@ -853,10 +916,13 @@ dependencies = [
"envconfig", "envconfig",
"futures", "futures",
"itertools 0.14.0", "itertools 0.14.0",
"lazy_static",
"log", "log",
"mongodb", "mongodb",
"pretty_env_logger", "pretty_env_logger",
"quickjs-rusty",
"serde", "serde",
"serde_json",
"teloxide", "teloxide",
"thiserror 2.0.12", "thiserror 2.0.12",
"tokio", "tokio",
@ -1352,6 +1418,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "jobserver"
version = "0.1.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
dependencies = [
"getrandom 0.3.2",
"libc",
]
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.77" version = "0.3.77"
@ -1362,12 +1438,39 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.171" version = "0.2.171"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"
[[package]]
name = "libloading"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
dependencies = [
"cfg-if",
"windows-targets 0.52.6",
]
[[package]]
name = "libquickjs-ng-sys"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c98c1ad542ec61348faba7ce5386fef9060e35fbeea19dda64ce41862084e0a"
dependencies = [
"bindgen",
"cc",
"copy_dir",
]
[[package]] [[package]]
name = "libsqlite3-sys" name = "libsqlite3-sys"
version = "0.30.1" version = "0.30.1"
@ -1502,6 +1605,12 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.5" version = "0.8.5"
@ -1600,12 +1709,41 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@ -1828,6 +1966,16 @@ dependencies = [
"log", "log",
] ]
[[package]]
name = "prettyplease"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "664ec5419c51e34154eec046ebcba56312d5a2fc3b09a06da188e1ad21afadf6"
dependencies = [
"proc-macro2",
"syn 2.0.100",
]
[[package]] [[package]]
name = "proc-macro-error-attr2" name = "proc-macro-error-attr2"
version = "2.0.0" version = "2.0.0"
@ -1858,6 +2006,22 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "quickjs-rusty"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3b4d659d1bc37e9112a14ad9a7727d182b0fb12216eb6684bdbada3e9991a22"
dependencies = [
"anyhow",
"chrono",
"libquickjs-ng-sys",
"log",
"num-bigint",
"num-traits",
"serde",
"thiserror 2.0.12",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.40" version = "1.0.40"
@ -2067,6 +2231,12 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@ -2157,6 +2327,15 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "schannel" name = "schannel"
version = "0.1.27" version = "0.1.27"
@ -3059,6 +3238,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]] [[package]]
name = "want" name = "want"
version = "0.3.1" version = "0.3.1"

View File

@ -15,10 +15,13 @@ enum_stringify = "0.6.3"
envconfig = "0.11.0" envconfig = "0.11.0"
futures = "0.3.31" futures = "0.3.31"
itertools = "0.14.0" itertools = "0.14.0"
lazy_static = "1.5.0"
log = "0.4.27" log = "0.4.27"
mongodb = "3.2.3" mongodb = "3.2.3"
pretty_env_logger = "0.5.0" pretty_env_logger = "0.5.0"
quickjs-rusty = "0.9.0"
serde = { version = "1.0.219", features = ["derive", "serde_derive"] } serde = { version = "1.0.219", features = ["derive", "serde_derive"] }
serde_json = "1.0.140"
teloxide = { version = "0.14.0", features = ["macros", "postgres-storage-nativetls"] } teloxide = { version = "0.14.0", features = ["macros", "postgres-storage-nativetls"] }
thiserror = "2.0.12" thiserror = "2.0.12"
tokio = { version = "1.44.1", features = ["rt-multi-thread", "macros"] } 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

82
mainbot.js Normal file
View File

@ -0,0 +1,82 @@
// db - is set globally
const PROJECTS_COUNT = 2
const start_msg = {
buttons: [
[{ name: { literal: "show_projects" }, callback_name: "project_0" }],
[{ name: { literal: "more_info_btn" }, callback_name: "more_info" }],
[{ name: { literal: "leave_application" }, callback_name: "leave_application" }],
[{ name: { literal: "ask_question_btn" }, callback_name: "ask_question" }],
], // default is `null`
replace: true,
state: "start"
};
const dialog = {
commands: {
start: start_msg,
},
buttons: {
more_info: {
buttons: [
[{ name: { name: "На главную" }, callback_name: "start" }],
]
},
start: start_msg,
leave_application: {
handler: leave_application
},
ask_question: {}
},
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 leave_application(user) {
print(JSON.stringify(user))
user_application(user)
return false
}
function enter_name() { }
const fmt = (number) => number.toString().padStart(2, '0');
function add_project_callbacks(point) {
for (const i of Array(PROJECTS_COUNT).keys()) {
buttons = [
[],
[{ name: { name: "На главную" }, callback_name: "start" }]
]
if (i > 0) {
buttons[0].push({ name: { literal: "prev_project" }, callback_name: `project_${i - 1}` })
}
if (i < PROJECTS_COUNT - 1) {
buttons[0].push({ name: { literal: "next_project" }, callback_name: `project_${i + 1}` })
}
point[`project_${i}`] = {
replace: true,
buttons: buttons
}
}
}
add_project_callbacks(dialog.buttons)
print(JSON.stringify(dialog.buttons))
const config = {
version: 1.1,
}
// {config, dialog}
const c = { config: config, dialog: dialog }
c

View File

@ -5,7 +5,8 @@ use teloxide::{
}; };
use crate::{ use crate::{
db::{CallDB, DB}, bot_manager::DEFAULT_SCRIPT,
db::{bots::BotInstance, CallDB, DB},
BotResult, BotResult,
}; };
use crate::{BotDialogue, LogMsg, State}; use crate::{BotDialogue, LogMsg, State};
@ -41,6 +42,8 @@ pub enum AdminCommands {
Users, Users,
/// Cancel current action and sets user state to default /// Cancel current action and sets user state to default
Cancel, Cancel,
/// Create new instance of telegram bot
Deploy { token: String },
} }
pub async fn admin_command_handler( pub async fn admin_command_handler(
@ -156,6 +159,36 @@ pub async fn admin_command_handler(
.await?; .await?;
Ok(()) 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(())
}
} }
} }

229
src/bot_handler.rs Normal file
View File

@ -0,0 +1,229 @@
use log::{error, info};
use quickjs_rusty::serde::to_js;
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 is_propagate: bool = match bm.get_handler() {
Some(handler) => 'prop: {
let ctx = match handler.context() {
Some(ctx) => ctx,
// falling back to propagation
None => break 'prop true,
};
let jsuser = to_js(ctx, &tguser).unwrap();
match handler.call_args(vec![jsuser]) {
Ok(v) => {
if v.is_bool() {
v.to_bool().unwrap_or(true)
} else if v.is_int() {
v.to_int().unwrap_or(1) != 0
} else {
// falling back to propagation
true
}
}
Err(err) => {
error!("Failed to get return of handler, err: {err}");
// falling back to propagation
true
}
}
}
None => true,
};
if !is_propagate {
return Ok(());
}
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 is_propagate: bool = match bm.get_handler() {
Some(handler) => 'prop: {
let ctx = match handler.context() {
Some(ctx) => ctx,
// falling back to propagation
None => break 'prop true,
};
let jsuser = to_js(ctx, &tguser).unwrap();
match handler.call_args(vec![jsuser]) {
Ok(v) => {
if v.is_bool() {
v.to_bool().unwrap_or(true)
} else if v.is_int() {
v.to_int().unwrap_or(1) != 0
} else {
// falling back to propagation
true
}
}
Err(err) => {
error!("Failed to get return of handler, err: {err}");
// falling back to propagation
true
}
}
}
None => true,
};
if !is_propagate {
return Ok(());
}
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)
}

788
src/botscript.rs Normal file
View File

@ -0,0 +1,788 @@
pub mod application;
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;
use quickjs_rusty::utils::create_string;
use quickjs_rusty::ContextError;
use quickjs_rusty::ExecutionError;
use quickjs_rusty::JsFunction;
use quickjs_rusty::OwnedJsValue as JsValue;
use quickjs_rusty::ValueError;
use quickjs_rusty::{Context, OwnedJsObject};
use serde::Deserialize;
use serde::Serialize;
#[derive(thiserror::Error, Debug)]
pub enum ScriptError {
#[error("error context: {0:?}")]
ContextError(#[from] ContextError),
#[error("error running: {0:?}")]
ExecutionError(#[from] ExecutionError),
#[error("error from anyhow: {0:?}")]
SerdeError(#[from] quickjs_rusty::serde::Error),
#[error("error value: {0:?}")]
ValueError(#[from] ValueError),
#[error("error bot function execution: {0:?}")]
BotFunctionError(String),
#[error("error from DB: {0:?}")]
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)]
pub enum ResolveError {
#[error("wrong literal: {0:?}")]
IncorrectLiteral(String),
}
pub type ScriptResult<T> = Result<T, ScriptError>;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct BotFunction {
func: FunctionMarker,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(untagged)]
pub enum FunctionMarker {
/// serde is not able to (de)serialize this, so ignore it and fill
/// in runtime with injection in DeserializeJS
#[serde(skip)]
Function(JsFunction),
StrTemplate(String),
}
impl FunctionMarker {
pub fn as_str_template(&self) -> Option<&String> {
if let Self::StrTemplate(v) = self {
Some(v)
} else {
None
}
}
pub fn as_function(&self) -> Option<&JsFunction> {
if let Self::Function(v) = self {
Some(v)
} else {
None
}
}
pub fn set_js_function(&mut self, f: JsFunction) {
*self = Self::Function(f)
}
}
impl Parcelable<Self> for BotFunction {
fn get_field(
&mut self,
_name: &str,
) -> crate::utils::parcelable::ParcelableResult<ParcelType<Self>> {
todo!()
}
fn resolve(&mut self) -> ParcelableResult<ParcelType<Self>>
where
Self: Sized + 'static,
{
Ok(ParcelType::Function(self))
}
}
impl BotFunction {
pub fn by_name(name: String) -> Self {
Self {
func: FunctionMarker::StrTemplate(name),
}
}
pub fn call_context(&self, runner: &Runner) -> ScriptResult<JsValue> {
match &self.func {
FunctionMarker::Function(f) => {
let val = f.call(Default::default())?;
Ok(val)
}
FunctionMarker::StrTemplate(func_name) => runner.run_script(&format!("{func_name}()")),
}
}
pub fn context(&self) -> Option<*mut quickjs_rusty::JSContext> {
match &self.func {
FunctionMarker::Function(js_function) => Some(js_function.context()),
FunctionMarker::StrTemplate(_) => None,
}
}
pub fn call(&self) -> ScriptResult<JsValue> {
self.call_args(Default::default())
}
pub fn call_args(&self, args: Vec<JsValue>) -> ScriptResult<JsValue> {
if let FunctionMarker::Function(f) = &self.func {
let val = f.call(args)?;
Ok(val)
} else {
Err(ScriptError::BotFunctionError(
"Js Function is not defined".to_string(),
))
}
}
pub fn set_js_function(&mut self, f: JsFunction) {
self.func.set_js_function(f);
}
}
pub trait DeserializeJS {
fn js_into<'a, T: Deserialize<'a>>(&'a self) -> ScriptResult<T>;
}
impl DeserializeJS for JsValue {
fn js_into<'a, T: Deserialize<'a>>(&'a self) -> ScriptResult<T> {
let rc = from_js(self.context(), self)?;
Ok(rc)
}
}
#[derive(Default)]
pub struct DeserializerJS {
fn_map: HashMap<String, JsFunction>,
}
impl DeserializerJS {
pub fn new() -> Self {
Self {
fn_map: HashMap::new(),
}
}
pub fn deserialize_js<'a, T: Deserialize<'a> + Parcelable<BotFunction> + 'static>(
value: &'a JsValue,
) -> ScriptResult<T> {
let mut s = Self::new();
s.inject_templates(value, "".to_string())?;
let mut res = value.js_into()?;
for (k, jsf) in s.fn_map {
let item: ParcelType<'_, BotFunction> =
match Parcelable::<BotFunction>::get_nested(&mut res, &k) {
Ok(item) => item,
Err(err) => {
log::error!("Failed to inject original functions to structs, error: {err}");
continue;
}
};
if let ParcelType::Function(f) = item {
f.set_js_function(jsf);
}
}
Ok(res)
}
pub fn inject_templates(
&mut self,
value: &JsValue,
path: String,
) -> ScriptResult<Option<String>> {
if let Ok(f) = value.clone().try_into_function() {
self.fn_map.insert(path.clone(), f);
return Ok(Some(path));
} else if let Ok(o) = value.clone().try_into_object() {
let path = if path.is_empty() { path } else { path + "." }; // trying to avoid . in the start
// of stringified path
let res = o
.properties_iter()?
.chunks(2)
.into_iter()
// since chunks(2) is used and properties iterator over object
// always has even elements, unwrap will not fail
.map(
#[allow(clippy::unwrap_used)]
|mut chunk| (chunk.next().unwrap(), chunk.next().unwrap()),
)
.map(|(k, p)| k.and_then(|k| p.map(|p| (k, p))))
.filter_map(|m| m.ok())
.try_for_each(|(k, p)| {
let k = match k.to_string() {
Ok(k) => k,
Err(err) => return Err(ScriptError::ValueError(err)),
};
let res = match self.inject_templates(&p, path.clone() + &k)? {
Some(_) => {
let fo = JsValue::new(
o.context(),
create_empty_object(o.context()).expect("couldn't create object"),
)
.try_into_object()
.expect("the object created was not an object :/");
fo.set_property(
"func",
JsValue::new(
o.context(),
create_string(o.context(), "somefunc")
.expect("couldn't create string"),
),
)
.expect("wasn't able to set property on object :/");
o.set_property(&k, fo.into_value())
}
None => Ok(()),
};
match res {
Ok(res) => Ok(res),
Err(err) => Err(ScriptError::ExecutionError(err)),
}
});
res?;
};
Ok(None)
}
}
// TODO: remove this function since it is suitable only for early development
#[allow(clippy::print_stdout)]
fn print(s: String) {
println!("{s}");
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct BotConfig {
version: f64,
}
pub trait ResolveValue {
type Value;
fn resolve(self) -> ScriptResult<Self::Value>;
fn resolve_with(self, runner: &Runner) -> ScriptResult<Self::Value>;
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(untagged)]
pub enum KeyboardDefinition {
Rows(Vec<RowDefinition>),
Function(BotFunction),
}
impl Parcelable<BotFunction> for KeyboardDefinition {
fn get_field(&mut self, _name: &str) -> ParcelableResult<ParcelType<BotFunction>> {
todo!()
}
fn resolve(&mut self) -> ParcelableResult<ParcelType<BotFunction>>
where
Self: Sized + 'static,
{
match self {
KeyboardDefinition::Rows(rows) => Ok(rows.resolve()?),
KeyboardDefinition::Function(f) => Ok(f.resolve()?),
}
}
}
impl ResolveValue for KeyboardDefinition {
type Value = Vec<<RowDefinition as ResolveValue>::Value>;
fn resolve(self) -> ScriptResult<Self::Value> {
match self {
KeyboardDefinition::Rows(rows) => rows.into_iter().map(|r| r.resolve()).collect(),
KeyboardDefinition::Function(f) => {
<Self as ResolveValue>::resolve(f.call()?.js_into()?)
}
}
}
fn resolve_with(self, runner: &Runner) -> ScriptResult<Self::Value> {
match self {
KeyboardDefinition::Rows(rows) => {
rows.into_iter().map(|r| r.resolve_with(runner)).collect()
}
KeyboardDefinition::Function(f) => {
<Self as ResolveValue>::resolve_with(f.call_context(runner)?.js_into()?, runner)
}
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(untagged)]
pub enum RowDefinition {
Buttons(Vec<ButtonDefinition>),
Function(BotFunction),
}
impl Parcelable<BotFunction> for RowDefinition {
fn get_field(&mut self, _name: &str) -> ParcelableResult<ParcelType<BotFunction>> {
todo!()
}
fn resolve(&mut self) -> ParcelableResult<ParcelType<BotFunction>>
where
Self: Sized + 'static,
{
match self {
Self::Buttons(buttons) => Ok(buttons.resolve()?),
Self::Function(f) => Ok(f.resolve()?),
}
}
}
impl ResolveValue for RowDefinition {
type Value = Vec<<ButtonDefinition as ResolveValue>::Value>;
fn resolve(self) -> ScriptResult<Self::Value> {
match self {
RowDefinition::Buttons(buttons) => buttons.into_iter().map(|b| b.resolve()).collect(),
RowDefinition::Function(f) => <Self as ResolveValue>::resolve(f.call()?.js_into()?),
}
}
fn resolve_with(self, runner: &Runner) -> ScriptResult<Self::Value> {
match self {
RowDefinition::Buttons(buttons) => buttons
.into_iter()
.map(|b| b.resolve_with(runner))
.collect(),
RowDefinition::Function(f) => {
<Self as ResolveValue>::resolve_with(f.call_context(runner)?.js_into()?, runner)
}
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(untagged)]
pub enum ButtonDefinition {
Button(ButtonRaw),
ButtonLiteral(String),
Function(BotFunction),
}
impl ResolveValue for ButtonDefinition {
type Value = ButtonRaw;
fn resolve(self) -> ScriptResult<Self::Value> {
match self {
ButtonDefinition::Button(button) => Ok(button),
ButtonDefinition::ButtonLiteral(l) => Ok(ButtonRaw::from_literal(l)),
ButtonDefinition::Function(f) => <Self as ResolveValue>::resolve(f.call()?.js_into()?),
}
}
fn resolve_with(self, runner: &Runner) -> ScriptResult<Self::Value> {
match self {
ButtonDefinition::Button(button) => Ok(button),
ButtonDefinition::ButtonLiteral(l) => Ok(ButtonRaw::from_literal(l)),
ButtonDefinition::Function(f) => {
<Self as ResolveValue>::resolve_with(f.call_context(runner)?.js_into()?, runner)
}
}
}
}
impl Parcelable<BotFunction> for ButtonDefinition {
fn get_field(&mut self, _name: &str) -> ParcelableResult<ParcelType<BotFunction>> {
todo!()
}
fn resolve(&mut self) -> ParcelableResult<ParcelType<BotFunction>>
where
Self: Sized + 'static,
{
match self {
Self::Button(braw) => Ok(braw.resolve()?),
Self::ButtonLiteral(s) => Ok(s.resolve()?),
Self::Function(f) => Ok(f.resolve()?),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ButtonRaw {
name: ButtonName,
callback_name: String,
}
impl<F> Parcelable<F> for ButtonRaw {
fn get_field(&mut self, _name: &str) -> ParcelableResult<ParcelType<F>> {
todo!()
}
}
impl ButtonRaw {
pub fn from_literal(literal: String) -> Self {
ButtonRaw {
name: ButtonName::Literal {
literal: literal.clone(),
},
callback_name: literal,
}
}
pub fn name(&self) -> &ButtonName {
&self.name
}
pub fn callback_name(&self) -> &str {
&self.callback_name
}
pub fn literal(&self) -> Option<String> {
match self.name() {
ButtonName::Value { .. } => None,
ButtonName::Literal { literal } => Some(literal.to_string()),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(untagged)]
pub enum ButtonName {
Value { name: String },
Literal { literal: String },
}
impl ButtonName {
pub async fn resolve_name(self, db: &mut DB) -> ScriptResult<String> {
match self {
ButtonName::Value { name } => Ok(name),
ButtonName::Literal { literal } => {
let value = db.get_literal_value(&literal).await?;
Ok(match value {
Some(value) => Ok(value),
None => Err(ResolveError::IncorrectLiteral(format!(
"not found literal `{literal}` in DB"
))),
}?)
}
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Button {
name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct BotMessage {
// buttons: Vec<Button>
literal: Option<String>,
#[serde(default)]
replace: bool,
buttons: Option<KeyboardDefinition>,
state: Option<String>,
handler: Option<BotFunction>,
}
impl BotMessage {
pub fn fill_literal(&self, l: String) -> Self {
BotMessage {
literal: self.clone().literal.or(Some(l)),
..self.clone()
}
}
pub fn is_replace(&self) -> bool {
self.replace
}
pub fn get_handler(&self) -> Option<&BotFunction> {
self.handler.as_ref()
}
}
impl BotMessage {
pub async fn resolve_buttons(
&self,
db: &mut DB,
) -> ScriptResult<Option<Vec<Vec<ButtonLayout>>>> {
let raw_buttons = self.buttons.clone().map(|b| b.resolve()).transpose()?;
match raw_buttons {
Some(braws) => {
let kbd: Vec<Vec<_>> = join_all(braws.into_iter().map(|rows| async {
join_all(rows.into_iter().map(|b| async {
let mut db = db.clone();
ButtonLayout::resolve_raw(b, &mut db).await
}))
.await
.into_iter()
.collect()
}))
.await
.into_iter()
.collect::<Result<_, _>>()?;
Ok(Some(kbd))
}
None => Ok(None),
}
}
pub fn literal(&self) -> Option<&String> {
self.literal.as_ref()
}
}
pub enum ButtonLayout {
Callback {
name: String,
literal: Option<String>,
callback: String,
},
}
impl ButtonLayout {
pub async fn resolve_raw(braw: ButtonRaw, db: &mut DB) -> ScriptResult<Self> {
let name = braw.name().clone().resolve_name(db).await?;
let literal = braw.literal();
let callback = braw.callback_name().to_string();
Ok(Self::Callback {
name,
literal,
callback,
})
}
}
impl Parcelable<BotFunction> for BotMessage {
fn get_field(&mut self, name: &str) -> ParcelableResult<ParcelType<BotFunction>> {
match name {
"buttons" => Ok(self.buttons.resolve()?),
"state" => Ok(self.state.resolve()?),
"handler" => Ok(self.handler.resolve()?),
field => Err(ParcelableError::FieldError(format!(
"tried to get field {field}, but this field does not exists or private"
))),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct BotDialog {
pub commands: HashMap<String, BotMessage>,
pub buttons: HashMap<String, BotMessage>,
stateful_msg_handlers: HashMap<String, BotMessage>,
}
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"
))),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct RunnerConfig {
config: BotConfig,
pub dialog: BotDialog,
}
impl RunnerConfig {
/// command without starting `/`
pub fn get_command_message(&self, command: &str) -> Option<BotMessage> {
let bm = self.dialog.commands.get(command).cloned();
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 {
fn get_field(&mut self, name: &str) -> Result<ParcelType<BotFunction>, ParcelableError> {
match name {
"dialog" => Ok(ParcelType::Parcelable(&mut self.dialog)),
field => Err(ParcelableError::FieldError(format!(
"tried to get field {field}, but this field does not exists or private"
))),
}
}
}
#[derive(Clone)]
pub struct Runner {
context: Arc<Mutex<Context>>,
}
impl Runner {
pub fn init() -> ScriptResult<Self> {
let context = Context::new(None)?;
context.add_callback("print", |a: String| {
print(a);
None::<bool>
})?;
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 call_attacher<F, R>(&mut self, f: F) -> ScriptResult<R>
where
F: FnOnce(&Context, &mut OwnedJsObject) -> R,
{
let context = self.context.lock().unwrap();
let mut global = context.global()?;
let res = f(&context, &mut global);
Ok(res)
}
pub fn run_script(&self, content: &str) -> ScriptResult<JsValue> {
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)?;
Ok(val)
}
pub fn init_config(&self, content: &str) -> ScriptResult<RunnerConfig> {
let val = self.run_script(content)?;
// let rc: RunnerConfig = from_js(unsafe { self.context.context_raw() }, &val)?;
let rc: RunnerConfig = DeserializerJS::deserialize_js(&val)?;
Ok(rc)
}
}
#[cfg(test)]
// allowing this since it is better for debugging tests)
#[allow(clippy::unwrap_used)]
#[allow(clippy::print_stdout)]
mod tests {
use quickjs_rusty::{serde::from_js, OwnedJsObject};
use super::*;
#[test]
fn test_run_script_valid() {
let runner = Runner::init().unwrap();
let val = runner.run_script(r#"print"#).unwrap();
println!("Val: {:?}", val);
let val = runner.run_script(r#"print('Hello from JS!');"#).unwrap();
println!("Val: {:?}", val);
assert!(val.is_null());
let val = runner.run_script(r#"const a = 1+2; a"#).unwrap();
println!("Val: {:?}", val);
assert_eq!(val.to_int(), Ok(3));
let val = runner.run_script(r#"a + 39"#).unwrap();
println!("Val: {:?}", val);
assert_eq!(val.to_int(), Ok(42));
}
#[test]
fn test_run_script_file_main() {
let runner = Runner::init().unwrap();
let val = runner.run_script(include_str!("../mainbot.js")).unwrap();
println!("config: {:?}", val);
let d: RunnerConfig = DeserializerJS::deserialize_js(&val).unwrap();
println!("desr rc: {:?}", d);
let val = runner.run_script("start_buttons()").unwrap();
println!("Val: {:?}", val.to_string());
}
#[test]
fn test_func_deserialization_main() {
let runner = Runner::init().unwrap();
let _ = runner
.run_script("function cancel_buttons() {return 'cancelation'}")
.unwrap();
let f = BotFunction::by_name("cancel_buttons".to_string());
let res = f.call_context(&runner).unwrap();
println!("RES: {res:?}");
let sres: String = res.js_into().unwrap();
println!("Deserialized RES: {:?}", sres);
assert_eq!(sres, "cancelation");
}
fn recursive_format(o: OwnedJsObject) -> String {
let props: Vec<_> = o.properties_iter().unwrap().map(|x| x.unwrap()).collect();
let sp: Vec<String> = props
.into_iter()
.map(|v| {
if v.is_object() {
recursive_format(v.try_into_object().unwrap())
} else {
format!("{:?}", v)
}
})
.collect();
format!("{:?}", sp)
}
#[test]
fn test_run_script_invalid() {
let runner = Runner::init().unwrap();
let result = runner.run_script(r#"invalid_script();"#);
assert!(result.is_err());
let errstr =
if let Err(ScriptError::ExecutionError(ExecutionError::Exception(errstr))) = result {
errstr.to_string().unwrap()
} else {
panic!("test returned wrong error!, {result:?}");
};
if errstr != "ReferenceError: invalid_script is not defined" {
panic!("test returned an error, but the wrong one, {errstr}")
}
}
}

View File

@ -0,0 +1,81 @@
use std::sync::RwLock;
use log::info;
use quickjs_rusty::{context::Context, serde::from_js, OwnedJsObject};
use teloxide::Bot;
use tokio::runtime::Handle;
use crate::{
db::{application::Application, message_forward::MessageForward, CallDB, DB},
message_answerer::MessageAnswerer,
send_application_to_chat, BotError,
};
use super::ScriptError;
pub fn attach_user_application(
c: &Context,
o: &mut OwnedJsObject,
db: &DB,
bot: &Bot,
) -> Result<(), ScriptError> {
let db: std::sync::Arc<RwLock<DB>> = std::sync::Arc::new(RwLock::new(db.clone()));
let dbbox = Box::new(db.clone());
let db: &'static _ = Box::leak(dbbox);
let bot: std::sync::Arc<RwLock<Bot>> = std::sync::Arc::new(RwLock::new(bot.clone()));
let botbox = Box::new(bot.clone());
let bot: &'static _ = Box::leak(botbox);
let user_application =
c.create_callback(move |q: OwnedJsObject| -> Result<_, ScriptError> {
let db = db.clone();
let user: teloxide::types::User = match from_js(q.context(), &q) {
Ok(q) => q,
Err(_) => todo!(),
};
let application = futures::executor::block_on(
Application::new(user.clone()).store_db(&mut db.write().unwrap()),
)?;
let db2 = db.clone();
let msg = tokio::task::block_in_place(move || {
Handle::current().block_on(async move {
send_application_to_chat(
&bot.read().unwrap(),
&mut db2.write().unwrap(),
&application,
)
.await
})
});
let msg = match msg {
Ok(msg) => msg,
Err(err) => {
info!("Got err: {err}");
return Err(ScriptError::MutexError("🤦‍♂️".to_string()));
}
};
let (chat_id, msg_id) = futures::executor::block_on(
MessageAnswerer::new(
&bot.read().unwrap(),
&mut db.write().unwrap(),
user.id.0 as i64,
)
.answer("left_application_msg", None, None),
)
.unwrap();
futures::executor::block_on(
MessageForward::new(msg.chat.id.0, msg.id.0, chat_id, msg_id, false)
.store_db(&mut db.write().unwrap()),
)?;
let ret = true;
Ok(ret)
})?;
o.set_property("user_application", user_application.into_value())?;
Ok(())
}

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

100
src/commands.rs Normal file
View File

@ -0,0 +1,100 @@
use std::str::FromStr;
use teloxide::utils::command::ParseError;
#[derive(thiserror::Error, Debug)]
pub enum CommandError {
#[error("parse error: {0:?}")]
ParseError(#[from] ParseError),
#[error("failed to validate command: {0:?}")]
ValidationError(String),
}
#[derive(Clone)]
pub struct BotCommand {
command: String,
args: Option<String>,
}
impl FromStr for BotCommand {
type Err = CommandError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (command, args) = s.split_once(" ").map_or((s, None), |s| (s.0, Some(s.1)));
match command.strip_prefix("/") {
Some(command) => Ok(Self {
command: command.to_string(),
args: args.map(str::to_string),
}),
None => Err(CommandError::ParseError(ParseError::IncorrectFormat(
"Not a command".into(),
))),
}
}
}
impl BotCommand {
pub fn from_validate(s: &str, cmds: &[&str]) -> Result<Self, CommandError> {
let bc = Self::from_str(s)?;
if !cmds.contains(&bc.command.as_str()) {
return Err(CommandError::ValidationError(format!(
"invalid command {}",
bc.command
)));
};
Ok(bc)
}
pub fn command(&self) -> &str {
&self.command
}
pub fn args(&self) -> Option<&str> {
self.args.as_deref()
}
pub fn args_list(&self) -> Vec<&str> {
let args = match self.args {
Some(ref args) => args.as_str(),
None => "",
};
args.split_whitespace().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_botcommand_from_str_simple() {
let cmdstr = "/start";
let bc = BotCommand::from_str(cmdstr).unwrap();
assert_eq!(bc.command(), "start");
assert_eq!(bc.args(), None);
}
#[test]
fn test_botcommand_from_str_with_args() {
let cmdstr = "/some_long_cmd arg1 arg2";
let bc = BotCommand::from_str(cmdstr).unwrap();
assert_eq!(bc.command(), "some_long_cmd");
assert_eq!(bc.args(), Some("arg1 arg2"));
}
#[test]
fn test_botcommand_arg_list() {
let cmdstr = "/some_long_cmd arg1 arg2";
let bc = BotCommand::from_str(cmdstr).unwrap();
assert_eq!(bc.command(), "some_long_cmd");
assert_eq!(bc.args(), Some("arg1 arg2"));
assert_eq!(bc.args_list(), vec!["arg1", "arg2"]);
}
}

View File

@ -2,6 +2,7 @@ use chrono::{DateTime, FixedOffset, Local};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::DbResult; use super::DbResult;
use super::DB;
use crate::query_call_consume; use crate::query_call_consume;
use crate::CallDB; use crate::CallDB;
@ -36,4 +37,13 @@ where
Ok(self) Ok(self)
}); });
pub async fn store_db(self, db: &mut DB) -> DbResult<Self> {
let db = db.get_database().await;
let ci = db.collection::<Self>("applications");
ci.insert_one(&self).await?;
Ok(self)
}
} }

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

@ -2,6 +2,7 @@ use bson::doc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::DbResult; use super::DbResult;
use super::DB;
use crate::query_call_consume; use crate::query_call_consume;
use crate::CallDB; use crate::CallDB;
@ -42,6 +43,15 @@ impl MessageForward {
Ok(self) Ok(self)
}); });
pub async fn store_db(self, db: &mut DB) -> DbResult<Self> {
let db = db.get_database().await;
let ci = db.collection::<Self>("message_forward");
ci.insert_one(&self).await?;
Ok(self)
}
pub async fn get<D: CallDB>( pub async fn get<D: CallDB>(
db: &mut D, db: &mut D,
chat_id: i64, chat_id: i64,

View File

@ -1,6 +1,8 @@
pub mod application; pub mod application;
pub mod bots;
pub mod callback_info; pub mod callback_info;
pub mod message_forward; pub mod message_forward;
pub mod raw_calls;
use std::time::Duration; use std::time::Duration;
@ -11,7 +13,7 @@ use futures::stream::TryStreamExt;
use mongodb::options::IndexOptions; use mongodb::options::IndexOptions;
use mongodb::{bson::doc, options::ClientOptions, Client}; use mongodb::{bson::doc, options::ClientOptions, Client};
use mongodb::{Database, IndexModel}; use mongodb::{Collection, Database, IndexModel};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(EnumStringify)] #[derive(EnumStringify)]
@ -140,14 +142,15 @@ pub struct Media {
#[derive(Clone)] #[derive(Clone)]
pub struct DB { pub struct DB {
client: Client, client: Client,
name: String,
} }
impl DB { 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 options = ClientOptions::parse(db_url.into()).await?;
let client = Client::with_options(options)?; let client = Client::with_options(options)?;
Ok(DB { client }) Ok(DB { client, name })
} }
pub async fn migrate(&mut self) -> DbResult<()> { pub async fn migrate(&mut self) -> DbResult<()> {
@ -184,18 +187,38 @@ impl DB {
Ok(()) Ok(())
} }
pub async fn init<S: Into<String>>(db_url: S) -> DbResult<Self> { pub async fn init<S: Into<String>>(db_url: S, name: String) -> DbResult<Self> {
let mut db = Self::new(db_url).await?; let mut db = Self::new(db_url, name).await?;
db.migrate().await?; db.migrate().await?;
Ok(db) 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] #[async_trait]
impl CallDB for DB { impl CallDB for DB {
async fn get_database(&mut self) -> Database { 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(); dotenvy::dotenv().unwrap();
let db_url = std::env::var("DATABASE_URL").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] #[tokio::test]

549
src/handlers/admin.rs Normal file
View File

@ -0,0 +1,549 @@
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()
// keep on top to cancel any action
.branch(cancel_handler())
.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 cancel_handler() -> BotHandler {
Update::filter_message()
.filter(|msg: Message| msg.text() == Some("/cancel"))
.enter_dialogue::<Message, MongodbStorage<Json>, State>()
.endpoint(async |bot: Bot, msg: Message, dialogue: BotDialogue| {
dialogue.exit().await?;
bot.send_message(msg.chat.id, "Диалог закончен!").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 text = format!(
"Сообщение от поддержки:\n{}\nЧтобы закончить диалог, нажмите на /cancel",
text
);
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 serde::{de::DeserializeOwned, Deserialize, Serialize};
use teloxide::dispatching::dialogue::{Serializer, Storage}; use teloxide::dispatching::dialogue::{Serializer, Storage};
use crate::db::{CallDB, DB};
pub struct MongodbStorage<S> { pub struct MongodbStorage<S> {
database: Database, database: Database,
serializer: S, serializer: S,
@ -28,6 +30,13 @@ impl<S> MongodbStorage<S> {
serializer, 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)] #[derive(Serialize, Deserialize)]

View File

@ -1,3 +1,5 @@
pub mod parcelable;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use teloxide::types::InlineKeyboardButton; use teloxide::types::InlineKeyboardButton;

110
src/utils/parcelable.rs Normal file
View File

@ -0,0 +1,110 @@
use std::collections::HashMap;
pub enum ParcelType<'a, F> {
Function(&'a mut F),
Parcelable(&'a mut dyn Parcelable<F>),
Other(()),
}
#[derive(thiserror::Error, Debug)]
pub enum ParcelableError {
#[error("error to get field: {0:?}")]
FieldError(String),
#[error("error when addressing nested element: {0:?}")]
NestError(String),
#[error("error to resolve Parcelable: {0:?}")]
ResolveError(String),
}
pub type ParcelableResult<T> = Result<T, ParcelableError>;
pub trait Parcelable<F> {
fn get_field(&mut self, name: &str) -> ParcelableResult<ParcelType<F>>;
fn resolve(&mut self) -> ParcelableResult<ParcelType<F>>
where
Self: Sized + 'static,
{
let root = ParcelableResult::Ok(ParcelType::Parcelable(self));
root
}
/// Get nested field by name, which is fields joined by dot
/// for example: passing name "field1.somefield" will be the same
/// as using `struct.field1.somefield`, by dynamically
fn get_nested(&mut self, name: &str) -> ParcelableResult<ParcelType<F>>
where
Self: Sized + 'static,
{
let root = ParcelableResult::Ok(ParcelType::Parcelable(self));
name.split('.')
.fold(root, |s: ParcelableResult<ParcelType<F>>, field| match s? {
ParcelType::Parcelable(p) => p.get_field(field),
_ => Err(ParcelableError::NestError(format!(
"Failed to get field {field}. End of nestment"
))),
})
}
}
impl<F> Parcelable<F> for String {
fn get_field(&mut self, _name: &str) -> ParcelableResult<ParcelType<F>> {
todo!()
}
fn resolve(&mut self) -> ParcelableResult<ParcelType<F>>
where
Self: Sized + 'static,
{
Ok(ParcelType::Other(()))
}
}
impl<F, T: Parcelable<F>> Parcelable<F> for Option<T> {
fn get_field(&mut self, name: &str) -> ParcelableResult<ParcelType<F>> {
Err(ParcelableError::FieldError(format!(
"tried to get field {name}, but calls of get_field are not allowed on Option"
)))
}
fn resolve(&mut self) -> crate::utils::parcelable::ParcelableResult<ParcelType<F>>
where
Self: Sized + 'static,
{
match self {
Some(v) => Ok(v.resolve()?),
None => Err(ParcelableError::ResolveError("Option was None".to_string())),
}
}
}
impl<F, V: Parcelable<F> + 'static> Parcelable<F> for HashMap<String, V> {
fn get_field(&mut self, name: &str) -> ParcelableResult<ParcelType<F>> {
match self.get_mut(name) {
Some(v) => Ok(Parcelable::resolve(v)?),
None => Err(ParcelableError::FieldError(format!(
"tried to get value by key {name}, but this key does not exists"
))),
}
}
}
impl<F, T: Parcelable<F> + 'static> Parcelable<F> for Vec<T> {
fn get_field(&mut self, name: &str) -> ParcelableResult<ParcelType<F>> {
let index: usize = match name.parse() {
Ok(index) => index,
Err(err) => {
return Err(ParcelableError::FieldError(format!(
"Failed to parse field name `{name}` as an array index, err: {err}"
)))
}
};
let veclen = self.len();
let value = match self.get_mut(index) {
Some(value) => value,
None => return Err(ParcelableError::FieldError(format!("Failed to get vec element with index {index}, probably out of bound (vec len: {veclen})"))),
};
Parcelable::resolve(value)
}
}