Compare commits

...

132 Commits

Author SHA1 Message Date
f7318f3661 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
2025-05-31 08:49:52 +00:00
Akulij
adad94ad43 update mainbot.js logic 2025-05-31 13:47:46 +05:00
Akulij
714853730a create /cancel command for users 2025-05-31 13:47:14 +05:00
Akulij
b980a653cb attach user_application to runner 2025-05-31 13:46:54 +05:00
Akulij
b8d07d0ad5 create MessageForward.store_db to store by DB type
reason: easier to use with RwLock
2025-05-31 13:45:45 +05:00
Akulij
2e447e87fd create Application.store_db to store by DB type 2025-05-31 13:45:02 +05:00
Akulij
d749b57811 call js function handler if set in script_handler 2025-05-31 13:43:41 +05:00
Akulij
a106891050 create call_attacher for Runner to attach global objects on initialization 2025-05-31 13:42:54 +05:00
Akulij
9cd0765030 create BotMessage js function handler getter 2025-05-31 13:42:10 +05:00
Akulij
bc46e0fda4 create BotFunction context getter 2025-05-31 13:41:42 +05:00
Akulij
845071c800 create botscript application module for js's runtime user_application function 2025-05-31 13:39:17 +05:00
Akulij
c936ea38a9 update mainbot.js 2025-05-31 11:04:35 +05:00
Akulij
bee93b32d1 use BotController 2025-05-31 10:48:52 +05:00
Akulij
0265942449 enable Send for BotController 2025-05-31 10:48:15 +05:00
Akulij
e993a6c941 derive Clone on Config 2025-05-31 10:47:49 +05:00
Akulij
6dfe9b839d fix logic: move bot insertion to db to /deploy command, instead of function in bot_manager 2025-05-31 10:45:34 +05:00
Akulij
f27fb670bd create BotManager 2025-05-31 10:43:05 +05:00
Akulij
22025cde11 handle callbacks in script_handler 2025-05-31 10:35:52 +05:00
Akulij
3bd16a58cd add replace flag to BotMessage 2025-05-31 10:34:29 +05:00
Akulij
474795bd99 create RunnerConfig.get_callback_message 2025-05-31 10:34:06 +05:00
Akulij
fdf1e352a6 add buttons field to BotDialog 2025-05-31 10:33:26 +05:00
Akulij
39e376195c create BotInstance::update_script 2025-05-31 10:31:09 +05:00
Akulij
308b15ed69 create BotInstance::restart_one and restart_all 2025-05-31 10:30:39 +05:00
Akulij
102fae25c7 create BotInstance::get_by_name 2025-05-31 10:30:03 +05:00
Akulij
bc2397a32c derive Clone for BotInstance 2025-05-31 10:29:03 +05:00
Akulij
fde7087172 create /newscript command handler 2025-05-31 10:28:22 +05:00
Akulij
67ad3c2acd plug provided handlers in start_bot 2025-05-28 11:11:25 +05:00
Akulij
866a028de1 move out bot handlers to separate module 2025-05-28 11:10:32 +05:00
Akulij
e63187dcd3 rename botscript_command_handler to more correct handle_botmessage 2025-05-28 10:15:30 +05:00
Akulij
098cff72bd delete inspector in botscript handler 2025-05-28 10:14:14 +05:00
Akulij
d428c8798d make script_handler return a public type 2025-05-27 15:35:40 +05:00
Akulij
6eb6f2f454 fix: start bot with it's db name 2025-05-27 15:34:50 +05:00
Akulij
2fdd8a346d fix: instead of actually starting bot on /deploy, just put info in DB
reason: it's not a responsibility of /deploy command to store bot info
AND starting bot thread, isntead it is responsible only for storing info
in DB, as every other command does, and then bot will lazily start by
bot manager
2025-05-27 15:12:14 +05:00
Akulij
d10acc992a start bot instances in main 2025-05-27 14:21:36 +05:00
Akulij
8e3c647727 fix: handle io and RwLock error for bot manager 2025-05-27 14:20:02 +05:00
Akulij
8fed0daf4c extend BotController creation implementation 2025-05-27 14:17:29 +05:00
Akulij
e1b6b5aa10 derive Clone for BotController 2025-05-27 14:14:23 +05:00
Akulij
4a35243a4c store RunnerConfig in BotController as atomic rw lock for thread safety 2025-05-27 14:13:28 +05:00
Akulij
1757571f35 create bot_name field in Config 2025-05-27 14:11:33 +05:00
Akulij
77ba6dcfc5 define const MAIN_BOT_SCRIPT 2025-05-27 14:10:48 +05:00
Akulij
6ac3665dee fix: add bot_handler to compile tree 2025-05-27 04:17:26 +05:00
Akulij
ef5d74cf1c create bot_manager module to handle background bot start 2025-05-27 04:17:05 +05:00
Akulij
593316d541 cargo add lazy_static 2025-05-27 04:15:39 +05:00
Akulij
a136558681 create MongodbStorage::from_db initializer 2025-05-27 04:09:47 +05:00
Akulij
9e99064bc5 create DB.with_name method 2025-05-27 04:09:05 +05:00
Akulij
3acd168155 make Runner thread safe 2025-05-27 04:08:27 +05:00
Akulij
4be9c034c9 use variable db name instead of hardcoded one 2025-05-26 20:50:25 +05:00
Akulij
9bbf481002 derive Clone on bot and runner config 2025-05-26 20:48:11 +05:00
Akulij
4384431696 create RunnerConfig::init_with_db 2025-05-26 20:47:35 +05:00
Akulij
13a861e74b cargo add serde_json 2025-05-26 20:45:59 +05:00
Akulij
ff7f317ae5 create attach_db_obj for botscript 2025-05-26 20:44:34 +05:00
Akulij
aac968e408 add RawCallError to ScriptError 2025-05-26 20:43:04 +05:00
Akulij
cb7c888028 create RawCall trait, that will contain DB methods to call from script runtime 2025-05-26 20:22:46 +05:00
Akulij
a33d4b393c impl GetCollection for CallDB 2025-05-26 20:16:12 +05:00
Akulij
9c15b0a375 create DbCollection and GetCollection traits 2025-05-26 20:15:27 +05:00
Akulij
4c149b6922 create BotInstance collection 2025-05-26 20:12:46 +05:00
Akulij
a7433cd8cc create /deploy admin command 2025-05-26 20:10:12 +05:00
Akulij
bdb30c8d98 create default script for new bots 2025-05-26 20:08:55 +05:00
Akulij
d1c1b7500d create script_handler function that creates teloxide's handler for botscript dispatch 2025-05-26 20:06:07 +05:00
Akulij
d5dbaa0b75 refactor message answer and replace 2025-05-25 09:40:41 +05:00
Akulij
4548419946 implement botscript_command_handler 2025-05-23 16:36:34 +05:00
Akulij
e8dbf3db76 access RunnerConfig instead of manually using commands hash map 2025-05-23 16:29:54 +05:00
Akulij
1ff86f641f impl RunnerConfig::get_command_message 2025-05-23 16:27:06 +05:00
Akulij
31e78be68f change interface for ResolveValue 2025-05-23 16:26:35 +05:00
Akulij
217a074c95 create ResolveError 2025-05-23 16:24:17 +05:00
Akulij
1c17639c0e handle DbError in ScriptError 2025-05-23 16:23:54 +05:00
Akulij
9e35f4168e use DB and join_all 2025-05-23 16:23:12 +05:00
Akulij
178f2a2399 create ButtonRaw name, callback_name and literal getters 2025-05-23 16:21:26 +05:00
Akulij
1730107e9a create BotMessage.literal getter 2025-05-23 16:20:21 +05:00
Akulij
506fdcb260 create BotMessage.resolve_buttons 2025-05-23 16:19:37 +05:00
Akulij
6d5f748ab8 create ButtonLayout::resolve_raw 2025-05-23 16:15:44 +05:00
Akulij
cbb9c0c335 botscript: create ButtonLayout enum 2025-05-23 16:13:56 +05:00
Akulij
f8c63e5315 create BotMessage.fill_literal method 2025-05-23 16:04:03 +05:00
Akulij
66180e0cfb add optional literal string in BotMessage 2025-05-23 16:03:12 +05:00
Akulij
1117af0724 impl ButtonName.resolve_name 2025-05-23 15:49:30 +05:00
Akulij
99758500b3 fix mainbot.js: start_buttons: return actual buttons 2025-05-23 15:29:22 +05:00
Akulij
d174ee7bc7 fix: handle Runner in BotController
reason: earlier, Runner and js runtime in it were freed after init of
BotController, which potentially was able to lead into hanging function
in config without runtime, BUT, there is check in in JS_FreeRuntime to
have zero objects, so bug found without hesitation ;)
2025-05-21 14:07:02 +05:00
Akulij
ca2e661a0e fix: in Runner's init_config use deserialize_js instead of js_into 2025-05-21 14:06:09 +05:00
Akulij
c33c67044a delete test test_deserialization_main, since it isnot actual anymore 2025-05-21 13:04:08 +05:00
Akulij
d1b6d153d4 create call and call_args methods for BotFunction 2025-05-21 12:53:26 +05:00
Akulij
0dc71fda08 fix BotFunction's call_context method
previously called by stored template string instead of actual function
2025-05-21 12:46:46 +05:00
Akulij
970ce07280 fix name of stateful_msg_handlers in Parcelable field getter 2025-05-21 12:41:04 +05:00
Akulij
940a832561 mainbot.js: use function in handler instead of string function name 2025-05-21 12:33:03 +05:00
Akulij
a15cdeadf9 mainbot.js: create example function enter_name 2025-05-21 12:32:45 +05:00
Akulij
f5a894fe37 mainbot.js: change comment style for handler example 2025-05-21 12:31:43 +05:00
Akulij
a4e8ea0390 mainbot.js: use function in buttons field instead of function name 2025-05-21 12:29:47 +05:00
Akulij
6fa398401d restore functions in deserialize_js 2025-05-21 12:28:24 +05:00
Akulij
6ea26c0618 change injectable template in DeserializeJS to correct one 2025-05-21 12:27:27 +05:00
Akulij
b76392d597 impl Parcelable for all RunnerConfig types 2025-05-21 12:26:09 +05:00
Akulij
ad58587160 fix test: use BotFunction's method by_name 2025-05-21 12:24:16 +05:00
Akulij
1aed17fa30 specify ResolveValue in recursive resolve call 2025-05-21 12:23:32 +05:00
Akulij
684895a554 change BotFunction implementation to be compatible with js function
injection
2025-05-21 12:19:39 +05:00
Akulij
d5f39e4e60 create Parcelable trait with default implementations 2025-05-21 12:16:34 +05:00
Akulij
2a4ed51824 limit CD workflow to triger only on main branch push 2025-05-20 08:52:53 +05:00
Akulij
d1b25b52c1 fix: use KeyboardDefinition in BotMessage 2025-05-20 08:49:52 +05:00
Akulij
2c5802eaeb delete unnecessary literal field in ButtonRaw 2025-05-20 08:49:23 +05:00
Akulij
bd800e88eb create predefined buttons layout example in mainbot.js 2025-05-20 08:48:34 +05:00
Akulij
a2e1354bee fix missing #[serde(untagged)] on keyboard layout structs 2025-05-20 08:47:23 +05:00
Akulij
55d53bd140 create keyboard struct definition with resolvement trait 2025-05-20 08:32:28 +05:00
Akulij
ea007127ff test for DeserializerJS::deserialize_js 2025-05-20 08:32:09 +05:00
Akulij
0a60b0469f create DeserializerJS::inject_templates 2025-05-20 08:29:40 +05:00
Akulij
40eec7d38d fix js_into: self is already a reference 2025-05-20 04:36:19 +05:00
Akulij
2ccfc19a6c create filter to handle botscript-defined commands 2025-05-20 02:47:00 +05:00
Akulij
e0c00d68f9 derive Clone on BotCommand
reason: everything passed in teloxide's filter_map asks Clone (or Copy) implementation
2025-05-20 01:16:52 +05:00
Akulij
0e10cdbdf0 create command module
defines BotCommand struct
2025-05-20 01:07:11 +05:00
Akulij
29cd73e98f create basic handler to answer BotMessage 2025-05-20 00:44:08 +05:00
Akulij
3d9a1c31b4 use RunnerConfig in BotController 2025-05-20 00:42:32 +05:00
Akulij
5c8cadf7a0 create test for deserealization of result of js function 2025-05-20 00:40:25 +05:00
Akulij
534a0e6090 create init_config method for Runner 2025-05-20 00:39:39 +05:00
Akulij
135139514b update btoscript struct definitions 2025-05-20 00:39:15 +05:00
Akulij
f6b18af5dd derive clone on BoMessage 2025-05-20 00:38:29 +05:00
Akulij
22ab941ed8 create buttons definition example in mainbot.js 2025-05-20 00:33:51 +05:00
Akulij
cddf5986ba use from_js 2025-05-19 23:58:53 +05:00
Akulij
3f8f25fff9 create SerdeError for ScriptError
reason: to handle errors from call of from_js
2025-05-19 23:57:22 +05:00
Akulij
eb63743714 create js_into for JsValue
reason: wil be way less boilerplate for deserialization of JsValue to defined structs
2025-05-19 23:55:44 +05:00
Akulij
c2e02efc47 create call_context method for BotFunction 2025-05-19 23:54:39 +05:00
Akulij
c8c67b54e3 make botFunction a struct 2025-05-19 23:54:08 +05:00
Akulij
7dbad400cc mainbot.js: fix missing comma 2025-05-18 22:19:27 +03:00
Akulij
3d6bf15806 add somecomplicatedcmd as an example in mainbot.js 2025-05-18 22:19:09 +03:00
Akulij
6299f6d815 add comment about default value of buttons in mainbot.js 2025-05-18 22:18:37 +03:00
Akulij
8e9d5b4f1d add stateful_msg_handlers example to mainbot.js 2025-05-18 16:30:46 +03:00
Akulij
05b298e61c add more command example to mainbot.js 2025-05-18 16:30:25 +03:00
Akulij
48cbd4c7d0 fix missing commas in mainbot.js 2025-05-18 16:15:29 +03:00
Akulij
a643a707ed new specification of bot commands definition 2025-05-18 16:13:17 +03:00
Akulij
e7d43adc40 create structs for bot's configuration definition 2025-05-18 16:10:03 +03:00
Akulij
a6206d3d6f use quickjs_rusty's from_js in tests 2025-05-18 16:09:34 +03:00
Akulij
96996fd33d update mainbot.js with new specification 2025-05-18 16:08:34 +03:00
Akulij
bf032e6ce4 create test for deserealization of mainbot.js 2025-05-18 16:07:40 +03:00
Akulij
9d5bf86289 create recursive_format function just for tests 2025-05-18 16:05:10 +03:00
Akulij
0c927448d2 create mainbot.js where main bot's logic will be contained 2025-05-10 03:28:00 +03:00
Akulij
08c1b67f02 create botscript runner 2025-05-10 03:27:35 +03:00
Akulij
d39d2c8144 cargo add quickjs-rusty 2025-05-10 03:25:53 +03:00
25 changed files with 3159 additions and 848 deletions

