Added telegram

This commit is contained in:
Ultradesu
2025-10-18 15:49:49 +03:00
parent e4984dd29d
commit 42c8016d9c
26 changed files with 2415 additions and 22 deletions

302
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@@ -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<Box<dyn core::future::Future<Output = Result<Self, DbErr>> + 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<String>,
pub is_active: Option<bool>,
}
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),
}
}
}

View File

@@ -18,6 +18,9 @@ pub struct Model {
/// Optional Telegram user ID for bot integration
pub telegram_id: Option<i64>,
/// 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<String>,
pub telegram_id: Option<i64>,
#[serde(default)]
pub is_telegram_admin: bool,
}
/// User update data transfer object
@@ -73,6 +79,7 @@ pub struct UpdateUserDto {
pub name: Option<String>,
pub comment: Option<String>,
pub telegram_id: Option<i64>,
pub is_telegram_admin: Option<bool>,
}
impl From<CreateUserDto> for ActiveModel {
@@ -81,6 +88,7 @@ impl From<CreateUserDto> 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
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
pub use inbound_users::InboundUsersRepository;
pub use telegram_config::TelegramConfigRepository;

View File

@@ -76,4 +76,13 @@ impl ServerRepository {
Ok(server.get_grpc_endpoint())
}
pub async fn get_all(&self) -> Result<Vec<server::Model>> {
Ok(Server::find().all(&self.db).await?)
}
pub async fn count(&self) -> Result<u64> {
let count = Server::find().count(&self.db).await?;
Ok(count)
}
}

View File

@@ -163,4 +163,16 @@ impl ServerInboundRepository {
Ok(inbound.update(&self.db).await?)
}
pub async fn find_by_user_id(&self, user_id: Uuid) -> Result<Vec<server_inbound::Model>> {
// 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<u64> {
let count = ServerInbound::find().count(&self.db).await?;
Ok(count)
}
}

View File

@@ -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<Option<Model>> {
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<Option<Model>> {
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<Option<Model>> {
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<Model> {
// 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<Option<Model>> {
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<Option<Model>> {
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<Option<Model>> {
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<bool> {
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(())
}
}

View File

@@ -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<Vec<Model>> {
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<Option<Model>> {
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<bool> {
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<bool> {
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();

View File

@@ -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),
_ => {}

View File

@@ -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;
pub use certificates::CertificateService;
pub use telegram::TelegramService;

View File

@@ -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::<handlers::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");
}
}
}

View File

@@ -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<teloxide::RequestError> for TelegramError {
fn from(err: teloxide::RequestError) -> Self {
Self::TelegramApi(err.to_string())
}
}
impl From<sea_orm::DbErr> for TelegramError {
fn from(err: sea_orm::DbErr) -> Self {
Self::Database(err.to_string())
}
}
impl From<anyhow::Error> for TelegramError {
fn from(err: anyhow::Error) -> Self {
Self::Other(err.to_string())
}
}

View File

@@ -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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
// 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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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(())
}

View File

@@ -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<RwLock<Option<Bot>>>,
config: Arc<RwLock<Option<TelegramConfig>>>,
shutdown_signal: Arc<RwLock<Option<tokio::sync::oneshot::Sender<()>>>>,
}
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<TelegramConfig>,
}

View File

@@ -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::*;
pub use tasks::*;
pub use telegram::*;

View File

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

View File

@@ -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<BotInfo>,
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<AppState>,
) -> 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<AppState>,
Json(dto): Json<CreateTelegramConfigDto>,
) -> 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<AppState>,
Path(id): Path<Uuid>,
Json(dto): Json<UpdateTelegramConfigDto>,
) -> 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<AppState>,
Path(id): Path<Uuid>,
) -> 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<BotInfo>,
}
async fn get_bot_status(state: &AppState) -> Result<BotStatusResponse, String> {
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<AppState>,
) -> 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<i64>,
}
pub async fn get_telegram_admins(
State(state): State<AppState>,
) -> impl IntoResponse {
let repo = UserRepository::new(state.db.connection());
match repo.get_telegram_admins().await {
Ok(admins) => {
let response: Vec<TelegramAdmin> = 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<AppState>,
Path(user_id): Path<Uuid>,
) -> 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<AppState>,
Path(user_id): Path<Uuid>,
) -> 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<AppState>,
Json(req): Json<SendMessageRequest>,
) -> 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()
}
}

View File

@@ -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<Arc<TelegramService>>,
}
/// 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<Arc<TelegramService>>) -> Result<()> {
let xray_service = XrayService::new();
let app_state = AppState {
db,
config: config.clone(),
xray_service,
telegram_service,
};
// Serve static files

View File

