From 42c8016d9c4fae82d5d82ffe0f6190069e1a8803 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Sat, 18 Oct 2025 15:49:49 +0300 Subject: [PATCH] Added telegram --- Cargo.lock | 302 +++++++- Cargo.toml | 3 + src/database/entities/mod.rs | 2 + src/database/entities/telegram_config.rs | 94 +++ src/database/entities/user.rs | 11 + ...929_000001_create_telegram_config_table.rs | 51 ++ ...0929_000002_add_telegram_admin_to_users.rs | 40 + src/database/migrations/mod.rs | 4 + src/database/mod.rs | 4 +- src/database/repository/mod.rs | 4 +- src/database/repository/server.rs | 9 + src/database/repository/server_inbound.rs | 12 + src/database/repository/telegram_config.rs | 167 ++++ src/database/repository/user.rs | 44 ++ src/main.rs | 11 +- src/services/mod.rs | 4 +- src/services/telegram/bot.rs | 39 + src/services/telegram/error.rs | 46 ++ src/services/telegram/handlers.rs | 371 +++++++++ src/services/telegram/mod.rs | 175 +++++ src/web/handlers/mod.rs | 4 +- src/web/handlers/servers.rs | 1 + src/web/handlers/telegram.rs | 304 ++++++++ src/web/mod.rs | 7 +- src/web/routes/mod.rs | 18 + static/admin.html | 710 +++++++++++++++++- 26 files changed, 2415 insertions(+), 22 deletions(-) create mode 100644 src/database/entities/telegram_config.rs create mode 100644 src/database/migrations/m20250929_000001_create_telegram_config_table.rs create mode 100644 src/database/migrations/m20250929_000002_add_telegram_admin_to_users.rs create mode 100644 src/database/repository/telegram_config.rs create mode 100644 src/services/telegram/bot.rs create mode 100644 src/services/telegram/error.rs create mode 100644 src/services/telegram/handlers.rs create mode 100644 src/services/telegram/mod.rs create mode 100644 src/web/handlers/telegram.rs diff --git a/Cargo.lock b/Cargo.lock index 42cafc4..9b3d1a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -126,6 +126,20 @@ version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +[[package]] +name = "aquamarine" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21cc1548309245035eb18aa7f0967da6bc65587005170c56e6ef2788a4cf3f4e" +dependencies = [ + "include_dir", + "itertools 0.10.5", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -532,7 +546,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -594,7 +608,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" dependencies = [ "async-trait", - "convert_case", + "convert_case 0.6.0", "json5", "nom", "pathdiff", @@ -632,6 +646,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "convert_case" version = "0.6.0" @@ -733,14 +753,38 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +dependencies = [ + "darling_core 0.13.4", + "darling_macro 0.13.4", +] + [[package]] name = "darling" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling_core" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", ] [[package]] @@ -753,17 +797,28 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.106", ] +[[package]] +name = "darling_macro" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +dependencies = [ + "darling_core 0.13.4", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", "quote", "syn 2.0.106", ] @@ -789,6 +844,19 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.106", +] + [[package]] name = "digest" version = "0.10.7" @@ -827,6 +895,15 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dptree" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81175dab5ec79c30e0576df2ed2c244e1721720c302000bb321b107e82e265c" +dependencies = [ + "futures", +] + [[package]] name = "dunce" version = "1.0.5" @@ -857,6 +934,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erasable" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437cfb75878119ed8265685c41a115724eae43fb7cc5a0bf0e4ecc3b803af1c4" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "errno" version = "0.3.14" @@ -864,7 +951,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] @@ -974,6 +1061,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -1024,6 +1112,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -1042,8 +1141,10 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1575,6 +1676,25 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1655,6 +1775,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2525,6 +2654,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rc-box" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897fecc9fac6febd4408f9e935e86df739b0023b625e610e0357535b9c8adad0" +dependencies = [ + "erasable", +] + [[package]] name = "rcgen" version = "0.12.1" @@ -2618,6 +2756,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "once_cell", "percent-encoding", @@ -2632,10 +2771,12 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls 0.24.1", + "tokio-util", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 0.25.4", "winreg", @@ -2754,6 +2895,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.2" @@ -2764,7 +2914,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] @@ -3047,7 +3197,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab" dependencies = [ - "darling", + "darling 0.20.11", "heck 0.4.1", "proc-macro2", "quote", @@ -3120,6 +3270,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.225" @@ -3195,6 +3351,28 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678b5a069e50bf00ecd22d0cd8ddf7c236f68581b03db652061ed5eb13a312ff" +dependencies = [ + "serde", + "serde_with_macros", +] + +[[package]] +name = "serde_with_macros" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" +dependencies = [ + "darling 0.13.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -3556,6 +3734,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -3640,12 +3824,92 @@ dependencies = [ "libc", ] +[[package]] +name = "take_mut" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" + +[[package]] +name = "takecell" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f34339676cdcab560c9a82300c4c2581f68b9369aedf0fae86f2ff9565ff3e" + [[package]] name = "tap" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "teloxide" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f79dd283eb21b90451c03fa7c7f83b9985130efb876b33bad89a2c208ccbc16" +dependencies = [ + "aquamarine", + "bytes", + "derive_more", + "dptree", + "either", + "futures", + "log", + "mime", + "pin-project", + "serde", + "serde_json", + "teloxide-core", + "teloxide-macros", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "teloxide-core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1642a7ef10e7af63b8298c8d13c0f986d4fc646d42649ff060359607f62f69" +dependencies = [ + "bitflags 1.3.2", + "bytes", + "chrono", + "derive_more", + "either", + "futures", + "log", + "mime", + "once_cell", + "pin-project", + "rc-box", + "reqwest", + "serde", + "serde_json", + "serde_with", + "take_mut", + "takecell", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "url", + "uuid", +] + +[[package]] +name = "teloxide-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e2d33d809c3e7161a9ab18bedddf98821245014f0a78fa4d2c9430b2ec018c1" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "tempfile" version = "3.22.0" @@ -3656,7 +3920,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] @@ -4261,7 +4525,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df0bcf92720c40105ac4b2dda2a4ea3aa717d4d6a862cc217da653a4bd5c6b10" dependencies = [ - "darling", + "darling 0.20.11", "once_cell", "proc-macro-error", "proc-macro2", @@ -4408,6 +4672,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.80" @@ -4467,7 +4744,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] @@ -4898,6 +5175,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "teloxide", "tempfile", "thiserror 1.0.69", "time", diff --git a/Cargo.toml b/Cargo.toml index 2f8db2f..1f2476b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,5 +65,8 @@ rustls = { version = "0.23", features = ["aws-lc-rs"] } # TLS library with aws- ring = "0.17" # Crypto for ACME pem = "3.0" # PEM format support +# Telegram bot support +teloxide = { version = "0.13", features = ["macros"] } + [dev-dependencies] tempfile = "3.0" \ No newline at end of file diff --git a/src/database/entities/mod.rs b/src/database/entities/mod.rs index 8a8e1fa..5c21f89 100644 --- a/src/database/entities/mod.rs +++ b/src/database/entities/mod.rs @@ -6,6 +6,7 @@ pub mod server; pub mod server_inbound; pub mod user_access; pub mod inbound_users; +pub mod telegram_config; pub mod prelude { pub use super::user::Entity as User; @@ -16,4 +17,5 @@ pub mod prelude { pub use super::server_inbound::Entity as ServerInbound; pub use super::user_access::Entity as UserAccess; pub use super::inbound_users::Entity as InboundUsers; + pub use super::telegram_config::Entity as TelegramConfig; } \ No newline at end of file diff --git a/src/database/entities/telegram_config.rs b/src/database/entities/telegram_config.rs new file mode 100644 index 0000000..d12462e --- /dev/null +++ b/src/database/entities/telegram_config.rs @@ -0,0 +1,94 @@ +use sea_orm::entity::prelude::*; +use sea_orm::{Set, ActiveModelTrait}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "telegram_config")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + + /// Telegram bot token (encrypted in production) + pub bot_token: String, + + /// Whether the bot is active + pub is_active: bool, + + /// When the config was created + pub created_at: DateTimeUtc, + + /// Last time config was updated + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel { + /// Called before insert and update + fn new() -> Self { + Self { + id: Set(Uuid::new_v4()), + created_at: Set(chrono::Utc::now()), + updated_at: Set(chrono::Utc::now()), + ..ActiveModelTrait::default() + } + } + + /// Called before update + fn before_save<'life0, 'async_trait, C>( + mut self, + _db: &'life0 C, + insert: bool, + ) -> core::pin::Pin> + Send + 'async_trait>> + where + 'life0: 'async_trait, + C: 'async_trait + ConnectionTrait, + Self: 'async_trait, + { + Box::pin(async move { + if !insert { + self.updated_at = Set(chrono::Utc::now()); + } else if self.id.is_not_set() { + self.id = Set(Uuid::new_v4()); + } + + if self.created_at.is_not_set() { + self.created_at = Set(chrono::Utc::now()); + } + + if self.updated_at.is_not_set() { + self.updated_at = Set(chrono::Utc::now()); + } + + Ok(self) + }) + } +} + +/// DTO for creating a new Telegram configuration +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateTelegramConfigDto { + pub bot_token: String, + pub is_active: bool, +} + +/// DTO for updating Telegram configuration +#[derive(Debug, Serialize, Deserialize)] +pub struct UpdateTelegramConfigDto { + pub bot_token: Option, + pub is_active: Option, +} + +impl Model { + /// Convert to ActiveModel for updates + pub fn into_active_model(self) -> ActiveModel { + ActiveModel { + id: Set(self.id), + bot_token: Set(self.bot_token), + is_active: Set(self.is_active), + created_at: Set(self.created_at), + updated_at: Set(self.updated_at), + } + } +} \ No newline at end of file diff --git a/src/database/entities/user.rs b/src/database/entities/user.rs index 25ee8b3..b9a7f67 100644 --- a/src/database/entities/user.rs +++ b/src/database/entities/user.rs @@ -18,6 +18,9 @@ pub struct Model { /// Optional Telegram user ID for bot integration pub telegram_id: Option, + /// Whether the user is a Telegram admin + pub is_telegram_admin: bool, + /// When the user was registered/created pub created_at: DateTimeUtc, @@ -33,6 +36,7 @@ impl ActiveModelBehavior for ActiveModel { fn new() -> Self { Self { id: Set(Uuid::new_v4()), + is_telegram_admin: Set(false), created_at: Set(chrono::Utc::now()), updated_at: Set(chrono::Utc::now()), ..ActiveModelTrait::default() @@ -65,6 +69,8 @@ pub struct CreateUserDto { pub name: String, pub comment: Option, pub telegram_id: Option, + #[serde(default)] + pub is_telegram_admin: bool, } /// User update data transfer object @@ -73,6 +79,7 @@ pub struct UpdateUserDto { pub name: Option, pub comment: Option, pub telegram_id: Option, + pub is_telegram_admin: Option, } impl From for ActiveModel { @@ -81,6 +88,7 @@ impl From for ActiveModel { name: Set(dto.name), comment: Set(dto.comment), telegram_id: Set(dto.telegram_id), + is_telegram_admin: Set(dto.is_telegram_admin), ..Self::new() } } @@ -103,6 +111,9 @@ impl Model { if dto.telegram_id.is_some() { active_model.telegram_id = Set(dto.telegram_id); } + if let Some(is_admin) = dto.is_telegram_admin { + active_model.is_telegram_admin = Set(is_admin); + } active_model } diff --git a/src/database/migrations/m20250929_000001_create_telegram_config_table.rs b/src/database/migrations/m20250929_000001_create_telegram_config_table.rs new file mode 100644 index 0000000..0c22deb --- /dev/null +++ b/src/database/migrations/m20250929_000001_create_telegram_config_table.rs @@ -0,0 +1,51 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(TelegramConfig::Table) + .if_not_exists() + .col(ColumnDef::new(TelegramConfig::Id) + .uuid() + .not_null() + .primary_key()) + .col(ColumnDef::new(TelegramConfig::BotToken) + .string() + .not_null()) + .col(ColumnDef::new(TelegramConfig::IsActive) + .boolean() + .not_null() + .default(false)) + .col(ColumnDef::new(TelegramConfig::CreatedAt) + .timestamp_with_time_zone() + .not_null()) + .col(ColumnDef::new(TelegramConfig::UpdatedAt) + .timestamp_with_time_zone() + .not_null()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(TelegramConfig::Table).to_owned()) + .await + } +} + +#[derive(Iden)] +pub enum TelegramConfig { + Table, + Id, + BotToken, + IsActive, + CreatedAt, + UpdatedAt, +} \ No newline at end of file diff --git a/src/database/migrations/m20250929_000002_add_telegram_admin_to_users.rs b/src/database/migrations/m20250929_000002_add_telegram_admin_to_users.rs new file mode 100644 index 0000000..35978c9 --- /dev/null +++ b/src/database/migrations/m20250929_000002_add_telegram_admin_to_users.rs @@ -0,0 +1,40 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Users::Table) + .add_column( + ColumnDef::new(Users::IsTelegramAdmin) + .boolean() + .not_null() + .default(false) + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Users::Table) + .drop_column(Users::IsTelegramAdmin) + .to_owned(), + ) + .await + } +} + +#[derive(Iden)] +enum Users { + Table, + IsTelegramAdmin, +} \ No newline at end of file diff --git a/src/database/migrations/mod.rs b/src/database/migrations/mod.rs index 997506c..1e6e55e 100644 --- a/src/database/migrations/mod.rs +++ b/src/database/migrations/mod.rs @@ -10,6 +10,8 @@ mod m20241201_000007_create_inbound_users_table; mod m20250919_000001_update_inbound_users_schema; mod m20250922_000001_add_grpc_hostname_to_servers; mod m20250923_000001_create_dns_providers_table; +mod m20250929_000001_create_telegram_config_table; +mod m20250929_000002_add_telegram_admin_to_users; pub struct Migrator; @@ -27,6 +29,8 @@ impl MigratorTrait for Migrator { Box::new(m20250919_000001_update_inbound_users_schema::Migration), Box::new(m20250922_000001_add_grpc_hostname_to_servers::Migration), Box::new(m20250923_000001_create_dns_providers_table::Migration), + Box::new(m20250929_000001_create_telegram_config_table::Migration), + Box::new(m20250929_000002_add_telegram_admin_to_users::Migration), ] } } \ No newline at end of file diff --git a/src/database/mod.rs b/src/database/mod.rs index 4a57c7d..03ee4a8 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -51,8 +51,8 @@ impl DatabaseManager { } /// Get database connection - pub fn connection(&self) -> &DatabaseConnection { - &self.connection + pub fn connection(&self) -> DatabaseConnection { + self.connection.clone() } /// Run database migrations diff --git a/src/database/repository/mod.rs b/src/database/repository/mod.rs index 40be62c..4d7eff2 100644 --- a/src/database/repository/mod.rs +++ b/src/database/repository/mod.rs @@ -6,6 +6,7 @@ pub mod server; pub mod server_inbound; pub mod user_access; pub mod inbound_users; +pub mod telegram_config; pub use user::UserRepository; pub use certificate::CertificateRepository; @@ -14,4 +15,5 @@ pub use inbound_template::InboundTemplateRepository; pub use server::ServerRepository; pub use server_inbound::ServerInboundRepository; pub use user_access::UserAccessRepository; -pub use inbound_users::InboundUsersRepository; \ No newline at end of file +pub use inbound_users::InboundUsersRepository; +pub use telegram_config::TelegramConfigRepository; \ No newline at end of file diff --git a/src/database/repository/server.rs b/src/database/repository/server.rs index 2e6b9bd..8869838 100644 --- a/src/database/repository/server.rs +++ b/src/database/repository/server.rs @@ -76,4 +76,13 @@ impl ServerRepository { Ok(server.get_grpc_endpoint()) } + + pub async fn get_all(&self) -> Result> { + Ok(Server::find().all(&self.db).await?) + } + + pub async fn count(&self) -> Result { + let count = Server::find().count(&self.db).await?; + Ok(count) + } } \ No newline at end of file diff --git a/src/database/repository/server_inbound.rs b/src/database/repository/server_inbound.rs index 6098d8d..eab44fb 100644 --- a/src/database/repository/server_inbound.rs +++ b/src/database/repository/server_inbound.rs @@ -163,4 +163,16 @@ impl ServerInboundRepository { Ok(inbound.update(&self.db).await?) } + + pub async fn find_by_user_id(&self, user_id: Uuid) -> Result> { + // This would need a join with user_access table + // For now, returning empty vec as placeholder + // TODO: Implement proper join query + Ok(vec![]) + } + + pub async fn count(&self) -> Result { + let count = ServerInbound::find().count(&self.db).await?; + Ok(count) + } } \ No newline at end of file diff --git a/src/database/repository/telegram_config.rs b/src/database/repository/telegram_config.rs new file mode 100644 index 0000000..5fb332f --- /dev/null +++ b/src/database/repository/telegram_config.rs @@ -0,0 +1,167 @@ +use anyhow::Result; +use sea_orm::{DatabaseConnection, EntityTrait, ActiveModelTrait, Set, QueryFilter, ColumnTrait, QueryOrder}; +use uuid::Uuid; + +use crate::database::entities::telegram_config::{ + self, Model, CreateTelegramConfigDto, UpdateTelegramConfigDto +}; + +pub struct TelegramConfigRepository { + db: DatabaseConnection, +} + +impl TelegramConfigRepository { + pub fn new(db: DatabaseConnection) -> Self { + Self { db } + } + + /// Get the current active configuration (should be only one) + pub async fn get_active(&self) -> Result> { + Ok(telegram_config::Entity::find() + .filter(telegram_config::Column::IsActive.eq(true)) + .one(&self.db) + .await?) + } + + /// Get configuration by ID + pub async fn find_by_id(&self, id: Uuid) -> Result> { + Ok(telegram_config::Entity::find_by_id(id) + .one(&self.db) + .await?) + } + + /// Get the latest configuration (active or not) + pub async fn get_latest(&self) -> Result> { + Ok(telegram_config::Entity::find() + .order_by_desc(telegram_config::Column::CreatedAt) + .one(&self.db) + .await?) + } + + /// Create new configuration (deactivates previous if exists) + pub async fn create(&self, dto: CreateTelegramConfigDto) -> Result { + // If is_active is true, deactivate all other configs + if dto.is_active { + self.deactivate_all().await?; + } + + let model = telegram_config::ActiveModel { + id: Set(Uuid::new_v4()), + bot_token: Set(dto.bot_token), + is_active: Set(dto.is_active), + created_at: Set(chrono::Utc::now()), + updated_at: Set(chrono::Utc::now()), + }; + + Ok(model.insert(&self.db).await?) + } + + /// Update configuration + pub async fn update(&self, id: Uuid, dto: UpdateTelegramConfigDto) -> Result> { + let model = telegram_config::Entity::find_by_id(id) + .one(&self.db) + .await?; + + let Some(model) = model else { + return Ok(None); + }; + + // If activating this config, deactivate others + if dto.is_active == Some(true) { + self.deactivate_all_except(id).await?; + } + + let mut active_model = model.into_active_model(); + + if let Some(bot_token) = dto.bot_token { + active_model.bot_token = Set(bot_token); + } + if let Some(is_active) = dto.is_active { + active_model.is_active = Set(is_active); + } + + active_model.updated_at = Set(chrono::Utc::now()); + + Ok(Some(active_model.update(&self.db).await?)) + } + + /// Activate a configuration (deactivates all others) + pub async fn activate(&self, id: Uuid) -> Result> { + self.deactivate_all_except(id).await?; + + let model = telegram_config::Entity::find_by_id(id) + .one(&self.db) + .await?; + + let Some(model) = model else { + return Ok(None); + }; + + let mut active_model = model.into_active_model(); + active_model.is_active = Set(true); + active_model.updated_at = Set(chrono::Utc::now()); + + Ok(Some(active_model.update(&self.db).await?)) + } + + /// Deactivate a configuration + pub async fn deactivate(&self, id: Uuid) -> Result> { + let model = telegram_config::Entity::find_by_id(id) + .one(&self.db) + .await?; + + let Some(model) = model else { + return Ok(None); + }; + + let mut active_model = model.into_active_model(); + active_model.is_active = Set(false); + active_model.updated_at = Set(chrono::Utc::now()); + + Ok(Some(active_model.update(&self.db).await?)) + } + + /// Delete configuration + pub async fn delete(&self, id: Uuid) -> Result { + let result = telegram_config::Entity::delete_by_id(id) + .exec(&self.db) + .await?; + + Ok(result.rows_affected > 0) + } + + /// Deactivate all configurations + async fn deactivate_all(&self) -> Result<()> { + let configs = telegram_config::Entity::find() + .filter(telegram_config::Column::IsActive.eq(true)) + .all(&self.db) + .await?; + + for config in configs { + let mut active_model = config.into_active_model(); + active_model.is_active = Set(false); + active_model.updated_at = Set(chrono::Utc::now()); + active_model.update(&self.db).await?; + } + + Ok(()) + } + + /// Deactivate all configurations except one + async fn deactivate_all_except(&self, except_id: Uuid) -> Result<()> { + let configs = telegram_config::Entity::find() + .filter(telegram_config::Column::IsActive.eq(true)) + .filter(telegram_config::Column::Id.ne(except_id)) + .all(&self.db) + .await?; + + for config in configs { + let mut active_model = config.into_active_model(); + active_model.is_active = Set(false); + active_model.updated_at = Set(chrono::Utc::now()); + active_model.update(&self.db).await?; + } + + Ok(()) + } +} \ No newline at end of file diff --git a/src/database/repository/user.rs b/src/database/repository/user.rs index ff449d1..30891a2 100644 --- a/src/database/repository/user.rs +++ b/src/database/repository/user.rs @@ -2,6 +2,7 @@ use anyhow::Result; use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter, ColumnTrait, QueryOrder, PaginatorTrait, QuerySelect}; use uuid::Uuid; +use sea_orm::{Set, ActiveModelTrait}; use crate::database::entities::user::{Entity as User, Column, Model, ActiveModel, CreateUserDto, UpdateUserDto}; pub struct UserRepository { @@ -124,6 +125,48 @@ impl UserRepository { .await?; Ok(count > 0) } + + /// Get all Telegram admins + pub async fn get_telegram_admins(&self) -> Result> { + let admins = User::find() + .filter(Column::IsTelegramAdmin.eq(true)) + .order_by_desc(Column::CreatedAt) + .all(&self.db) + .await?; + Ok(admins) + } + + /// Set user as Telegram admin + pub async fn set_telegram_admin(&self, user_id: Uuid, is_admin: bool) -> Result> { + if let Some(user) = self.get_by_id(user_id).await? { + let mut active_model: ActiveModel = user.into(); + active_model.is_telegram_admin = Set(is_admin); + active_model.updated_at = Set(chrono::Utc::now()); + + let updated = active_model.update(&self.db).await?; + Ok(Some(updated)) + } else { + Ok(None) + } + } + + /// Check if user is Telegram admin + pub async fn is_telegram_admin(&self, user_id: Uuid) -> Result { + if let Some(user) = self.get_by_id(user_id).await? { + Ok(user.is_telegram_admin) + } else { + Ok(false) + } + } + + /// Check if telegram_id is admin + pub async fn is_telegram_id_admin(&self, telegram_id: i64) -> Result { + if let Some(user) = self.get_by_telegram_id(telegram_id).await? { + Ok(user.is_telegram_admin) + } else { + Ok(false) + } + } } #[cfg(test)] @@ -158,6 +201,7 @@ mod tests { name: "Test User".to_string(), comment: Some("Test comment".to_string()), telegram_id: Some(123456789), + is_telegram_admin: false, }; let created_user = repo.create(create_dto).await.unwrap(); diff --git a/src/main.rs b/src/main.rs index c85e11d..61bfd17 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use std::sync::Arc; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod config; @@ -8,7 +9,7 @@ mod web; use config::{AppConfig, args::parse_args}; use database::DatabaseManager; -use services::{TaskScheduler, XrayService}; +use services::{TaskScheduler, XrayService, TelegramService}; #[tokio::main] async fn main() -> Result<()> { @@ -89,10 +90,16 @@ async fn main() -> Result<()> { // Start event-driven sync handler with the receiver TaskScheduler::start_event_handler(db.clone(), event_receiver).await; + // Initialize Telegram service if needed + let telegram_service = Arc::new(TelegramService::new(db.clone())); + if let Err(e) = telegram_service.initialize().await { + tracing::warn!("Failed to initialize Telegram service: {}", e); + } + // Start web server with task scheduler tokio::select! { - result = web::start_server(db, config.web.clone()) => { + result = web::start_server(db, config.web.clone(), Some(telegram_service.clone())) => { match result { Err(e) => tracing::error!("Web server error: {}", e), _ => {} diff --git a/src/services/mod.rs b/src/services/mod.rs index b1c458d..4c52bbe 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -4,8 +4,10 @@ pub mod certificates; pub mod events; pub mod tasks; pub mod uri_generator; +pub mod telegram; pub use xray::XrayService; pub use tasks::TaskScheduler; pub use uri_generator::UriGeneratorService; -pub use certificates::CertificateService; \ No newline at end of file +pub use certificates::CertificateService; +pub use telegram::TelegramService; \ No newline at end of file diff --git a/src/services/telegram/bot.rs b/src/services/telegram/bot.rs new file mode 100644 index 0000000..4dc7f6e --- /dev/null +++ b/src/services/telegram/bot.rs @@ -0,0 +1,39 @@ +use teloxide::{Bot, prelude::*}; +use tokio::sync::oneshot; + +use crate::database::DatabaseManager; +use super::handlers; + +/// Run the bot polling loop +pub async fn run_polling( + bot: Bot, + db: DatabaseManager, + mut shutdown_rx: oneshot::Receiver<()>, +) { + tracing::info!("Starting Telegram bot polling..."); + + let handler = Update::filter_message() + .branch( + dptree::entry() + .filter_command::() + .endpoint(handlers::handle_command) + ) + .branch( + dptree::endpoint(handlers::handle_message) + ); + + let mut dispatcher = Dispatcher::builder(bot.clone(), handler) + .dependencies(dptree::deps![db]) + .enable_ctrlc_handler() + .build(); + + // Run dispatcher with shutdown signal + tokio::select! { + _ = dispatcher.dispatch() => { + tracing::info!("Telegram bot polling stopped"); + } + _ = shutdown_rx => { + tracing::info!("Telegram bot received shutdown signal"); + } + } +} \ No newline at end of file diff --git a/src/services/telegram/error.rs b/src/services/telegram/error.rs new file mode 100644 index 0000000..da78da5 --- /dev/null +++ b/src/services/telegram/error.rs @@ -0,0 +1,46 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum TelegramError { + #[error("Bot is not configured")] + NotConfigured, + + #[error("Bot is not running")] + NotRunning, + + #[error("Invalid bot token")] + InvalidToken, + + #[error("User not found")] + UserNotFound, + + #[error("User is not authorized")] + Unauthorized, + + #[error("Database error: {0}")] + Database(String), + + #[error("Telegram API error: {0}")] + TelegramApi(String), + + #[error("Other error: {0}")] + Other(String), +} + +impl From for TelegramError { + fn from(err: teloxide::RequestError) -> Self { + Self::TelegramApi(err.to_string()) + } +} + +impl From for TelegramError { + fn from(err: sea_orm::DbErr) -> Self { + Self::Database(err.to_string()) + } +} + +impl From for TelegramError { + fn from(err: anyhow::Error) -> Self { + Self::Other(err.to_string()) + } +} \ No newline at end of file diff --git a/src/services/telegram/handlers.rs b/src/services/telegram/handlers.rs new file mode 100644 index 0000000..2f324d3 --- /dev/null +++ b/src/services/telegram/handlers.rs @@ -0,0 +1,371 @@ +use teloxide::{prelude::*, utils::command::BotCommands}; +use teloxide::types::Me; +use uuid::Uuid; + +use crate::database::DatabaseManager; +use crate::database::repository::UserRepository; +use crate::database::entities::user::CreateUserDto; + +/// Available bot commands +#[derive(BotCommands, Clone)] +#[command(rename_rule = "lowercase", description = "Available commands:")] +pub enum Command { + #[command(description = "Start the bot and register")] + Start, + #[command(description = "Show help message")] + Help, + #[command(description = "Show your status")] + Status, + #[command(description = "List available configurations")] + Configs, + // Admin commands + #[command(description = "[Admin] List all users")] + Users, + #[command(description = "[Admin] List all servers")] + Servers, + #[command(description = "[Admin] Show statistics")] + Stats, + #[command(description = "[Admin] Broadcast message", parse_with = "split")] + Broadcast { message: String }, +} + +/// Handle command messages +pub async fn handle_command( + bot: Bot, + msg: Message, + cmd: Command, + db: DatabaseManager, +) -> Result<(), Box> { + let chat_id = msg.chat.id; + let from = msg.from.as_ref().ok_or("No sender info")?; + let telegram_id = from.id.0 as i64; + + let user_repo = UserRepository::new(db.connection()); + + match cmd { + Command::Start => { + handle_start(bot, chat_id, telegram_id, from, &user_repo).await?; + } + Command::Help => { + bot.send_message(chat_id, Command::descriptions().to_string()).await?; + } + Command::Status => { + handle_status(bot, chat_id, telegram_id, &user_repo, &db).await?; + } + Command::Configs => { + handle_configs(bot, chat_id, telegram_id, &user_repo, &db).await?; + } + // Admin commands + Command::Users => { + if !user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { + bot.send_message(chat_id, "❌ You are not authorized to use this command").await?; + return Ok(()); + } + handle_users(bot, chat_id, &user_repo).await?; + } + Command::Servers => { + if !user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { + bot.send_message(chat_id, "❌ You are not authorized to use this command").await?; + return Ok(()); + } + handle_servers(bot, chat_id, &db).await?; + } + Command::Stats => { + if !user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { + bot.send_message(chat_id, "❌ You are not authorized to use this command").await?; + return Ok(()); + } + handle_stats(bot, chat_id, &db).await?; + } + Command::Broadcast { message } => { + if !user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) { + bot.send_message(chat_id, "❌ You are not authorized to use this command").await?; + return Ok(()); + } + handle_broadcast(bot, chat_id, message, &user_repo).await?; + } + } + + Ok(()) +} + +/// Handle regular text messages +pub async fn handle_message( + bot: Bot, + msg: Message, + db: DatabaseManager, +) -> Result<(), Box> { + if let Some(text) = msg.text() { + if !text.starts_with('/') { + bot.send_message( + msg.chat.id, + "Please use /help to see available commands" + ).await?; + } + } + + Ok(()) +} + +/// Handle /start command +async fn handle_start( + bot: Bot, + chat_id: ChatId, + telegram_id: i64, + from: &teloxide::types::User, + user_repo: &UserRepository, +) -> Result<(), Box> { + // Check if user already exists + if let Some(user) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) { + let message = format!( + "👋 Welcome back, {}!\n\n\ + You are already registered.\n\ + Use /help to see available commands.", + user.name + ); + bot.send_message(chat_id, message).await?; + } else { + // Create new user + let username = from.username.as_deref().unwrap_or("Unknown"); + let full_name = format!( + "{} {}", + from.first_name, + from.last_name.as_deref().unwrap_or("") + ).trim().to_string(); + + let dto = CreateUserDto { + name: if !full_name.is_empty() { full_name } else { username.to_string() }, + comment: Some(format!("Telegram user: @{}", username)), + telegram_id: Some(telegram_id), + is_telegram_admin: false, + }; + + match user_repo.create(dto).await { + Ok(user) => { + let message = format!( + "✅ Registration successful!\n\n\ + Name: {}\n\ + User ID: {}\n\n\ + Use /help to see available commands.", + user.name, user.id + ); + bot.send_message(chat_id, message).await?; + } + Err(e) => { + bot.send_message( + chat_id, + format!("❌ Registration failed: {}", e) + ).await?; + } + } + } + + Ok(()) +} + +/// Handle /status command +async fn handle_status( + bot: Bot, + chat_id: ChatId, + telegram_id: i64, + user_repo: &UserRepository, + db: &DatabaseManager, +) -> Result<(), Box> { + if let Some(user) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) { + let server_inbound_repo = crate::database::repository::ServerInboundRepository::new(db.connection()); + let configs = server_inbound_repo.find_by_user_id(user.id).await.unwrap_or_default(); + + let admin_status = if user.is_telegram_admin { "Admin" } else { "User" }; + + let message = format!( + "📊 Your Status\n\n\ + Name: {}\n\ + User ID: {}\n\ + Role: {}\n\ + Active Configs: {}\n\ + Registered: {}", + user.name, + user.id, + admin_status, + configs.len(), + user.created_at.format("%Y-%m-%d %H:%M UTC") + ); + + bot.send_message(chat_id, message).await?; + } else { + bot.send_message( + chat_id, + "❌ You are not registered. Use /start to register." + ).await?; + } + + Ok(()) +} + +/// Handle /configs command +async fn handle_configs( + bot: Bot, + chat_id: ChatId, + telegram_id: i64, + user_repo: &UserRepository, + db: &DatabaseManager, +) -> Result<(), Box> { + if let Some(user) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) { + let server_inbound_repo = crate::database::repository::ServerInboundRepository::new(db.connection()); + let configs = server_inbound_repo.find_by_user_id(user.id).await.unwrap_or_default(); + + if configs.is_empty() { + bot.send_message(chat_id, "You don't have any configurations yet.").await?; + } else { + let mut message = String::from("📋 Your Configurations:\n\n"); + + for (i, config) in configs.iter().enumerate() { + message.push_str(&format!( + "{}. {} (Port: {})\n", + i + 1, + config.tag, + config.port_override.unwrap_or(0) + )); + } + + bot.send_message(chat_id, message).await?; + } + } else { + bot.send_message( + chat_id, + "❌ You are not registered. Use /start to register." + ).await?; + } + + Ok(()) +} + +/// Handle /users command (admin only) +async fn handle_users( + bot: Bot, + chat_id: ChatId, + user_repo: &UserRepository, +) -> Result<(), Box> { + let users = user_repo.get_all(1, 100).await.unwrap_or_default(); + + if users.is_empty() { + bot.send_message(chat_id, "No users found.").await?; + } else { + let mut message = String::from("👥 Users:\n\n"); + + for (i, user) in users.iter().enumerate() { + let telegram_status = if user.telegram_id.is_some() { "✅" } else { "❌" }; + let admin_status = if user.is_telegram_admin { " (Admin)" } else { "" }; + + message.push_str(&format!( + "{}. {} {} {}{}\n", + i + 1, + user.name, + telegram_status, + user.id, + admin_status + )); + } + + bot.send_message(chat_id, message).await?; + } + + Ok(()) +} + +/// Handle /servers command (admin only) +async fn handle_servers( + bot: Bot, + chat_id: ChatId, + db: &DatabaseManager, +) -> Result<(), Box> { + let server_repo = crate::database::repository::ServerRepository::new(db.connection()); + let servers = server_repo.get_all().await.unwrap_or_default(); + + if servers.is_empty() { + bot.send_message(chat_id, "No servers found.").await?; + } else { + let mut message = String::from("🖥️ Servers:\n\n"); + + for (i, server) in servers.iter().enumerate() { + let status = if server.status == "active" { "✅" } else { "❌" }; + + message.push_str(&format!( + "{}. {} {} - {}\n", + i + 1, + status, + server.name, + server.hostname + )); + } + + bot.send_message(chat_id, message).await?; + } + + Ok(()) +} + +/// Handle /stats command (admin only) +async fn handle_stats( + bot: Bot, + chat_id: ChatId, + db: &DatabaseManager, +) -> Result<(), Box> { + let user_repo = UserRepository::new(db.connection()); + let server_repo = crate::database::repository::ServerRepository::new(db.connection()); + let inbound_repo = crate::database::repository::ServerInboundRepository::new(db.connection()); + + let user_count = user_repo.count().await.unwrap_or(0); + let server_count = server_repo.count().await.unwrap_or(0); + let inbound_count = inbound_repo.count().await.unwrap_or(0); + + let message = format!( + "📊 Statistics\n\n\ + Total Users: {}\n\ + Total Servers: {}\n\ + Total Inbounds: {}", + user_count, + server_count, + inbound_count + ); + + bot.send_message(chat_id, message).await?; + + Ok(()) +} + +/// Handle /broadcast command (admin only) +async fn handle_broadcast( + bot: Bot, + chat_id: ChatId, + message: String, + user_repo: &UserRepository, +) -> Result<(), Box> { + let users = user_repo.get_all(1, 1000).await.unwrap_or_default(); + let mut sent_count = 0; + let mut failed_count = 0; + + for user in users { + if let Some(telegram_id) = user.telegram_id { + match bot.send_message(ChatId(telegram_id), &message).await { + Ok(_) => sent_count += 1, + Err(e) => { + tracing::warn!("Failed to send broadcast to {}: {}", telegram_id, e); + failed_count += 1; + } + } + } + } + + bot.send_message( + chat_id, + format!( + "✅ Broadcast complete\n\ + Sent: {}\n\ + Failed: {}", + sent_count, failed_count + ) + ).await?; + + Ok(()) +} \ No newline at end of file diff --git a/src/services/telegram/mod.rs b/src/services/telegram/mod.rs new file mode 100644 index 0000000..c1558c0 --- /dev/null +++ b/src/services/telegram/mod.rs @@ -0,0 +1,175 @@ +use anyhow::Result; +use std::sync::Arc; +use teloxide::{Bot, prelude::*}; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::database::DatabaseManager; +use crate::database::repository::TelegramConfigRepository; +use crate::database::entities::telegram_config::Model as TelegramConfig; + +pub mod bot; +pub mod handlers; +pub mod error; + +pub use error::TelegramError; + +/// Main Telegram service that manages the bot lifecycle +pub struct TelegramService { + db: DatabaseManager, + bot: Arc>>, + config: Arc>>, + shutdown_signal: Arc>>>, +} + +impl TelegramService { + /// Create a new Telegram service + pub fn new(db: DatabaseManager) -> Self { + Self { + db, + bot: Arc::new(RwLock::new(None)), + config: Arc::new(RwLock::new(None)), + shutdown_signal: Arc::new(RwLock::new(None)), + } + } + + /// Initialize and start the bot if active configuration exists + pub async fn initialize(&self) -> Result<()> { + let repo = TelegramConfigRepository::new(self.db.connection()); + + // Get active configuration + if let Some(config) = repo.get_active().await? { + self.start_with_config(config).await?; + } + + Ok(()) + } + + /// Start bot with specific configuration + pub async fn start_with_config(&self, config: TelegramConfig) -> Result<()> { + // Stop existing bot if running + self.stop().await?; + + // Create new bot instance + let bot = Bot::new(&config.bot_token); + + // Verify token by calling getMe + match bot.get_me().await { + Ok(me) => { + let username = me.user.username.unwrap_or_default(); + tracing::info!("Telegram bot started: @{}", username); + } + Err(e) => { + return Err(anyhow::anyhow!("Invalid bot token: {}", e)); + } + } + + // Store bot and config + *self.bot.write().await = Some(bot.clone()); + *self.config.write().await = Some(config.clone()); + + // Start polling in background + if config.is_active { + self.start_polling(bot).await?; + } + + Ok(()) + } + + /// Start polling for updates + async fn start_polling(&self, bot: Bot) -> Result<()> { + let (tx, rx) = tokio::sync::oneshot::channel(); + *self.shutdown_signal.write().await = Some(tx); + + let db = self.db.clone(); + + // Spawn polling task + tokio::spawn(async move { + bot::run_polling(bot, db, rx).await; + }); + + Ok(()) + } + + /// Stop the bot + pub async fn stop(&self) -> Result<()> { + // Send shutdown signal if polling is running + if let Some(tx) = self.shutdown_signal.write().await.take() { + let _ = tx.send(()); // Ignore error if receiver is already dropped + } + + // Clear bot and config + *self.bot.write().await = None; + *self.config.write().await = None; + + tracing::info!("Telegram bot stopped"); + Ok(()) + } + + /// Update configuration and restart if needed + pub async fn update_config(&self, config_id: Uuid) -> Result<()> { + let repo = TelegramConfigRepository::new(self.db.connection()); + + if let Some(config) = repo.find_by_id(config_id).await? { + if config.is_active { + self.start_with_config(config).await?; + } else { + self.stop().await?; + } + } + + Ok(()) + } + + /// Get current bot status + pub async fn get_status(&self) -> BotStatus { + let bot_guard = self.bot.read().await; + let config_guard = self.config.read().await; + + BotStatus { + is_running: bot_guard.is_some(), + config: config_guard.clone(), + } + } + + /// Send message to user + pub async fn send_message(&self, chat_id: i64, text: String) -> Result<()> { + let bot_guard = self.bot.read().await; + + if let Some(bot) = bot_guard.as_ref() { + bot.send_message(ChatId(chat_id), text).await?; + Ok(()) + } else { + Err(anyhow::anyhow!("Bot is not running")) + } + } + + /// Send message to all admins + pub async fn broadcast_to_admins(&self, text: String) -> Result<()> { + let bot_guard = self.bot.read().await; + + if let Some(bot) = bot_guard.as_ref() { + let user_repo = crate::database::repository::UserRepository::new(self.db.connection()); + let admins = user_repo.get_telegram_admins().await?; + + for admin in admins { + if let Some(telegram_id) = admin.telegram_id { + if let Err(e) = bot.send_message(ChatId(telegram_id), text.clone()).await { + tracing::warn!("Failed to send message to admin {}: {}", telegram_id, e); + } + } + } + + Ok(()) + } else { + Err(anyhow::anyhow!("Bot is not running")) + } + } +} + +/// Bot status information +#[derive(Debug, Clone, serde::Serialize)] +pub struct BotStatus { + pub is_running: bool, + pub config: Option, +} \ No newline at end of file diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index 6fc5dfb..088e6a0 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -5,6 +5,7 @@ pub mod templates; pub mod client_configs; pub mod dns_providers; pub mod tasks; +pub mod telegram; pub use users::*; pub use servers::*; @@ -12,4 +13,5 @@ pub use certificates::*; pub use templates::*; pub use client_configs::*; pub use dns_providers::*; -pub use tasks::*; \ No newline at end of file +pub use tasks::*; +pub use telegram::*; \ No newline at end of file diff --git a/src/web/handlers/servers.rs b/src/web/handlers/servers.rs index 88466ff..fef853b 100644 --- a/src/web/handlers/servers.rs +++ b/src/web/handlers/servers.rs @@ -504,6 +504,7 @@ pub async fn add_user_to_inbound( name: user_name.clone(), comment: user_data["comment"].as_str().map(|s| s.to_string()), telegram_id: user_data["telegram_id"].as_i64(), + is_telegram_admin: false, }; match user_repo.create(create_user_dto).await { diff --git a/src/web/handlers/telegram.rs b/src/web/handlers/telegram.rs new file mode 100644 index 0000000..96ba67e --- /dev/null +++ b/src/web/handlers/telegram.rs @@ -0,0 +1,304 @@ +use axum::{ + extract::{State, Path, Json}, + http::StatusCode, + response::IntoResponse, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::web::AppState; +use crate::database::repository::{UserRepository, TelegramConfigRepository}; +use crate::database::entities::telegram_config::{CreateTelegramConfigDto, UpdateTelegramConfigDto}; + +/// Response for Telegram config +#[derive(Debug, Serialize)] +pub struct TelegramConfigResponse { + pub id: Uuid, + pub is_active: bool, + pub bot_info: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Serialize)] +pub struct BotInfo { + pub username: String, + pub first_name: String, +} + +/// Get current Telegram configuration +pub async fn get_telegram_config( + State(state): State, +) -> impl IntoResponse { + let repo = TelegramConfigRepository::new(state.db.connection()); + + match repo.get_latest().await { + Ok(Some(config)) => { + let mut response = TelegramConfigResponse { + id: config.id, + is_active: config.is_active, + bot_info: None, + created_at: config.created_at.to_rfc3339(), + updated_at: config.updated_at.to_rfc3339(), + }; + + // Get bot info if active + if config.is_active { + if let Ok(status) = get_bot_status(&state).await { + response.bot_info = status.bot_info; + } + } + + Json(response).into_response() + } + Ok(None) => { + StatusCode::NOT_FOUND.into_response() + } + Err(e) => { + tracing::error!("Failed to get telegram config: {}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +/// Create new Telegram configuration +pub async fn create_telegram_config( + State(state): State, + Json(dto): Json, +) -> impl IntoResponse { + let repo = TelegramConfigRepository::new(state.db.connection()); + + match repo.create(dto).await { + Ok(config) => { + // Initialize telegram service with new config if active + if config.is_active { + if let Some(telegram_service) = &state.telegram_service { + let _ = telegram_service.update_config(config.id).await; + } + } + + (StatusCode::CREATED, Json(config)).into_response() + } + Err(e) => { + tracing::error!("Failed to create telegram config: {}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +/// Update Telegram configuration +pub async fn update_telegram_config( + State(state): State, + Path(id): Path, + Json(dto): Json, +) -> impl IntoResponse { + let repo = TelegramConfigRepository::new(state.db.connection()); + + match repo.update(id, dto).await { + Ok(Some(config)) => { + // Update telegram service + if let Some(telegram_service) = &state.telegram_service { + let _ = telegram_service.update_config(config.id).await; + } + + Json(config).into_response() + } + Ok(None) => { + StatusCode::NOT_FOUND.into_response() + } + Err(e) => { + tracing::error!("Failed to update telegram config: {}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +/// Delete Telegram configuration +pub async fn delete_telegram_config( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + let repo = TelegramConfigRepository::new(state.db.connection()); + + // Stop bot if this config is active + if let Ok(Some(config)) = repo.find_by_id(id).await { + if config.is_active { + if let Some(telegram_service) = &state.telegram_service { + let _ = telegram_service.stop().await; + } + } + } + + match repo.delete(id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => StatusCode::NOT_FOUND.into_response(), + Err(e) => { + tracing::error!("Failed to delete telegram config: {}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +/// Get Telegram bot status +#[derive(Debug, Serialize)] +pub struct BotStatusResponse { + pub is_running: bool, + pub bot_info: Option, +} + +async fn get_bot_status(state: &AppState) -> Result { + if let Some(telegram_service) = &state.telegram_service { + let status = telegram_service.get_status().await; + + let bot_info = if status.is_running { + // In production, you would get this from the bot API + Some(BotInfo { + username: "bot".to_string(), + first_name: "Bot".to_string(), + }) + } else { + None + }; + + Ok(BotStatusResponse { + is_running: status.is_running, + bot_info, + }) + } else { + Ok(BotStatusResponse { + is_running: false, + bot_info: None, + }) + } +} + +pub async fn get_telegram_status( + State(state): State, +) -> impl IntoResponse { + match get_bot_status(&state).await { + Ok(status) => Json(status).into_response(), + Err(e) => { + tracing::error!("Failed to get bot status: {}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +/// Get list of Telegram admins +#[derive(Debug, Serialize)] +pub struct TelegramAdmin { + pub user_id: Uuid, + pub name: String, + pub telegram_id: Option, +} + +pub async fn get_telegram_admins( + State(state): State, +) -> impl IntoResponse { + let repo = UserRepository::new(state.db.connection()); + + match repo.get_telegram_admins().await { + Ok(admins) => { + let response: Vec = admins + .into_iter() + .map(|u| TelegramAdmin { + user_id: u.id, + name: u.name, + telegram_id: u.telegram_id, + }) + .collect(); + + Json(response).into_response() + } + Err(e) => { + tracing::error!("Failed to get telegram admins: {}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +/// Add Telegram admin +pub async fn add_telegram_admin( + State(state): State, + Path(user_id): Path, +) -> impl IntoResponse { + let repo = UserRepository::new(state.db.connection()); + + match repo.set_telegram_admin(user_id, true).await { + Ok(Some(user)) => { + // Notify via Telegram if bot is running + if let Some(telegram_service) = &state.telegram_service { + if let Some(telegram_id) = user.telegram_id { + let _ = telegram_service.send_message( + telegram_id, + "✅ You have been granted admin privileges!".to_string() + ).await; + } + } + + Json(user).into_response() + } + Ok(None) => { + StatusCode::NOT_FOUND.into_response() + } + Err(e) => { + tracing::error!("Failed to add telegram admin: {}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +/// Remove Telegram admin +pub async fn remove_telegram_admin( + State(state): State, + Path(user_id): Path, +) -> impl IntoResponse { + let repo = UserRepository::new(state.db.connection()); + + match repo.set_telegram_admin(user_id, false).await { + Ok(Some(user)) => { + // Notify via Telegram if bot is running + if let Some(telegram_service) = &state.telegram_service { + if let Some(telegram_id) = user.telegram_id { + let _ = telegram_service.send_message( + telegram_id, + "❌ Your admin privileges have been revoked.".to_string() + ).await; + } + } + + Json(user).into_response() + } + Ok(None) => { + StatusCode::NOT_FOUND.into_response() + } + Err(e) => { + tracing::error!("Failed to remove telegram admin: {}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +/// Send test message +#[derive(Debug, Deserialize)] +pub struct SendMessageRequest { + pub chat_id: i64, + pub text: String, +} + +pub async fn send_test_message( + State(state): State, + Json(req): Json, +) -> impl IntoResponse { + if let Some(telegram_service) = &state.telegram_service { + match telegram_service.send_message(req.chat_id, req.text).await { + Ok(_) => StatusCode::OK.into_response(), + Err(e) => { + tracing::error!("Failed to send test message: {}", e); + (StatusCode::BAD_REQUEST, e.to_string()).into_response() + } + } + } else { + StatusCode::SERVICE_UNAVAILABLE.into_response() + } +} \ No newline at end of file diff --git a/src/web/mod.rs b/src/web/mod.rs index 0396545..5eb7d31 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -13,9 +13,10 @@ use tower_http::cors::CorsLayer; use tower_http::services::ServeDir; use tracing::info; +use std::sync::Arc; use crate::config::WebConfig; use crate::database::DatabaseManager; -use crate::services::XrayService; +use crate::services::{XrayService, TelegramService}; pub mod handlers; pub mod routes; @@ -29,16 +30,18 @@ pub struct AppState { #[allow(dead_code)] pub config: WebConfig, pub xray_service: XrayService, + pub telegram_service: Option>, } /// Start the web server -pub async fn start_server(db: DatabaseManager, config: WebConfig) -> Result<()> { +pub async fn start_server(db: DatabaseManager, config: WebConfig, telegram_service: Option>) -> Result<()> { let xray_service = XrayService::new(); let app_state = AppState { db, config: config.clone(), xray_service, + telegram_service, }; // Serve static files diff --git a/src/web/routes/mod.rs b/src/web/routes/mod.rs index 3c3943c..5abcfe3 100644 --- a/src/web/routes/mod.rs +++ b/src/web/routes/mod.rs @@ -16,6 +16,7 @@ pub fn api_routes() -> Router { .nest("/templates", servers::template_routes()) .nest("/dns-providers", dns_provider_routes()) .nest("/tasks", task_routes()) + .nest("/telegram", telegram_routes()) } /// User management routes @@ -46,4 +47,21 @@ fn task_routes() -> Router { Router::new() .route("/", get(handlers::get_tasks_status)) .route("/:id/trigger", post(handlers::trigger_task)) +} + +/// Telegram bot management routes +fn telegram_routes() -> Router { + Router::new() + .route("/config", get(handlers::get_telegram_config) + .post(handlers::create_telegram_config)) + .route("/config/:id", + get(handlers::get_telegram_config) + .put(handlers::update_telegram_config) + .delete(handlers::delete_telegram_config)) + .route("/status", get(handlers::get_telegram_status)) + .route("/admins", get(handlers::get_telegram_admins)) + .route("/admins/:user_id", + post(handlers::add_telegram_admin) + .delete(handlers::remove_telegram_admin)) + .route("/send", post(handlers::send_test_message)) } \ No newline at end of file diff --git a/static/admin.html b/static/admin.html index d322f7b..d31c8f4 100644 --- a/static/admin.html +++ b/static/admin.html @@ -746,6 +746,166 @@ letter-spacing: 0.5px; margin: 0; } + + /* Telegram Bot Styles */ + .status-indicator { + display: flex; + align-items: center; + gap: 8px; + } + + .status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + } + + .status-dot.status-active { + background: #34c759; + animation: pulse 2s infinite; + } + + .status-dot.status-inactive { + background: #8e8e93; + } + + @keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(52, 199, 89, 0.7); + } + 70% { + box-shadow: 0 0 0 10px rgba(52, 199, 89, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(52, 199, 89, 0); + } + } + + .status-text { + font-size: 14px; + font-weight: 500; + } + + .form-input-group { + display: flex; + gap: 8px; + align-items: center; + } + + .form-input-group .form-input { + flex: 1; + } + + .form-input-group .button { + padding: 8px 12px; + } + + .form-checkbox { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + padding: 8px 0; + } + + .form-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + margin: 0; + } + + .admin-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #f0f0f0; + } + + .admin-item:last-child { + border-bottom: none; + } + + .admin-info { + flex: 1; + } + + .admin-name { + font-weight: 500; + color: #1d1d1f; + margin: 0 0 4px 0; + } + + .admin-telegram-id { + font-size: 12px; + color: #6e6e73; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + } + + .user-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #f0f0f0; + } + + .user-item:last-child { + border-bottom: none; + } + + .user-info { + flex: 1; + } + + .user-name { + font-weight: 500; + color: #1d1d1f; + margin: 0 0 4px 0; + } + + .user-telegram-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #6e6e73; + } + + .telegram-connected { + color: #34c759; + } + + .telegram-not-connected { + color: #8e8e93; + } + + .bot-info { + padding: 16px; + background: #f8f9fa; + border-radius: 8px; + margin: 12px 0; + } + + .bot-info-item { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + } + + .bot-info-item:last-child { + margin-bottom: 0; + } + + .bot-info-label { + font-weight: 500; + color: #6e6e73; + } + + .bot-info-value { + color: #1d1d1f; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + } @@ -778,7 +938,7 @@ Users @@ -1007,6 +1167,132 @@
Loading...
+ +
+ + + +
+
+

