diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 035b78f..cd6fb6d 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -1,5 +1,8 @@ name: Build && Deploy -on: [push] +on: + push: + branches: + - main jobs: build: diff --git a/Cargo.lock b/Cargo.lock index d244f23..d00b307 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,6 +60,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + [[package]] name = "aquamarine" version = "0.6.0" @@ -133,6 +139,26 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "bitflags" version = "1.3.2" @@ -220,9 +246,20 @@ version = "1.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a" dependencies = [ + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -265,6 +302,17 @@ dependencies = [ "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]] name = "concurrent-queue" version = "2.5.0" @@ -309,6 +357,15 @@ dependencies = [ "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]] name = "core-foundation" version = "0.9.4" @@ -840,6 +897,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + [[package]] name = "gongbotrs" version = "0.1.0" @@ -853,10 +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", @@ -1352,6 +1418,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "js-sys" version = "0.3.77" @@ -1362,12 +1438,39 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "libsqlite3-sys" version = "0.30.1" @@ -1502,6 +1605,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.5" @@ -1600,12 +1709,41 @@ dependencies = [ "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]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "num-traits" version = "0.2.19" @@ -1828,6 +1966,16 @@ dependencies = [ "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]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -1858,6 +2006,22 @@ dependencies = [ "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]] name = "quote" version = "1.0.40" @@ -2067,6 +2231,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2157,6 +2327,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "schannel" version = "0.1.27" @@ -3059,6 +3238,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index e8b707a..e29d0c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,10 +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"] } diff --git a/default_script.js b/default_script.js new file mode 100644 index 0000000..91553cf --- /dev/null +++ b/default_script.js @@ -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 diff --git a/mainbot.js b/mainbot.js new file mode 100644 index 0000000..55dfab9 --- /dev/null +++ b/mainbot.js @@ -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 diff --git a/src/admin.rs b/src/admin.rs index e8102cc..10fbba2 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -5,7 +5,8 @@ use teloxide::{ }; use crate::{ - db::{CallDB, DB}, + bot_manager::DEFAULT_SCRIPT, + db::{bots::BotInstance, CallDB, DB}, BotResult, }; use crate::{BotDialogue, LogMsg, State}; @@ -41,6 +42,8 @@ pub enum AdminCommands { Users, /// Cancel current action and sets user state to default Cancel, + /// Create new instance of telegram bot + Deploy { token: String }, } pub async fn admin_command_handler( @@ -156,6 +159,36 @@ pub async fn admin_command_handler( .await?; Ok(()) } + AdminCommands::Deploy { token } => { + let bot_instance = { + let botnew = Bot::new(&token); + let name = match botnew.get_me().await { + Ok(me) => me.username().to_string(), + Err(teloxide::RequestError::Api(teloxide::ApiError::InvalidToken)) => { + bot.send_message(msg.chat.id, "Error: bot token is invalid") + .await?; + return Ok(()); + } + Err(err) => { + return Err(err.into()); + } + }; + + let bi = + BotInstance::new(name.clone(), token.to_string(), DEFAULT_SCRIPT.to_string()) + .store(&mut db) + .await?; + + bi + }; + + bot.send_message( + msg.chat.id, + format!("Deployed bot with name: {}", bot_instance.name), + ) + .await?; + Ok(()) + } } } diff --git a/src/bot_handler.rs b/src/bot_handler.rs new file mode 100644 index 0000000..d85ddfd --- /dev/null +++ b/src/bot_handler.rs @@ -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>) -> 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(()) +} diff --git a/src/bot_manager.rs b/src/bot_manager.rs new file mode 100644 index 0000000..be324ec --- /dev/null +++ b/src/bot_manager.rs @@ -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>>, +} + +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> = RwLock::new(HashMap::new()); +} + +pub static DEFAULT_SCRIPT: &str = + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/default_script.js")); + +pub struct BotManager +where + BIG: FnMut() -> FBIS, + FBIS: Future, + BIS: Iterator, + HM: FnMut(BotInstance) -> FHI, + FHI: Future, + HI: Iterator, +{ + bot_pool: HashMap, + bi_getter: BIG, + h_mapper: HM, +} + +impl BotManager +where + BIG: FnMut() -> FBIS, + FBIS: Future, + BIS: Iterator, + HM: FnMut(BotInstance) -> FHI, + FHI: Future, + HI: Iterator, +{ + /// 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, + ) -> BotResult { + 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 { + 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, +) -> BotResult { + 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>> { + 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) +} diff --git a/src/botscript.rs b/src/botscript.rs new file mode 100644 index 0000000..fff66c9 --- /dev/null +++ b/src/botscript.rs @@ -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 = Result; + +#[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 for BotFunction { + fn get_field( + &mut self, + _name: &str, + ) -> crate::utils::parcelable::ParcelableResult> { + todo!() + } + + fn resolve(&mut self) -> ParcelableResult> + 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 { + 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 { + self.call_args(Default::default()) + } + + pub fn call_args(&self, args: Vec) -> ScriptResult { + 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; +} + +impl DeserializeJS for JsValue { + fn js_into<'a, T: Deserialize<'a>>(&'a self) -> ScriptResult { + let rc = from_js(self.context(), self)?; + + Ok(rc) + } +} + +#[derive(Default)] +pub struct DeserializerJS { + fn_map: HashMap, +} + +impl DeserializerJS { + pub fn new() -> Self { + Self { + fn_map: HashMap::new(), + } + } + + pub fn deserialize_js<'a, T: Deserialize<'a> + Parcelable + 'static>( + value: &'a JsValue, + ) -> ScriptResult { + 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::::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> { + 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; + fn resolve_with(self, runner: &Runner) -> ScriptResult; +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(untagged)] +pub enum KeyboardDefinition { + Rows(Vec), + Function(BotFunction), +} + +impl Parcelable for KeyboardDefinition { + fn get_field(&mut self, _name: &str) -> ParcelableResult> { + todo!() + } + fn resolve(&mut self) -> ParcelableResult> + 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<::Value>; + + fn resolve(self) -> ScriptResult { + match self { + KeyboardDefinition::Rows(rows) => rows.into_iter().map(|r| r.resolve()).collect(), + KeyboardDefinition::Function(f) => { + ::resolve(f.call()?.js_into()?) + } + } + } + + fn resolve_with(self, runner: &Runner) -> ScriptResult { + match self { + KeyboardDefinition::Rows(rows) => { + rows.into_iter().map(|r| r.resolve_with(runner)).collect() + } + KeyboardDefinition::Function(f) => { + ::resolve_with(f.call_context(runner)?.js_into()?, runner) + } + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(untagged)] +pub enum RowDefinition { + Buttons(Vec), + Function(BotFunction), +} + +impl Parcelable for RowDefinition { + fn get_field(&mut self, _name: &str) -> ParcelableResult> { + todo!() + } + fn resolve(&mut self) -> ParcelableResult> + 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<::Value>; + + fn resolve(self) -> ScriptResult { + match self { + RowDefinition::Buttons(buttons) => buttons.into_iter().map(|b| b.resolve()).collect(), + RowDefinition::Function(f) => ::resolve(f.call()?.js_into()?), + } + } + + fn resolve_with(self, runner: &Runner) -> ScriptResult { + match self { + RowDefinition::Buttons(buttons) => buttons + .into_iter() + .map(|b| b.resolve_with(runner)) + .collect(), + RowDefinition::Function(f) => { + ::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 { + match self { + ButtonDefinition::Button(button) => Ok(button), + ButtonDefinition::ButtonLiteral(l) => Ok(ButtonRaw::from_literal(l)), + ButtonDefinition::Function(f) => ::resolve(f.call()?.js_into()?), + } + } + + fn resolve_with(self, runner: &Runner) -> ScriptResult { + match self { + ButtonDefinition::Button(button) => Ok(button), + ButtonDefinition::ButtonLiteral(l) => Ok(ButtonRaw::from_literal(l)), + ButtonDefinition::Function(f) => { + ::resolve_with(f.call_context(runner)?.js_into()?, runner) + } + } + } +} + +impl Parcelable for ButtonDefinition { + fn get_field(&mut self, _name: &str) -> ParcelableResult> { + todo!() + } + fn resolve(&mut self) -> ParcelableResult> + 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 Parcelable for ButtonRaw { + fn get_field(&mut self, _name: &str) -> ParcelableResult> { + 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 { + 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 { + 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