@@ -16,6 +16,7 @@ pub fn api_routes() -> Router<AppState> {
.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<AppState> {
Router::new()
.route("/", get(handlers::get_tasks_status))
.route("/:id/trigger", post(handlers::trigger_task))
}
/// Telegram bot management routes
fn telegram_routes() -> Router<AppState> {
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))
}

View File

@@ -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;
}
</style>
</head>
<body>
@@ -778,7 +938,7 @@
<a href="#users" class="nav-link" onclick="showPage('users')">Users</a>
</li>
<li class="nav-item">
<a href="#tasks" class="nav-link" onclick="showPage('tasks')">Tasks</a>
<a href="#telegram" class="nav-link" onclick="showPage('telegram')">Telegram Bot</a>
</li>
</ul>
</nav>
@@ -1007,6 +1167,132 @@
<div id="tasksTable" class="loading">Loading...</div>
</div>
</section>
<section id="telegram" class="page-section">
<div class="page-header">
<h1 class="page-title">Telegram Bot</h1>
<p class="page-subtitle">Configure and manage Telegram bot integration</p>
</div>
<!-- Bot Status Card -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Bot Status</h2>
<div id="botStatusIndicator" class="status-indicator">
<span class="status-dot status-inactive"></span>
<span class="status-text">Inactive</span>
</div>
</div>
<div id="botStatusInfo" class="loading">Loading...</div>
</div>
<!-- Bot Configuration Card -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Configuration</h2>
<div class="card-actions">
<button id="saveConfigBtn" class="button button-primary" onclick="saveTelegramConfig()" disabled>
<span class="icon">💾</span>
Save Configuration
</button>
</div>
</div>
<div class="card-body">
<form id="telegramConfigForm" class="form">
<div class="form-group">
<label for="botToken" class="form-label">Bot Token</label>
<div class="form-input-group">
<input type="password" id="botToken" class="form-input" placeholder="Enter bot token from @BotFather">
<button type="button" class="button button-outline" onclick="toggleTokenVisibility()">
<span class="icon">👁</span>
</button>
</div>
<div class="form-help">
Get your bot token from @BotFather on Telegram
</div>
</div>
<div class="form-group">
<label class="form-checkbox">
<input type="checkbox" id="botActive" onchange="onBotActiveChange()">
<span class="checkmark"></span>
Enable Bot
</label>
<div class="form-help">
When enabled, bot will start polling for messages
</div>
</div>
</form>
</div>
</div>
<!-- Admins Management Card -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Bot Administrators</h2>
<button class="button button-outline" onclick="refreshAdmins()">
<span class="icon">🔄</span>
Refresh
</button>
</div>
<div id="adminsTable" class="loading">Loading...</div>
</div>
<!-- Admin Management Card -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Admin Management</h2>
<div class="form-input-group">
<input type="text" id="userSearchInput" class="form-input" placeholder="Search users by name, ID, or Telegram ID" style="min-width: 300px;">
<button class="button button-outline" onclick="searchUsers()">
<span class="icon">🔍</span>
Search
</button>
</div>
</div>
<div class="card-body">
<p class="text-muted">Search for users and manage admin privileges. Only users connected to Telegram can be promoted to admin.</p>
<div id="userSearchResults"></div>
</div>
</div>
<!-- Users List Card -->
<div class="card">
<div class="card-header">
<h2 class="card-title">All Users</h2>
<button class="button button-outline" onclick="refreshTelegramUsers()">
<span class="icon">🔄</span>
Refresh
</button>
</div>
<div id="telegramUsersTable" class="loading">Loading...</div>
</div>
<!-- Test Message Card -->
<div class="card">
<div class="card-header">
<h2 class="card-title">Send Test Message</h2>
</div>
<div class="card-body">
<form id="testMessageForm" class="form">
<div class="form-group">
<label for="testChatId" class="form-label">Chat ID</label>
<input type="number" id="testChatId" class="form-input" placeholder="Enter chat ID">
</div>
<div class="form-group">
<label for="testMessage" class="form-label">Message</label>
<textarea id="testMessage" class="form-input" rows="3" placeholder="Enter test message"></textarea>
</div>
<button type="submit" class="button button-primary">
<span class="icon">📤</span>
Send Message
</button>
</form>
</div>
</div>
</section>
</main>
</div>
@@ -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 = `
<div class="bot-info">
<div class="bot-info-item">
<span class="bot-info-label">Username:</span>
<span class="bot-info-value">@${status.bot_info.username}</span>
</div>
<div class="bot-info-item">
<span class="bot-info-label">Name:</span>
<span class="bot-info-value">${status.bot_info.first_name}</span>
</div>
</div>
`;
}
} else {
dot.className = 'status-dot status-inactive';
text.textContent = 'Inactive';
statusInfo.innerHTML = '<p class="empty-state-text">Bot is not running</p>';
}
}
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 = '<span class="icon">🙈</span>';
} else {
tokenInput.type = 'password';
button.innerHTML = '<span class="icon">👁</span>';
}
}
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 = '<span class="icon">💾</span> 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 = '<div class="empty-state"><h3>Error loading admins</h3></div>';
}
}
function renderAdmins(admins) {
const container = document.getElementById('adminsTable');
if (admins.length === 0) {
container.innerHTML = '<div class="empty-state"><h3>No administrators</h3><p>Add administrators to manage the bot</p></div>';
return;
}
const adminsHtml = admins.map(admin => `
<div class="admin-item">
<div class="admin-info">
<h4 class="admin-name">${admin.name}</h4>
<div class="admin-telegram-id">${admin.telegram_id ? `ID: ${admin.telegram_id}` : 'No Telegram ID'}</div>
</div>
<div class="admin-actions">
<button class="button button-outline button-small" onclick="removeAdmin('${admin.user_id}')">
<span class="icon">❌</span>
Remove
</button>
</div>
</div>
`).join('');
container.innerHTML = `<div class="admins-list">${adminsHtml}</div>`;
}
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 =
`<div class="empty-state"><h3>Error loading users</h3><p>Status: ${response.status}</p><p>${errorText}</p></div>`;
}
} catch (error) {
console.error('Network error:', error);
document.getElementById('telegramUsersTable').innerHTML =
`<div class="empty-state"><h3>Error loading users</h3><p>Network error: ${error.message}</p></div>`;
}
}
function renderTelegramUsers(users) {
const container = document.getElementById('telegramUsersTable');
if (users.length === 0) {
container.innerHTML = '<div class="empty-state"><h3>No users</h3></div>';
return;
}
const usersHtml = users.map(user => `
<div class="user-item">
<div class="user-info">
<h4 class="user-name">${user.name}</h4>
<div class="user-telegram-status">
${user.telegram_id ?
`<span class="telegram-connected">📱 Connected (ID: ${user.telegram_id})</span>` :
'<span class="telegram-not-connected">📱 Not connected</span>'
}
${user.is_telegram_admin ? '<span class="admin-badge">👑 Admin</span>' : ''}
</div>
</div>
<div class="user-actions">
${user.telegram_id && !user.is_telegram_admin ?
`<button class="button button-primary button-small" onclick="makeAdmin('${user.id}')">
<span class="icon">👑</span>
Make Admin
</button>` : ''
}
</div>
</div>
`).join('');
container.innerHTML = `<div class="users-list">${usersHtml}</div>`;
}
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 = '<p class="text-muted">Enter a search term to find users</p>';
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 = '<div class="empty-state"><h3>Error searching users</h3></div>';
}
} catch (error) {
document.getElementById('userSearchResults').innerHTML = '<div class="empty-state"><h3>Search failed</h3></div>';
}
}
function renderSearchResults(users) {
const container = document.getElementById('userSearchResults');
if (users.length === 0) {
container.innerHTML = '<div class="empty-state"><h3>No users found</h3><p>Try a different search term</p></div>';
return;
}
const usersHtml = users.map(user => `
<div class="user-item" style="border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin-bottom: 12px;">
<div class="user-info">
<h4 class="user-name">${user.name}</h4>
<div class="user-details" style="margin-top: 8px;">
<p style="margin: 4px 0; font-size: 14px; color: #6b7280;">ID: ${user.id}</p>
${user.telegram_id ?
`<p style="margin: 4px 0; font-size: 14px; color: #6b7280;">📱 Telegram ID: ${user.telegram_id}</p>` :
'<p style="margin: 4px 0; font-size: 14px; color: #ef4444;">📱 Not connected to Telegram</p>'
}
${user.is_telegram_admin ?
'<p style="margin: 4px 0; font-size: 14px; color: #059669;">👑 Current Admin</p>' :
'<p style="margin: 4px 0; font-size: 14px; color: #6b7280;">Regular User</p>'
}
${user.comment ? `<p style="margin: 4px 0; font-size: 14px; color: #6b7280;">Comment: ${user.comment}</p>` : ''}
</div>
</div>
<div class="user-actions" style="margin-top: 12px;">
${user.telegram_id && !user.is_telegram_admin ?
`<button class="button button-primary" onclick="makeAdmin('${user.id}')" style="margin-right: 8px;">
<span class="icon">👑</span>
Make Admin
</button>` : ''
}
${user.telegram_id && user.is_telegram_admin ?
`<button class="button button-danger" onclick="removeAdmin('${user.id}')">
<span class="icon">👑</span>
Remove Admin
</button>` : ''
}
${!user.telegram_id ?
'<span style="color: #6b7280; font-size: 14px;">User must connect to Telegram first</span>' : ''
}
</div>
</div>
`).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');
</script>