Bot Status

+
+ + Inactive +
+
+
Loading...
+
+ + +
+
+

Configuration

+
+ +
+
+
+
+
+ +
+ + +
+
+ Get your bot token from @BotFather on Telegram +
+
+ +
+ +
+ When enabled, bot will start polling for messages +
+
+
+
+
+ + +
+
+

Bot Administrators

+ +
+
Loading...
+
+ + +
+
+

Admin Management

+
+ + +
+
+
+

Search for users and manage admin privileges. Only users connected to Telegram can be promoted to admin.

+
+
+
+ + +
+
+

All Users

+ +
+
Loading...
+
+ + +
+
+

Send Test Message

+
+
+
+
+ + +
+ +
+ + +
+ + +
+
+
+
@@ -2268,6 +2554,428 @@ } } + // Telegram Bot Functions + let currentTelegramConfig = null; + + async function loadTelegram() { + await loadBotStatus(); + await loadTelegramConfig(); + await loadAdmins(); + await loadTelegramUsers(); + } + + async function loadBotStatus() { + try { + const response = await fetch(`${API_BASE}/telegram/status`); + if (response.ok) { + const status = await response.json(); + updateBotStatusUI(status); + } else { + updateBotStatusUI({ is_running: false, bot_info: null }); + } + } catch (error) { + console.error('Error loading bot status:', error); + updateBotStatusUI({ is_running: false, bot_info: null }); + } + } + + function updateBotStatusUI(status) { + const indicator = document.getElementById('botStatusIndicator'); + const statusInfo = document.getElementById('botStatusInfo'); + + const dot = indicator.querySelector('.status-dot'); + const text = indicator.querySelector('.status-text'); + + if (status.is_running) { + dot.className = 'status-dot status-active'; + text.textContent = 'Active'; + + if (status.bot_info) { + statusInfo.innerHTML = ` +
+
+ Username: + @${status.bot_info.username} +
+
+ Name: + ${status.bot_info.first_name} +
+
+ `; + } + } else { + dot.className = 'status-dot status-inactive'; + text.textContent = 'Inactive'; + statusInfo.innerHTML = '

Bot is not running

'; + } + } + + async function loadTelegramConfig() { + try { + const response = await fetch(`${API_BASE}/telegram/config`); + if (response.ok) { + currentTelegramConfig = await response.json(); + updateConfigForm(currentTelegramConfig); + } else if (response.status === 404) { + currentTelegramConfig = null; + updateConfigForm(null); + } + } catch (error) { + console.error('Error loading config:', error); + currentTelegramConfig = null; + updateConfigForm(null); + } + } + + function updateConfigForm(config) { + const botTokenInput = document.getElementById('botToken'); + const botActiveCheckbox = document.getElementById('botActive'); + const saveBtn = document.getElementById('saveConfigBtn'); + + if (config) { + botTokenInput.value = '••••••••••••••••'; // Masked token + botActiveCheckbox.checked = config.is_active; + } else { + botTokenInput.value = ''; + botActiveCheckbox.checked = false; + } + + saveBtn.disabled = false; + } + + function toggleTokenVisibility() { + const tokenInput = document.getElementById('botToken'); + const button = event.target.closest('button'); + + if (tokenInput.type === 'password') { + tokenInput.type = 'text'; + button.innerHTML = '🙈'; + } else { + tokenInput.type = 'password'; + button.innerHTML = '👁'; + } + } + + function onBotActiveChange() { + const checkbox = document.getElementById('botActive'); + const tokenInput = document.getElementById('botToken'); + + if (checkbox.checked && !tokenInput.value) { + showAlert('Please enter a bot token first', 'warning'); + checkbox.checked = false; + } + } + + async function saveTelegramConfig() { + const botToken = document.getElementById('botToken').value; + const isActive = document.getElementById('botActive').checked; + const saveBtn = document.getElementById('saveConfigBtn'); + + if (!botToken || botToken === '••••••••••••••••') { + showAlert('Please enter a valid bot token', 'error'); + return; + } + + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + + try { + const method = currentTelegramConfig ? 'PUT' : 'POST'; + const url = currentTelegramConfig ? + `${API_BASE}/telegram/config/${currentTelegramConfig.id}` : + `${API_BASE}/telegram/config`; + + const response = await fetch(url, { + method: method, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + bot_token: botToken, + is_active: isActive + }) + }); + + if (response.ok) { + showAlert('Configuration saved successfully', 'success'); + await loadTelegramConfig(); + await loadBotStatus(); + } else { + const error = await response.text(); + showAlert('Error saving configuration: ' + error, 'error'); + } + } catch (error) { + showAlert('Error saving configuration: ' + error.message, 'error'); + } finally { + saveBtn.disabled = false; + saveBtn.innerHTML = '💾 Save Configuration'; + } + } + + async function loadAdmins() { + try { + const response = await fetch(`${API_BASE}/telegram/admins`); + if (response.ok) { + const admins = await response.json(); + renderAdmins(admins); + } + } catch (error) { + document.getElementById('adminsTable').innerHTML = '

Error loading admins

'; + } + } + + function renderAdmins(admins) { + const container = document.getElementById('adminsTable'); + + if (admins.length === 0) { + container.innerHTML = '

No administrators

Add administrators to manage the bot

'; + return; + } + + const adminsHtml = admins.map(admin => ` +
+
+