View File

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

189
Cargo.lock generated
View File

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

View File

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

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

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 super::DbResult;
use super::DB;
use crate::query_call_consume;
use crate::CallDB;
@ -36,4 +37,13 @@ where
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 super::DbResult;
use super::DB;
use crate::query_call_consume;
use crate::CallDB;
@ -42,6 +43,15 @@ impl MessageForward {
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>(
db: &mut D,
chat_id: i64,

View File

@ -1,6 +1,8 @@
pub mod application;
pub mod bots;
pub mod callback_info;
pub mod message_forward;
pub mod raw_calls;
use std::time::Duration;
@ -11,7 +13,7 @@ use futures::stream::TryStreamExt;
use mongodb::options::IndexOptions;
use mongodb::{bson::doc, options::ClientOptions, Client};
use mongodb::{Database, IndexModel};
use mongodb::{Collection, Database, IndexModel};
use serde::{Deserialize, Serialize};
#[derive(EnumStringify)]
@ -140,14 +142,15 @@ pub struct Media {
#[derive(Clone)]
pub struct DB {
client: Client,
name: String,
}
impl DB {
pub async fn new<S: Into<String>>(db_url: S) -> DbResult<Self> {
pub async fn new<S: Into<String>>(db_url: S, name: String) -> DbResult<Self> {
let options = ClientOptions::parse(db_url.into()).await?;
let client = Client::with_options(options)?;
Ok(DB { client })
Ok(DB { client, name })
}
pub async fn migrate(&mut self) -> DbResult<()> {
@ -184,18 +187,38 @@ impl DB {
Ok(())
}
pub async fn init<S: Into<String>>(db_url: S) -> DbResult<Self> {
let mut db = Self::new(db_url).await?;
pub async fn init<S: Into<String>>(db_url: S, name: String) -> DbResult<Self> {
let mut db = Self::new(db_url, name).await?;
db.migrate().await?;
Ok(db)
}
pub fn with_name(self, name: String) -> Self {
Self { name, ..self }
}
}
pub trait DbCollection {
const COLLECTION: &str;
}
pub trait GetCollection {
async fn get_collection<C: DbCollection + Send + Sync>(&mut self) -> Collection<C>;
}
#[async_trait]
impl CallDB for DB {
async fn get_database(&mut self) -> Database {
self.client.database("gongbot")
self.client.database(&self.name)
}
}
impl<T: CallDB> GetCollection for T {
async fn get_collection<C: DbCollection + Send + Sync>(&mut self) -> Collection<C> {
self.get_database()
.await
.collection(<C as DbCollection>::COLLECTION)
}
}

38
src/db/raw_calls.rs Normal file
View File

@ -0,0 +1,38 @@
use mongodb::Database;
use super::CallDB;
use serde_json::Value;
#[derive(thiserror::Error, Debug)]
pub enum RawCallError {
#[error("error while processing mongodb query: {0}")]
MongodbError(#[from] mongodb::error::Error),
#[error("error while buildint bson's query document: {0}")]
DocumentError(#[from] mongodb::bson::extjson::de::Error),
#[error("error when expected map: {0}")]
NotAMapError(String),
}
pub type RawCallResult<T> = Result<T, RawCallError>;
pub trait RawCall {
async fn get_database(&mut self) -> Database;
async fn find_one(&mut self, collection: &str, query: Value) -> RawCallResult<Option<Value>> {
let db = self.get_database().await;
let value = db.collection::<Value>(collection);
let map = match query {
Value::Object(map) => map,
_ => return Err(RawCallError::NotAMapError("query is not a map".to_string())),
};
let doc = map.try_into()?;
let ret = value.find_one(doc).await?;
Ok(ret)
}
}
impl<T: CallDB> RawCall for T {
async fn get_database(&mut self) -> Database {
CallDB::get_database(self).await
}
}

View File

@ -10,7 +10,7 @@ async fn setup_db() -> DB {
dotenvy::dotenv().unwrap();
let db_url = std::env::var("DATABASE_URL").unwrap();
DB::new(db_url).await.unwrap()
DB::new(db_url, "gongbot".to_string()).await.unwrap()
}
#[tokio::test]

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 teloxide::dispatching::dialogue::{Serializer, Storage};
use crate::db::{CallDB, DB};
pub struct MongodbStorage<S> {
database: Database,
serializer: S,
@ -28,6 +30,13 @@ impl<S> MongodbStorage<S> {
serializer,
}))
}
pub async fn from_db(db: &mut DB, serializer: S) -> Result<Arc<Self>, mongodb::error::Error> {
Ok(Arc::new(Self {
database: CallDB::get_database(db).await,
serializer,
}))
}
}
#[derive(Serialize, Deserialize)]

View File

@ -1,3 +1,5 @@
pub mod parcelable;
use serde::{Deserialize, Serialize};
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)
}
}