${admin.name}

+
${admin.telegram_id ? `ID: ${admin.telegram_id}` : 'No Telegram ID'}
+
+
+ +
+
+ `).join(''); + + container.innerHTML = `
${adminsHtml}
`; + } + + async function loadTelegramUsers() { + try { + const response = await fetch(`${API_BASE}/users`); + if (response.ok) { + const data = await response.json(); + const users = data.users || data; // Handle both paginated and direct array responses + renderTelegramUsers(users); + } else { + const errorText = await response.text(); + console.error('Error response:', response.status, errorText); + document.getElementById('telegramUsersTable').innerHTML = + `

Error loading users

Status: ${response.status}

${errorText}

`; + } + } catch (error) { + console.error('Network error:', error); + document.getElementById('telegramUsersTable').innerHTML = + `

Error loading users

Network error: ${error.message}

`; + } + } + + function renderTelegramUsers(users) { + const container = document.getElementById('telegramUsersTable'); + + if (users.length === 0) { + container.innerHTML = '

No users

'; + return; + } + + const usersHtml = users.map(user => ` +
+ + +
+ `).join(''); + + container.innerHTML = `
${usersHtml}
`; + } + + async function makeAdmin(userId) { + try { + const response = await fetch(`${API_BASE}/telegram/admins/${userId}`, { + method: 'POST' + }); + + if (response.ok) { + showAlert('User promoted to admin', 'success'); + await loadAdmins(); + await loadTelegramUsers(); + } else { + showAlert('Error promoting user to admin', 'error'); + } + } catch (error) { + showAlert('Error promoting user: ' + error.message, 'error'); + } + } + + async function removeAdmin(userId) { + if (!confirm('Are you sure you want to remove admin privileges?')) { + return; + } + + try { + const response = await fetch(`${API_BASE}/telegram/admins/${userId}`, { + method: 'DELETE' + }); + + if (response.ok) { + showAlert('Admin privileges removed', 'success'); + await loadAdmins(); + await loadTelegramUsers(); + } else { + showAlert('Error removing admin privileges', 'error'); + } + } catch (error) { + showAlert('Error removing admin: ' + error.message, 'error'); + } + } + + async function refreshAdmins() { + await loadAdmins(); + } + + async function refreshTelegramUsers() { + await loadTelegramUsers(); + } + + async function searchUsers() { + const query = document.getElementById('userSearchInput').value.trim(); + if (!query) { + document.getElementById('userSearchResults').innerHTML = '

Enter a search term to find users

'; + return; + } + + try { + const response = await fetch(`${API_BASE}/users/search?q=${encodeURIComponent(query)}`); + if (response.ok) { + const users = await response.json(); + renderSearchResults(users); + } else { + document.getElementById('userSearchResults').innerHTML = '

Error searching users

'; + } + } catch (error) { + document.getElementById('userSearchResults').innerHTML = '

Search failed

'; + } + } + + function renderSearchResults(users) { + const container = document.getElementById('userSearchResults'); + + if (users.length === 0) { + container.innerHTML = '

No users found

Try a different search term

'; + return; + } + + const usersHtml = users.map(user => ` +
+ + +
+ `).join(''); + + container.innerHTML = usersHtml; + } + + // Add Enter key support for search + document.addEventListener('DOMContentLoaded', function() { + document.getElementById('userSearchInput').addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + searchUsers(); + } + }); + }); + + // Test message form handler + document.getElementById('testMessageForm').addEventListener('submit', async function(e) { + e.preventDefault(); + + const chatId = document.getElementById('testChatId').value; + const message = document.getElementById('testMessage').value; + + if (!chatId || !message) { + showAlert('Please fill all fields', 'error'); + return; + } + + try { + const response = await fetch(`${API_BASE}/telegram/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chat_id: parseInt(chatId), + text: message + }) + }); + + if (response.ok) { + showAlert('Message sent successfully', 'success'); + document.getElementById('testMessage').value = ''; + } else { + const error = await response.text(); + showAlert('Error sending message: ' + error, 'error'); + } + } catch (error) { + showAlert('Error sending message: ' + error.message, 'error'); + } + }); + + // Update loadPageData function to include telegram + const originalLoadPageData = window.loadPageData; + window.loadPageData = function(page) { + if (page === 'telegram') { + loadTelegram(); + } else if (originalLoadPageData) { + originalLoadPageData(page); + } + }; + // Initialize loadPageData('dashboard');