mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-10-24 17:29:08 +00:00
TG almost works
This commit is contained in:
@@ -7,6 +7,7 @@ pub mod server_inbound;
|
|||||||
pub mod user_access;
|
pub mod user_access;
|
||||||
pub mod inbound_users;
|
pub mod inbound_users;
|
||||||
pub mod telegram_config;
|
pub mod telegram_config;
|
||||||
|
pub mod user_request;
|
||||||
|
|
||||||
pub mod prelude {
|
pub mod prelude {
|
||||||
pub use super::user::Entity as User;
|
pub use super::user::Entity as User;
|
||||||
@@ -18,4 +19,5 @@ pub mod prelude {
|
|||||||
pub use super::user_access::Entity as UserAccess;
|
pub use super::user_access::Entity as UserAccess;
|
||||||
pub use super::inbound_users::Entity as InboundUsers;
|
pub use super::inbound_users::Entity as InboundUsers;
|
||||||
pub use super::telegram_config::Entity as TelegramConfig;
|
pub use super::telegram_config::Entity as TelegramConfig;
|
||||||
|
pub use super::user_request::Entity as UserRequest;
|
||||||
}
|
}
|
||||||
172
src/database/entities/user_request.rs
Normal file
172
src/database/entities/user_request.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "user_requests")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
pub telegram_id: i64,
|
||||||
|
pub telegram_username: Option<String>,
|
||||||
|
pub telegram_first_name: Option<String>,
|
||||||
|
pub telegram_last_name: Option<String>,
|
||||||
|
pub status: String, // pending, approved, declined
|
||||||
|
pub request_message: Option<String>,
|
||||||
|
pub response_message: Option<String>,
|
||||||
|
pub processed_by_user_id: Option<Uuid>,
|
||||||
|
pub processed_at: Option<DateTimeWithTimeZone>,
|
||||||
|
pub language: String, // User's language preference (en, ru)
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::user::Column::Id",
|
||||||
|
on_update = "Cascade",
|
||||||
|
on_delete = "SetNull"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::user::Entity",
|
||||||
|
from = "Column::ProcessedByUserId",
|
||||||
|
to = "super::user::Column::Id",
|
||||||
|
on_update = "Cascade",
|
||||||
|
on_delete = "SetNull"
|
||||||
|
)]
|
||||||
|
ProcessedByUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|
||||||
|
// Request status enum
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum RequestStatus {
|
||||||
|
Pending,
|
||||||
|
Approved,
|
||||||
|
Declined,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestStatus {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
RequestStatus::Pending => "pending",
|
||||||
|
RequestStatus::Approved => "approved",
|
||||||
|
RequestStatus::Declined => "declined",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"pending" => Some(RequestStatus::Pending),
|
||||||
|
"approved" => Some(RequestStatus::Approved),
|
||||||
|
"declined" => Some(RequestStatus::Declined),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Model {
|
||||||
|
pub fn get_status(&self) -> RequestStatus {
|
||||||
|
RequestStatus::from_str(&self.status).unwrap_or(RequestStatus::Pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_full_name(&self) -> String {
|
||||||
|
let mut parts = vec![];
|
||||||
|
if let Some(first) = &self.telegram_first_name {
|
||||||
|
parts.push(first.clone());
|
||||||
|
}
|
||||||
|
if let Some(last) = &self.telegram_last_name {
|
||||||
|
parts.push(last.clone());
|
||||||
|
}
|
||||||
|
if parts.is_empty() {
|
||||||
|
self.telegram_username.clone().unwrap_or_else(|| format!("User {}", self.telegram_id))
|
||||||
|
} else {
|
||||||
|
parts.join(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_telegram_link(&self) -> String {
|
||||||
|
if let Some(username) = &self.telegram_username {
|
||||||
|
format!("@{}", username)
|
||||||
|
} else {
|
||||||
|
format!("tg://user?id={}", self.telegram_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_language(&self) -> String {
|
||||||
|
self.language.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTOs for creating and updating user requests
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CreateUserRequestDto {
|
||||||
|
pub telegram_id: i64,
|
||||||
|
pub telegram_username: Option<String>,
|
||||||
|
pub telegram_first_name: Option<String>,
|
||||||
|
pub telegram_last_name: Option<String>,
|
||||||
|
pub request_message: Option<String>,
|
||||||
|
pub language: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct UpdateUserRequestDto {
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub response_message: Option<String>,
|
||||||
|
pub processed_by_user_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CreateUserRequestDto> for ActiveModel {
|
||||||
|
fn from(dto: CreateUserRequestDto) -> Self {
|
||||||
|
use sea_orm::ActiveValue::*;
|
||||||
|
|
||||||
|
ActiveModel {
|
||||||
|
id: Set(Uuid::new_v4()),
|
||||||
|
user_id: Set(None),
|
||||||
|
telegram_id: Set(dto.telegram_id),
|
||||||
|
telegram_username: Set(dto.telegram_username),
|
||||||
|
telegram_first_name: Set(dto.telegram_first_name),
|
||||||
|
telegram_last_name: Set(dto.telegram_last_name),
|
||||||
|
status: Set("pending".to_string()),
|
||||||
|
request_message: Set(dto.request_message),
|
||||||
|
response_message: Set(None),
|
||||||
|
processed_by_user_id: Set(None),
|
||||||
|
processed_at: Set(None),
|
||||||
|
language: Set(dto.language),
|
||||||
|
created_at: Set(chrono::Utc::now().into()),
|
||||||
|
updated_at: Set(chrono::Utc::now().into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Model {
|
||||||
|
pub fn apply_update(self, dto: UpdateUserRequestDto, processed_by: Uuid) -> ActiveModel {
|
||||||
|
use sea_orm::ActiveValue::*;
|
||||||
|
|
||||||
|
let mut active: ActiveModel = self.into();
|
||||||
|
|
||||||
|
if let Some(status) = dto.status {
|
||||||
|
active.status = Set(status);
|
||||||
|
active.processed_by_user_id = Set(Some(processed_by));
|
||||||
|
active.processed_at = Set(Some(chrono::Utc::now().into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(response) = dto.response_message {
|
||||||
|
active.response_message = Set(Some(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
active.updated_at = Set(chrono::Utc::now().into());
|
||||||
|
active
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
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> {
|
||||||
|
// Create user_requests table
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(UserRequests::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(UserRequests::Id)
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key()
|
||||||
|
.default(Expr::cust("gen_random_uuid()")),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(UserRequests::UserId)
|
||||||
|
.uuid()
|
||||||
|
.null(), // Can be null if user doesn't exist yet
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(UserRequests::TelegramId)
|
||||||
|
.big_integer()
|
||||||
|
.not_null()
|
||||||
|
.unique_key(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(UserRequests::TelegramUsername)
|
||||||
|
.string()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(UserRequests::TelegramFirstName)
|
||||||
|
.string()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(UserRequests::TelegramLastName)
|
||||||
|
.string()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(UserRequests::Status)
|
||||||
|
.string()
|
||||||
|
.not_null()
|
||||||
|
.default("pending"), // pending, approved, declined
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(UserRequests::RequestMessage)
|
||||||
|
.text()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(UserRequests::ResponseMessage)
|
||||||
|
.text()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(UserRequests::ProcessedByUserId)
|
||||||
|
.uuid()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(UserRequests::ProcessedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(UserRequests::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(UserRequests::UpdatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_user_requests_user")
|
||||||
|
.from(UserRequests::Table, UserRequests::UserId)
|
||||||
|
.to(Users::Table, Users::Id)
|
||||||
|
.on_delete(ForeignKeyAction::SetNull)
|
||||||
|
.on_update(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_user_requests_processed_by")
|
||||||
|
.from(UserRequests::Table, UserRequests::ProcessedByUserId)
|
||||||
|
.to(Users::Table, Users::Id)
|
||||||
|
.on_delete(ForeignKeyAction::SetNull)
|
||||||
|
.on_update(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Create index on telegram_id for faster lookups
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_user_requests_telegram_id")
|
||||||
|
.table(UserRequests::Table)
|
||||||
|
.col(UserRequests::TelegramId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Create index on status for filtering
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_user_requests_status")
|
||||||
|
.table(UserRequests::Table)
|
||||||
|
.col(UserRequests::Status)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Create trigger to update updated_at timestamp
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
CREATE OR REPLACE FUNCTION update_user_requests_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER user_requests_updated_at
|
||||||
|
BEFORE UPDATE ON user_requests
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_user_requests_updated_at();
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Drop trigger and function
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
DROP TRIGGER IF EXISTS user_requests_updated_at ON user_requests;
|
||||||
|
DROP FUNCTION IF EXISTS update_user_requests_updated_at();
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Drop table
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(UserRequests::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Iden)]
|
||||||
|
enum UserRequests {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
UserId,
|
||||||
|
TelegramId,
|
||||||
|
TelegramUsername,
|
||||||
|
TelegramFirstName,
|
||||||
|
TelegramLastName,
|
||||||
|
Status,
|
||||||
|
RequestMessage,
|
||||||
|
ResponseMessage,
|
||||||
|
ProcessedByUserId,
|
||||||
|
ProcessedAt,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Iden)]
|
||||||
|
enum Users {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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> {
|
||||||
|
// Drop the unique constraint on telegram_id
|
||||||
|
// This allows users to have multiple requests (e.g., if one was declined)
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
ALTER TABLE user_requests
|
||||||
|
DROP CONSTRAINT IF EXISTS user_requests_telegram_id_key;
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Re-add the unique constraint
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
ALTER TABLE user_requests
|
||||||
|
ADD CONSTRAINT user_requests_telegram_id_key UNIQUE (telegram_id);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
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> {
|
||||||
|
// Add language column to user_requests table
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(UserRequests::Table)
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(UserRequests::Language)
|
||||||
|
.string()
|
||||||
|
.default("en") // Default to English
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Remove language column from user_requests table
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(UserRequests::Table)
|
||||||
|
.drop_column(UserRequests::Language)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Iden)]
|
||||||
|
enum UserRequests {
|
||||||
|
Table,
|
||||||
|
Language,
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ mod m20250922_000001_add_grpc_hostname_to_servers;
|
|||||||
mod m20250923_000001_create_dns_providers_table;
|
mod m20250923_000001_create_dns_providers_table;
|
||||||
mod m20250929_000001_create_telegram_config_table;
|
mod m20250929_000001_create_telegram_config_table;
|
||||||
mod m20250929_000002_add_telegram_admin_to_users;
|
mod m20250929_000002_add_telegram_admin_to_users;
|
||||||
|
mod m20251018_000001_create_user_requests_table;
|
||||||
|
mod m20251018_000002_remove_unique_telegram_id;
|
||||||
|
mod m20251018_000003_add_language_to_user_requests;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -31,6 +34,9 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20250923_000001_create_dns_providers_table::Migration),
|
Box::new(m20250923_000001_create_dns_providers_table::Migration),
|
||||||
Box::new(m20250929_000001_create_telegram_config_table::Migration),
|
Box::new(m20250929_000001_create_telegram_config_table::Migration),
|
||||||
Box::new(m20250929_000002_add_telegram_admin_to_users::Migration),
|
Box::new(m20250929_000002_add_telegram_admin_to_users::Migration),
|
||||||
|
Box::new(m20251018_000001_create_user_requests_table::Migration),
|
||||||
|
Box::new(m20251018_000002_remove_unique_telegram_id::Migration),
|
||||||
|
Box::new(m20251018_000003_add_language_to_user_requests::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,6 +195,7 @@ impl InboundUsersRepository {
|
|||||||
variable_values: server_inbound_entity.variable_values,
|
variable_values: server_inbound_entity.variable_values,
|
||||||
server_name: server_entity.name,
|
server_name: server_entity.name,
|
||||||
inbound_tag: server_inbound_entity.tag,
|
inbound_tag: server_inbound_entity.tag,
|
||||||
|
template_name: template_entity.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Some(config))
|
Ok(Some(config))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod server_inbound;
|
|||||||
pub mod user_access;
|
pub mod user_access;
|
||||||
pub mod inbound_users;
|
pub mod inbound_users;
|
||||||
pub mod telegram_config;
|
pub mod telegram_config;
|
||||||
|
pub mod user_request;
|
||||||
|
|
||||||
pub use user::UserRepository;
|
pub use user::UserRepository;
|
||||||
pub use certificate::CertificateRepository;
|
pub use certificate::CertificateRepository;
|
||||||
@@ -17,3 +18,4 @@ pub use server_inbound::ServerInboundRepository;
|
|||||||
pub use user_access::UserAccessRepository;
|
pub use user_access::UserAccessRepository;
|
||||||
pub use inbound_users::InboundUsersRepository;
|
pub use inbound_users::InboundUsersRepository;
|
||||||
pub use telegram_config::TelegramConfigRepository;
|
pub use telegram_config::TelegramConfigRepository;
|
||||||
|
pub use user_request::UserRequestRepository;
|
||||||
@@ -126,15 +126,6 @@ impl UserRepository {
|
|||||||
Ok(count > 0)
|
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
|
/// Set user as Telegram admin
|
||||||
pub async fn set_telegram_admin(&self, user_id: Uuid, is_admin: bool) -> Result<Option<Model>> {
|
pub async fn set_telegram_admin(&self, user_id: Uuid, is_admin: bool) -> Result<Option<Model>> {
|
||||||
@@ -167,6 +158,16 @@ impl UserRepository {
|
|||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get all Telegram admins
|
||||||
|
pub async fn get_telegram_admins(&self) -> Result<Vec<Model>> {
|
||||||
|
let admins = User::find()
|
||||||
|
.filter(Column::IsTelegramAdmin.eq(true))
|
||||||
|
.filter(Column::TelegramId.is_not_null())
|
||||||
|
.all(&self.db)
|
||||||
|
.await?;
|
||||||
|
Ok(admins)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
131
src/database/repository/user_request.rs
Normal file
131
src/database/repository/user_request.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use sea_orm::{EntityTrait, QueryFilter, ColumnTrait, DatabaseConnection, QueryOrder, PaginatorTrait, QuerySelect};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use crate::database::entities::user_request::{
|
||||||
|
self, Model, ActiveModel, CreateUserRequestDto, UpdateUserRequestDto, RequestStatus
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct UserRequestRepository {
|
||||||
|
db: DatabaseConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserRequestRepository {
|
||||||
|
pub fn new(db: DatabaseConnection) -> Self {
|
||||||
|
Self { db }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_all(&self, page: u64, per_page: u64) -> Result<(Vec<Model>, u64)> {
|
||||||
|
let paginator = user_request::Entity::find()
|
||||||
|
.order_by_desc(user_request::Column::CreatedAt)
|
||||||
|
.paginate(&self.db, per_page);
|
||||||
|
|
||||||
|
let total = paginator.num_items().await?;
|
||||||
|
let items = paginator.fetch_page(page - 1).await?;
|
||||||
|
|
||||||
|
Ok((items, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_pending(&self, page: u64, per_page: u64) -> Result<(Vec<Model>, u64)> {
|
||||||
|
let paginator = user_request::Entity::find()
|
||||||
|
.filter(user_request::Column::Status.eq("pending"))
|
||||||
|
.order_by_desc(user_request::Column::CreatedAt)
|
||||||
|
.paginate(&self.db, per_page);
|
||||||
|
|
||||||
|
let total = paginator.num_items().await?;
|
||||||
|
let items = paginator.fetch_page(page - 1).await?;
|
||||||
|
|
||||||
|
Ok((items, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_id(&self, id: Uuid) -> Result<Option<Model>> {
|
||||||
|
let request = user_request::Entity::find_by_id(id)
|
||||||
|
.one(&self.db)
|
||||||
|
.await?;
|
||||||
|
Ok(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_by_telegram_id(&self, telegram_id: i64) -> Result<Vec<Model>> {
|
||||||
|
let requests = user_request::Entity::find()
|
||||||
|
.filter(user_request::Column::TelegramId.eq(telegram_id))
|
||||||
|
.order_by_desc(user_request::Column::CreatedAt)
|
||||||
|
.all(&self.db)
|
||||||
|
.await?;
|
||||||
|
Ok(requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find recent user requests (ordered by creation date)
|
||||||
|
pub async fn find_recent(&self, limit: u64) -> Result<Vec<Model>> {
|
||||||
|
let requests = user_request::Entity::find()
|
||||||
|
.order_by_desc(user_request::Column::CreatedAt)
|
||||||
|
.limit(limit)
|
||||||
|
.all(&self.db)
|
||||||
|
.await?;
|
||||||
|
Ok(requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_pending_by_telegram_id(&self, telegram_id: i64) -> Result<Option<Model>> {
|
||||||
|
let request = user_request::Entity::find()
|
||||||
|
.filter(user_request::Column::TelegramId.eq(telegram_id))
|
||||||
|
.filter(user_request::Column::Status.eq("pending"))
|
||||||
|
.order_by_desc(user_request::Column::CreatedAt)
|
||||||
|
.one(&self.db)
|
||||||
|
.await?;
|
||||||
|
Ok(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(&self, dto: CreateUserRequestDto) -> Result<Model> {
|
||||||
|
use sea_orm::ActiveModelTrait;
|
||||||
|
let active_model: ActiveModel = dto.into();
|
||||||
|
let request = active_model.insert(&self.db).await?;
|
||||||
|
Ok(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(&self, id: Uuid, dto: UpdateUserRequestDto, processed_by: Uuid) -> Result<Option<Model>> {
|
||||||
|
let model = user_request::Entity::find_by_id(id)
|
||||||
|
.one(&self.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match model {
|
||||||
|
Some(model) => {
|
||||||
|
use sea_orm::ActiveModelTrait;
|
||||||
|
let active_model = model.apply_update(dto, processed_by);
|
||||||
|
let updated = active_model.update(&self.db).await?;
|
||||||
|
Ok(Some(updated))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn approve(&self, id: Uuid, response_message: Option<String>, processed_by: Uuid) -> Result<Option<Model>> {
|
||||||
|
let dto = UpdateUserRequestDto {
|
||||||
|
status: Some(RequestStatus::Approved.as_str().to_string()),
|
||||||
|
response_message,
|
||||||
|
processed_by_user_id: None,
|
||||||
|
};
|
||||||
|
self.update(id, dto, processed_by).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn decline(&self, id: Uuid, response_message: Option<String>, processed_by: Uuid) -> Result<Option<Model>> {
|
||||||
|
let dto = UpdateUserRequestDto {
|
||||||
|
status: Some(RequestStatus::Declined.as_str().to_string()),
|
||||||
|
response_message,
|
||||||
|
processed_by_user_id: None,
|
||||||
|
};
|
||||||
|
self.update(id, dto, processed_by).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, id: Uuid) -> Result<bool> {
|
||||||
|
let result = user_request::Entity::delete_by_id(id)
|
||||||
|
.exec(&self.db)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn count_by_status(&self, status: RequestStatus) -> Result<u64> {
|
||||||
|
let count = user_request::Entity::find()
|
||||||
|
.filter(user_request::Column::Status.eq(status.as_str()))
|
||||||
|
.count(&self.db)
|
||||||
|
.await?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,21 +93,50 @@ impl TaskScheduler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run initial sync on startup
|
// Run initial sync in background to avoid blocking startup
|
||||||
let start_time = Utc::now();
|
let db_initial = db.clone();
|
||||||
self.update_task_status("xray_sync", TaskState::Running, None);
|
let xray_service_initial = xray_service.clone();
|
||||||
|
let task_status_initial = self.task_status.clone();
|
||||||
|
|
||||||
match sync_xray_state(db.clone(), xray_service.clone()).await {
|
tokio::spawn(async move {
|
||||||
|
info!("Starting initial xray sync in background...");
|
||||||
|
let start_time = Utc::now();
|
||||||
|
|
||||||
|
// Update status to running
|
||||||
|
{
|
||||||
|
let mut status = task_status_initial.write().unwrap();
|
||||||
|
if let Some(task) = status.get_mut("xray_sync") {
|
||||||
|
task.status = TaskState::Running;
|
||||||
|
task.last_run = Some(start_time);
|
||||||
|
task.total_runs += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match sync_xray_state(db_initial, xray_service_initial).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let duration = (Utc::now() - start_time).num_milliseconds() as u64;
|
let duration = (Utc::now() - start_time).num_milliseconds() as u64;
|
||||||
self.update_task_status("xray_sync", TaskState::Success, Some(duration));
|
let mut status = task_status_initial.write().unwrap();
|
||||||
|
if let Some(task) = status.get_mut("xray_sync") {
|
||||||
|
task.status = TaskState::Success;
|
||||||
|
task.success_count += 1;
|
||||||
|
task.last_duration_ms = Some(duration);
|
||||||
|
task.last_error = None;
|
||||||
|
}
|
||||||
|
info!("Initial xray sync completed successfully in {}ms", duration);
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let duration = (Utc::now() - start_time).num_milliseconds() as u64;
|
let duration = (Utc::now() - start_time).num_milliseconds() as u64;
|
||||||
self.update_task_status_with_error("xray_sync", e.to_string(), Some(duration));
|
let mut status = task_status_initial.write().unwrap();
|
||||||
|
if let Some(task) = status.get_mut("xray_sync") {
|
||||||
|
task.status = TaskState::Error;
|
||||||
|
task.error_count += 1;
|
||||||
|
task.last_duration_ms = Some(duration);
|
||||||
|
task.last_error = Some(e.to_string());
|
||||||
|
}
|
||||||
error!("Initial xray sync failed: {}", e);
|
error!("Initial xray sync failed: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Add synchronization task that runs every minute
|
// Add synchronization task that runs every minute
|
||||||
let db_clone = db.clone();
|
let db_clone = db.clone();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use teloxide::{Bot, prelude::*};
|
|||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
use crate::database::DatabaseManager;
|
use crate::database::DatabaseManager;
|
||||||
use super::handlers;
|
use super::handlers::{self, Command};
|
||||||
|
|
||||||
/// Run the bot polling loop
|
/// Run the bot polling loop
|
||||||
pub async fn run_polling(
|
pub async fn run_polling(
|
||||||
@@ -12,14 +12,21 @@ pub async fn run_polling(
|
|||||||
) {
|
) {
|
||||||
tracing::info!("Starting Telegram bot polling...");
|
tracing::info!("Starting Telegram bot polling...");
|
||||||
|
|
||||||
let handler = Update::filter_message()
|
let handler = dptree::entry()
|
||||||
|
.branch(
|
||||||
|
Update::filter_message()
|
||||||
.branch(
|
.branch(
|
||||||
dptree::entry()
|
dptree::entry()
|
||||||
.filter_command::<handlers::Command>()
|
.filter_command::<Command>()
|
||||||
.endpoint(handlers::handle_command)
|
.endpoint(handlers::handle_command)
|
||||||
)
|
)
|
||||||
.branch(
|
.branch(
|
||||||
dptree::endpoint(handlers::handle_message)
|
dptree::endpoint(handlers::handle_message)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.branch(
|
||||||
|
Update::filter_callback_query()
|
||||||
|
.endpoint(handlers::handle_callback_query)
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut dispatcher = Dispatcher::builder(bot.clone(), handler)
|
let mut dispatcher = Dispatcher::builder(bot.clone(), handler)
|
||||||
|
|||||||
@@ -1,371 +0,0 @@
|
|||||||
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(())
|
|
||||||
}
|
|
||||||
723
src/services/telegram/handlers/admin.rs
Normal file
723
src/services/telegram/handlers/admin.rs
Normal file
@@ -0,0 +1,723 @@
|
|||||||
|
use teloxide::{prelude::*, types::{InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery}};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::database::DatabaseManager;
|
||||||
|
use crate::database::repository::{UserRepository, UserRequestRepository};
|
||||||
|
use crate::database::entities::user_request::RequestStatus;
|
||||||
|
use super::super::localization::{LocalizationService, Language};
|
||||||
|
use super::types::get_selected_servers;
|
||||||
|
|
||||||
|
/// Handle admin requests edit (show list of recent requests)
|
||||||
|
pub async fn handle_admin_requests_edit(
|
||||||
|
bot: Bot,
|
||||||
|
q: &CallbackQuery,
|
||||||
|
db: &DatabaseManager,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let admin_telegram_id = q.from.id.0 as i64;
|
||||||
|
let lang = Language::English; // Default admin language
|
||||||
|
let l10n = LocalizationService::new();
|
||||||
|
let chat_id = q.message.as_ref().and_then(|m| {
|
||||||
|
match m {
|
||||||
|
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}).ok_or("No chat ID")?;
|
||||||
|
|
||||||
|
let user_repo = UserRepository::new(db.connection());
|
||||||
|
let request_repo = UserRequestRepository::new(db.connection().clone());
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
if !user_repo.is_telegram_id_admin(admin_telegram_id).await.unwrap_or(false) {
|
||||||
|
bot.answer_callback_query(q.id.clone())
|
||||||
|
.text(l10n.get(lang, "unauthorized"))
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent requests (last 10)
|
||||||
|
let recent_requests = request_repo.find_recent(10).await.unwrap_or_default();
|
||||||
|
|
||||||
|
if recent_requests.is_empty() {
|
||||||
|
// Edit message to show no requests
|
||||||
|
if let Some(msg) = &q.message {
|
||||||
|
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
|
||||||
|
bot.edit_message_text(chat_id, regular_msg.id, l10n.get(lang.clone(), "no_pending_requests"))
|
||||||
|
.reply_markup(InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")],
|
||||||
|
]))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bot.answer_callback_query(q.id.clone()).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build message with request list
|
||||||
|
let mut message_lines = vec!["📋 <b>Recent Access Requests</b>\n".to_string()];
|
||||||
|
let mut keyboard_buttons = vec![];
|
||||||
|
|
||||||
|
for request in &recent_requests {
|
||||||
|
let status_emoji = match request.status.as_str() {
|
||||||
|
"pending" => "⏳",
|
||||||
|
"approved" => "✅",
|
||||||
|
"declined" => "❌",
|
||||||
|
_ => "❓"
|
||||||
|
};
|
||||||
|
|
||||||
|
let username = request.telegram_username.as_deref().unwrap_or("unknown");
|
||||||
|
let processed_info = if let Some(processed_by_id) = request.processed_by_user_id {
|
||||||
|
if let Ok(Some(admin)) = user_repo.get_by_id(processed_by_id).await {
|
||||||
|
format!(" by {}", admin.name)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let button_text = format!("{} {} @{}{}", status_emoji, request.get_full_name(), username, processed_info);
|
||||||
|
|
||||||
|
keyboard_buttons.push(vec![
|
||||||
|
InlineKeyboardButton::callback(button_text, format!("view_request:{}", request.id))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add back button
|
||||||
|
keyboard_buttons.push(vec![
|
||||||
|
InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")
|
||||||
|
]);
|
||||||
|
|
||||||
|
let message = message_lines.join("\n");
|
||||||
|
|
||||||
|
// Edit the existing message instead of sending a new one
|
||||||
|
if let Some(msg) = &q.message {
|
||||||
|
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
|
||||||
|
bot.edit_message_text(chat_id, regular_msg.id, message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(InlineKeyboardMarkup::new(keyboard_buttons))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.answer_callback_query(q.id.clone()).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle approve request
|
||||||
|
pub async fn handle_approve_request(
|
||||||
|
bot: Bot,
|
||||||
|
q: &CallbackQuery,
|
||||||
|
request_id: &str,
|
||||||
|
db: &DatabaseManager,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let admin_telegram_id = q.from.id.0 as i64;
|
||||||
|
let lang = Language::English; // Default admin language
|
||||||
|
let l10n = LocalizationService::new();
|
||||||
|
let _chat_id = q.message.as_ref().and_then(|m| {
|
||||||
|
match m {
|
||||||
|
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}).ok_or("No chat ID")?;
|
||||||
|
|
||||||
|
let user_repo = UserRepository::new(db.connection());
|
||||||
|
let request_repo = UserRequestRepository::new(db.connection().clone());
|
||||||
|
|
||||||
|
// Get admin user
|
||||||
|
let admin = user_repo.get_by_telegram_id(admin_telegram_id).await
|
||||||
|
.unwrap_or(None)
|
||||||
|
.ok_or(l10n.get(lang.clone(), "admin_not_found"))?;
|
||||||
|
|
||||||
|
// Parse request ID
|
||||||
|
let request_id = Uuid::parse_str(request_id).map_err(|_| l10n.get(lang.clone(), "invalid_request_id"))?;
|
||||||
|
|
||||||
|
// Get the request
|
||||||
|
let request = request_repo.find_by_id(request_id).await
|
||||||
|
.unwrap_or(None)
|
||||||
|
.ok_or(l10n.get(lang.clone(), "request_not_found"))?;
|
||||||
|
|
||||||
|
// Check if request is already processed
|
||||||
|
if request.status != "pending" {
|
||||||
|
bot.answer_callback_query(q.id.clone())
|
||||||
|
.text("This request has already been processed")
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user account
|
||||||
|
let username = request.telegram_username.as_deref().unwrap_or("Unknown");
|
||||||
|
let dto = crate::database::entities::user::CreateUserDto {
|
||||||
|
name: request.get_full_name(),
|
||||||
|
comment: Some(format!("Telegram user: @{}", username)),
|
||||||
|
telegram_id: Some(request.telegram_id),
|
||||||
|
is_telegram_admin: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
match user_repo.create(dto).await {
|
||||||
|
Ok(new_user) => {
|
||||||
|
// Approve the request
|
||||||
|
request_repo.approve(
|
||||||
|
request_id,
|
||||||
|
Some(format!("Approved by {}", admin.name)),
|
||||||
|
admin.id
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
// Update the callback message to show approval status and server selection
|
||||||
|
if let Some(message) = &q.message {
|
||||||
|
if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message {
|
||||||
|
if let Some(text) = msg.text() {
|
||||||
|
let updated_text = format!("{}\n\n✅ <b>APPROVED</b> by {}\n\n📋 Select servers to grant access:", text, admin.name);
|
||||||
|
let request_id_compact = request_id.to_string().replace("-", "");
|
||||||
|
let callback_data = format!("s:{}", request_id_compact);
|
||||||
|
tracing::info!("Callback data length: {} bytes, data: '{}'", callback_data.len(), callback_data);
|
||||||
|
let server_selection_keyboard = InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![InlineKeyboardButton::callback("🖥️ Select Servers", callback_data)],
|
||||||
|
vec![InlineKeyboardButton::callback("📋 All Requests", "back_to_requests")],
|
||||||
|
]);
|
||||||
|
|
||||||
|
let _ = bot.edit_message_text(msg.chat.id, msg.id, updated_text)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(server_selection_keyboard)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the user using their saved language preference
|
||||||
|
let user_lang = Language::from_telegram_code(Some(&request.get_language()));
|
||||||
|
let user_message = l10n.format(user_lang, "request_approved_notification", &[("user_id", &new_user.id.to_string())]);
|
||||||
|
|
||||||
|
bot.send_message(ChatId(request.telegram_id), user_message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
bot.answer_callback_query(q.id.clone())
|
||||||
|
.text(l10n.get(lang, "request_approved_admin"))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to create user during approval: {}", e);
|
||||||
|
bot.answer_callback_query(q.id.clone())
|
||||||
|
.text(l10n.format(lang.clone(), "user_creation_failed", &[("error", &e.to_string())]))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle decline request
|
||||||
|
pub async fn handle_decline_request(
|
||||||
|
bot: Bot,
|
||||||
|
q: &CallbackQuery,
|
||||||
|
request_id: &str,
|
||||||
|
db: &DatabaseManager,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let admin_telegram_id = q.from.id.0 as i64;
|
||||||
|
let lang = Language::English; // Default admin language
|
||||||
|
let l10n = LocalizationService::new();
|
||||||
|
|
||||||
|
let user_repo = UserRepository::new(db.connection());
|
||||||
|
let request_repo = UserRequestRepository::new(db.connection().clone());
|
||||||
|
|
||||||
|
// Get admin user
|
||||||
|
let admin = user_repo.get_by_telegram_id(admin_telegram_id).await
|
||||||
|
.unwrap_or(None)
|
||||||
|
.ok_or(l10n.get(lang.clone(), "admin_not_found"))?;
|
||||||
|
|
||||||
|
// Parse request ID
|
||||||
|
let request_id = Uuid::parse_str(request_id).map_err(|_| l10n.get(lang.clone(), "invalid_request_id"))?;
|
||||||
|
|
||||||
|
// Get the request
|
||||||
|
let request = request_repo.find_by_id(request_id).await
|
||||||
|
.unwrap_or(None)
|
||||||
|
.ok_or(l10n.get(lang.clone(), "request_not_found"))?;
|
||||||
|
|
||||||
|
// Check if request is already processed
|
||||||
|
if request.status != "pending" {
|
||||||
|
bot.answer_callback_query(q.id.clone())
|
||||||
|
.text("This request has already been processed")
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decline the request
|
||||||
|
request_repo.decline(
|
||||||
|
request_id,
|
||||||
|
Some(format!("Declined by {}", admin.name)),
|
||||||
|
admin.id
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
// Update the callback message to show decline status
|
||||||
|
if let Some(message) = &q.message {
|
||||||
|
if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message {
|
||||||
|
if let Some(text) = msg.text() {
|
||||||
|
let updated_text = format!("{}\n\n❌ <b>DECLINED</b> by {}", text, admin.name);
|
||||||
|
let back_keyboard = InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![InlineKeyboardButton::callback("📋 All Requests", "back_to_requests")],
|
||||||
|
]);
|
||||||
|
|
||||||
|
let _ = bot.edit_message_text(msg.chat.id, msg.id, updated_text)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(back_keyboard)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the user using their saved language preference
|
||||||
|
let user_lang = Language::from_telegram_code(Some(&request.get_language()));
|
||||||
|
let user_message = l10n.get(user_lang, "request_declined_notification");
|
||||||
|
|
||||||
|
bot.send_message(ChatId(request.telegram_id), user_message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
bot.answer_callback_query(q.id.clone())
|
||||||
|
.text(l10n.get(lang, "request_declined_admin"))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle view request details
|
||||||
|
pub async fn handle_view_request(
|
||||||
|
bot: Bot,
|
||||||
|
q: &CallbackQuery,
|
||||||
|
request_id: &str,
|
||||||
|
db: &DatabaseManager,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let lang = Language::English; // Default admin language
|
||||||
|
let l10n = LocalizationService::new();
|
||||||
|
let chat_id = q.message.as_ref().and_then(|m| {
|
||||||
|
match m {
|
||||||
|
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}).ok_or("No chat ID")?;
|
||||||
|
|
||||||
|
let request_repo = UserRequestRepository::new(db.connection().clone());
|
||||||
|
let user_repo = UserRepository::new(db.connection());
|
||||||
|
|
||||||
|
// Parse request ID
|
||||||
|
let request_id = Uuid::parse_str(request_id).map_err(|_| l10n.get(lang.clone(), "invalid_request_id"))?;
|
||||||
|
|
||||||
|
// Get the request
|
||||||
|
let request = request_repo.find_by_id(request_id).await
|
||||||
|
.unwrap_or(None)
|
||||||
|
.ok_or(l10n.get(lang.clone(), "request_not_found"))?;
|
||||||
|
|
||||||
|
// Get processed by admin info
|
||||||
|
let processed_by = if let Some(processed_by_id) = request.processed_by_user_id {
|
||||||
|
if let Ok(Some(admin)) = user_repo.get_by_id(processed_by_id).await {
|
||||||
|
format!("\n👤 Processed by: {}", admin.name)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let processed_at = if let Some(processed_at) = request.processed_at {
|
||||||
|
format!("\n⏰ Processed at: {}", processed_at.format("%Y-%m-%d %H:%M UTC"))
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_emoji = match request.status.as_str() {
|
||||||
|
"pending" => "⏳",
|
||||||
|
"approved" => "✅",
|
||||||
|
"declined" => "❌",
|
||||||
|
_ => "❓"
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = format!(
|
||||||
|
"📋 <b>Access Request Details</b>\n\n\
|
||||||
|
👤 Name: {}\n\
|
||||||
|
🆔 Telegram: {}\n\
|
||||||
|
🌍 Language: {}\n\
|
||||||
|
📅 Requested: {}\n\
|
||||||
|
{} Status: <b>{}</b>{}{}\n\n\
|
||||||
|
💬 Message: {}",
|
||||||
|
request.get_full_name(),
|
||||||
|
request.get_telegram_link(),
|
||||||
|
request.get_language().to_uppercase(),
|
||||||
|
request.created_at.format("%Y-%m-%d %H:%M UTC"),
|
||||||
|
status_emoji,
|
||||||
|
request.status.to_uppercase(),
|
||||||
|
processed_by,
|
||||||
|
processed_at,
|
||||||
|
request.request_message.as_deref().unwrap_or("No message")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create keyboard based on request status
|
||||||
|
let keyboard = if request.status == "pending" {
|
||||||
|
InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![
|
||||||
|
InlineKeyboardButton::callback(l10n.get(lang.clone(), "approve"), format!("approve:{}", request.id)),
|
||||||
|
InlineKeyboardButton::callback(l10n.get(lang.clone(), "decline"), format!("decline:{}", request.id)),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
InlineKeyboardButton::callback(l10n.get(lang.clone(), "back"), "back_to_requests"),
|
||||||
|
InlineKeyboardButton::callback("🏠 Menu", "back"),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![
|
||||||
|
InlineKeyboardButton::callback(l10n.get(lang.clone(), "back"), "back_to_requests"),
|
||||||
|
InlineKeyboardButton::callback("🏠 Menu", "back"),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edit the existing message instead of sending a new one
|
||||||
|
if let Some(msg) = &q.message {
|
||||||
|
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
|
||||||
|
bot.edit_message_text(chat_id, regular_msg.id, message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(keyboard)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.answer_callback_query(q.id.clone()).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle /stats command (admin only)
|
||||||
|
pub async fn handle_stats(
|
||||||
|
bot: Bot,
|
||||||
|
chat_id: ChatId,
|
||||||
|
db: &DatabaseManager,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let lang = Language::English; // Default admin language
|
||||||
|
let l10n = LocalizationService::new();
|
||||||
|
|
||||||
|
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 request_repo = UserRequestRepository::new(db.connection().clone());
|
||||||
|
|
||||||
|
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 pending_requests = request_repo.count_by_status(RequestStatus::Pending).await.unwrap_or(0);
|
||||||
|
|
||||||
|
let message = l10n.format(lang, "statistics", &[
|
||||||
|
("users", &user_count.to_string()),
|
||||||
|
("servers", &server_count.to_string()),
|
||||||
|
("inbounds", &inbound_count.to_string()),
|
||||||
|
("pending", &pending_requests.to_string())
|
||||||
|
]);
|
||||||
|
|
||||||
|
bot.send_message(chat_id, message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle /broadcast command (admin only)
|
||||||
|
pub 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(_) => failed_count += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lang = Language::English; // Default admin language
|
||||||
|
let l10n = LocalizationService::new();
|
||||||
|
|
||||||
|
let result_message = l10n.format(lang, "broadcast_complete", &[
|
||||||
|
("sent", &sent_count.to_string()),
|
||||||
|
("failed", &failed_count.to_string())
|
||||||
|
]);
|
||||||
|
|
||||||
|
bot.send_message(chat_id, result_message).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle server selection after approval
|
||||||
|
pub async fn handle_select_server_access(
|
||||||
|
bot: Bot,
|
||||||
|
q: &CallbackQuery,
|
||||||
|
request_id: &str,
|
||||||
|
db: &DatabaseManager,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let lang = Language::English; // Default admin language
|
||||||
|
let _l10n = LocalizationService::new();
|
||||||
|
let chat_id = q.message.as_ref().and_then(|m| {
|
||||||
|
match m {
|
||||||
|
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}).ok_or("No chat ID")?;
|
||||||
|
|
||||||
|
let server_repo = crate::database::repository::ServerRepository::new(db.connection());
|
||||||
|
|
||||||
|
// Get all active servers
|
||||||
|
let servers = server_repo.find_all().await.unwrap_or_default();
|
||||||
|
|
||||||
|
if servers.is_empty() {
|
||||||
|
bot.answer_callback_query(q.id.clone())
|
||||||
|
.text("No servers available")
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize selected servers for this request (empty initially)
|
||||||
|
{
|
||||||
|
let mut selected = get_selected_servers().lock().unwrap();
|
||||||
|
selected.insert(request_id.to_string(), Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build keyboard with server toggle buttons
|
||||||
|
let mut keyboard_buttons = vec![];
|
||||||
|
let selected_servers = {
|
||||||
|
let selected = get_selected_servers().lock().unwrap();
|
||||||
|
selected.get(request_id).cloned().unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
for server in &servers {
|
||||||
|
let is_selected = selected_servers.contains(&server.id.to_string());
|
||||||
|
let button_text = if is_selected {
|
||||||
|
format!("✅ {}", server.name)
|
||||||
|
} else {
|
||||||
|
format!("⬜ {}", server.name)
|
||||||
|
};
|
||||||
|
|
||||||
|
keyboard_buttons.push(vec![
|
||||||
|
InlineKeyboardButton::callback(
|
||||||
|
button_text,
|
||||||
|
format!("t:{}:{}", request_id.to_string().replace("-", ""), server.id.to_string().replace("-", ""))
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add apply and back buttons
|
||||||
|
keyboard_buttons.push(vec![
|
||||||
|
InlineKeyboardButton::callback("✅ Apply Selected", format!("a:{}", request_id.to_string().replace("-", ""))),
|
||||||
|
InlineKeyboardButton::callback("🔙 Back", "back_to_requests"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let message = format!("🖥️ <b>Select Servers for Access</b>\n\nChoose which servers to grant access to the approved user:");
|
||||||
|
|
||||||
|
// Edit the existing message
|
||||||
|
if let Some(msg) = &q.message {
|
||||||
|
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
|
||||||
|
bot.edit_message_text(chat_id, regular_msg.id, message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(InlineKeyboardMarkup::new(keyboard_buttons))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.answer_callback_query(q.id.clone()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle toggling server selection
|
||||||
|
pub async fn handle_toggle_server(
|
||||||
|
bot: Bot,
|
||||||
|
q: &CallbackQuery,
|
||||||
|
request_id: &str,
|
||||||
|
server_id: &str,
|
||||||
|
db: &DatabaseManager,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let chat_id = q.message.as_ref().and_then(|m| {
|
||||||
|
match m {
|
||||||
|
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}).ok_or("No chat ID")?;
|
||||||
|
|
||||||
|
// Toggle server selection
|
||||||
|
{
|
||||||
|
let mut selected = get_selected_servers().lock().unwrap();
|
||||||
|
let server_list = selected.entry(request_id.to_string()).or_insert_with(Vec::new);
|
||||||
|
|
||||||
|
if let Some(pos) = server_list.iter().position(|x| x == server_id) {
|
||||||
|
server_list.remove(pos);
|
||||||
|
} else {
|
||||||
|
server_list.push(server_id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the keyboard with updated selection
|
||||||
|
let server_repo = crate::database::repository::ServerRepository::new(db.connection());
|
||||||
|
let servers = server_repo.find_all().await.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut keyboard_buttons = vec![];
|
||||||
|
let selected_servers = {
|
||||||
|
let selected = get_selected_servers().lock().unwrap();
|
||||||
|
selected.get(request_id).cloned().unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
for server in &servers {
|
||||||
|
let is_selected = selected_servers.contains(&server.id.to_string());
|
||||||
|
let button_text = if is_selected {
|
||||||
|
format!("✅ {}", server.name)
|
||||||
|
} else {
|
||||||
|
format!("⬜ {}", server.name)
|
||||||
|
};
|
||||||
|
|
||||||
|
keyboard_buttons.push(vec![
|
||||||
|
InlineKeyboardButton::callback(
|
||||||
|
button_text,
|
||||||
|
format!("t:{}:{}", request_id.to_string().replace("-", ""), server.id.to_string().replace("-", ""))
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add apply and back buttons
|
||||||
|
keyboard_buttons.push(vec![
|
||||||
|
InlineKeyboardButton::callback("✅ Apply Selected", format!("a:{}", request_id.to_string().replace("-", ""))),
|
||||||
|
InlineKeyboardButton::callback("🔙 Back", "back_to_requests"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let selected_count = selected_servers.len();
|
||||||
|
let message = format!("🖥️ <b>Select Servers for Access</b>\n\nChoose which servers to grant access to the approved user:\n\n📊 Selected: {} server{}",
|
||||||
|
selected_count, if selected_count == 1 { "" } else { "s" });
|
||||||
|
|
||||||
|
// Edit the existing message
|
||||||
|
if let Some(msg) = &q.message {
|
||||||
|
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
|
||||||
|
bot.edit_message_text(chat_id, regular_msg.id, message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(InlineKeyboardMarkup::new(keyboard_buttons))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.answer_callback_query(q.id.clone()).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle applying server access
|
||||||
|
pub async fn handle_apply_server_access(
|
||||||
|
bot: Bot,
|
||||||
|
q: &CallbackQuery,
|
||||||
|
request_id: &str,
|
||||||
|
db: &DatabaseManager,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let lang = Language::English; // Default admin language
|
||||||
|
let _l10n = LocalizationService::new();
|
||||||
|
let chat_id = q.message.as_ref().and_then(|m| {
|
||||||
|
match m {
|
||||||
|
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}).ok_or("No chat ID")?;
|
||||||
|
|
||||||
|
// Get selected servers
|
||||||
|
let selected_server_ids = {
|
||||||
|
let selected = get_selected_servers().lock().unwrap();
|
||||||
|
selected.get(request_id).cloned().unwrap_or_default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if selected_server_ids.is_empty() {
|
||||||
|
bot.answer_callback_query(q.id.clone())
|
||||||
|
.text("No servers selected")
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let request_repo = UserRequestRepository::new(db.connection().clone());
|
||||||
|
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().clone());
|
||||||
|
let inbound_users_repo = crate::database::repository::InboundUsersRepository::new(db.connection().clone());
|
||||||
|
|
||||||
|
// Parse request ID and get request
|
||||||
|
let request_uuid = Uuid::parse_str(request_id).map_err(|_| "Invalid request ID")?;
|
||||||
|
let request = request_repo.find_by_id(request_uuid).await
|
||||||
|
.unwrap_or(None)
|
||||||
|
.ok_or("Request not found")?;
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
let user = user_repo.get_by_telegram_id(request.telegram_id).await
|
||||||
|
.unwrap_or(None)
|
||||||
|
.ok_or("User not found")?;
|
||||||
|
|
||||||
|
let mut granted_servers = Vec::new();
|
||||||
|
let mut total_inbounds = 0;
|
||||||
|
|
||||||
|
// Grant access to all inbounds on selected servers
|
||||||
|
for server_id_str in &selected_server_ids {
|
||||||
|
if let Ok(server_id) = Uuid::parse_str(server_id_str) {
|
||||||
|
// Get server info
|
||||||
|
if let Ok(Some(server)) = server_repo.find_by_id(server_id).await {
|
||||||
|
granted_servers.push(server.name.clone());
|
||||||
|
|
||||||
|
// Get all inbounds for this server
|
||||||
|
if let Ok(inbounds) = inbound_repo.find_by_server_id(server_id).await {
|
||||||
|
for inbound in inbounds {
|
||||||
|
// Check if user already has access to this inbound
|
||||||
|
if !inbound_users_repo.user_has_access_to_inbound(user.id, inbound.id).await.unwrap_or(false) {
|
||||||
|
// Create inbound user access
|
||||||
|
let dto = crate::database::entities::inbound_users::CreateInboundUserDto {
|
||||||
|
user_id: user.id,
|
||||||
|
server_inbound_id: inbound.id,
|
||||||
|
level: Some(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(_) = inbound_users_repo.create(dto).await {
|
||||||
|
total_inbounds += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up selected servers storage
|
||||||
|
{
|
||||||
|
let mut selected = get_selected_servers().lock().unwrap();
|
||||||
|
selected.remove(request_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update message with success
|
||||||
|
let message = format!(
|
||||||
|
"✅ <b>Server Access Granted</b>\n\nUser: {}\nServers: {}\nTotal inbounds: {}\n\n✅ Access has been successfully granted!",
|
||||||
|
user.name,
|
||||||
|
granted_servers.join(", "),
|
||||||
|
total_inbounds
|
||||||
|
);
|
||||||
|
|
||||||
|
let back_keyboard = InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![InlineKeyboardButton::callback("📋 All Requests", "back_to_requests")],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Edit the existing message
|
||||||
|
if let Some(msg) = &q.message {
|
||||||
|
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
|
||||||
|
bot.edit_message_text(chat_id, regular_msg.id, message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(back_keyboard)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.answer_callback_query(q.id.clone())
|
||||||
|
.text(format!("Access granted to {} servers", granted_servers.len()))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
153
src/services/telegram/handlers/mod.rs
Normal file
153
src/services/telegram/handlers/mod.rs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
pub mod admin;
|
||||||
|
pub mod user;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
// Re-export main handler functions for easier access
|
||||||
|
pub use admin::*;
|
||||||
|
pub use user::*;
|
||||||
|
pub use types::*;
|
||||||
|
|
||||||
|
use teloxide::{prelude::*, types::CallbackQuery};
|
||||||
|
use crate::database::DatabaseManager;
|
||||||
|
|
||||||
|
/// Handle bot commands
|
||||||
|
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.ok_or("No user info")?;
|
||||||
|
let telegram_id = from.id.0 as i64;
|
||||||
|
let user_repo = crate::database::repository::UserRepository::new(db.connection());
|
||||||
|
|
||||||
|
match cmd {
|
||||||
|
Command::Start => {
|
||||||
|
handle_start(bot, chat_id, telegram_id, from, &user_repo, &db).await?;
|
||||||
|
}
|
||||||
|
Command::Requests => {
|
||||||
|
// Check if user is admin
|
||||||
|
if user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) {
|
||||||
|
// Create a fake callback query for admin requests
|
||||||
|
// This is a workaround since the admin_requests function expects a callback query
|
||||||
|
// In practice, we could refactor this to not need a callback query
|
||||||
|
tracing::info!("Admin {} requested to view requests", telegram_id);
|
||||||
|
|
||||||
|
let message = "📋 Use the inline keyboard to view recent requests.";
|
||||||
|
let keyboard = teloxide::types::InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![teloxide::types::InlineKeyboardButton::callback("📋 Recent Requests", "admin_requests")],
|
||||||
|
]);
|
||||||
|
|
||||||
|
bot.send_message(chat_id, message)
|
||||||
|
.reply_markup(keyboard)
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
let lang = get_user_language(from);
|
||||||
|
let l10n = super::localization::LocalizationService::new();
|
||||||
|
bot.send_message(chat_id, l10n.get(lang, "unauthorized")).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::Stats => {
|
||||||
|
// Check if user is admin
|
||||||
|
if user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) {
|
||||||
|
handle_stats(bot, chat_id, &db).await?;
|
||||||
|
} else {
|
||||||
|
let lang = get_user_language(from);
|
||||||
|
let l10n = super::localization::LocalizationService::new();
|
||||||
|
bot.send_message(chat_id, l10n.get(lang, "unauthorized")).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Command::Broadcast { message } => {
|
||||||
|
// Check if user is admin
|
||||||
|
if user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false) {
|
||||||
|
handle_broadcast(bot, chat_id, message, &user_repo).await?;
|
||||||
|
} else {
|
||||||
|
let lang = get_user_language(from);
|
||||||
|
let l10n = super::localization::LocalizationService::new();
|
||||||
|
bot.send_message(chat_id, l10n.get(lang, "unauthorized")).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle regular messages (fallback)
|
||||||
|
pub async fn handle_message(
|
||||||
|
bot: Bot,
|
||||||
|
msg: Message,
|
||||||
|
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 user info")?;
|
||||||
|
let telegram_id = from.id.0 as i64;
|
||||||
|
let user_repo = crate::database::repository::UserRepository::new(db.connection());
|
||||||
|
|
||||||
|
// For non-command messages, just show the start menu
|
||||||
|
handle_start(bot, chat_id, telegram_id, from, &user_repo, &db).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle callback queries from inline keyboards
|
||||||
|
pub async fn handle_callback_query(
|
||||||
|
bot: Bot,
|
||||||
|
q: CallbackQuery,
|
||||||
|
db: DatabaseManager,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
if let Some(data) = &q.data {
|
||||||
|
if let Some(callback_data) = CallbackData::parse(data) {
|
||||||
|
match callback_data {
|
||||||
|
CallbackData::RequestAccess => {
|
||||||
|
handle_request_access(bot, &q, &db).await?;
|
||||||
|
}
|
||||||
|
CallbackData::MyConfigs => {
|
||||||
|
handle_my_configs_edit(bot, &q, &db).await?;
|
||||||
|
}
|
||||||
|
CallbackData::Support => {
|
||||||
|
handle_support(bot, &q).await?;
|
||||||
|
}
|
||||||
|
CallbackData::AdminRequests => {
|
||||||
|
handle_admin_requests_edit(bot, &q, &db).await?;
|
||||||
|
}
|
||||||
|
CallbackData::ApproveRequest(request_id) => {
|
||||||
|
handle_approve_request(bot, &q, &request_id, &db).await?;
|
||||||
|
}
|
||||||
|
CallbackData::DeclineRequest(request_id) => {
|
||||||
|
handle_decline_request(bot, &q, &request_id, &db).await?;
|
||||||
|
}
|
||||||
|
CallbackData::ViewRequest(request_id) => {
|
||||||
|
handle_view_request(bot, &q, &request_id, &db).await?;
|
||||||
|
}
|
||||||
|
CallbackData::ShowServerConfigs(encoded_server_name) => {
|
||||||
|
handle_show_server_configs(bot, &q, &encoded_server_name, &db).await?;
|
||||||
|
}
|
||||||
|
CallbackData::SelectServerAccess(request_id) => {
|
||||||
|
handle_select_server_access(bot, &q, &request_id, &db).await?;
|
||||||
|
}
|
||||||
|
CallbackData::ToggleServer(request_id, server_id) => {
|
||||||
|
handle_toggle_server(bot, &q, &request_id, &server_id, &db).await?;
|
||||||
|
}
|
||||||
|
CallbackData::ApplyServerAccess(request_id) => {
|
||||||
|
handle_apply_server_access(bot, &q, &request_id, &db).await?;
|
||||||
|
}
|
||||||
|
CallbackData::Back => {
|
||||||
|
// Back to main menu - edit the existing message
|
||||||
|
handle_start_edit(bot, &q, &db).await?;
|
||||||
|
}
|
||||||
|
CallbackData::BackToConfigs => {
|
||||||
|
handle_my_configs_edit(bot, &q, &db).await?;
|
||||||
|
}
|
||||||
|
CallbackData::BackToRequests => {
|
||||||
|
handle_admin_requests_edit(bot, &q, &db).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tracing::warn!("Unknown callback data: {}", data);
|
||||||
|
bot.answer_callback_query(q.id.clone()).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
137
src/services/telegram/handlers/types.rs
Normal file
137
src/services/telegram/handlers/types.rs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
use teloxide::utils::command::BotCommands;
|
||||||
|
use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, User};
|
||||||
|
|
||||||
|
use super::super::localization::{LocalizationService, Language};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
|
/// Available bot commands - keeping only admin commands
|
||||||
|
#[derive(BotCommands, Clone)]
|
||||||
|
#[command(rename_rule = "lowercase", description = "Admin commands:")]
|
||||||
|
pub enum Command {
|
||||||
|
#[command(description = "Start the bot")]
|
||||||
|
Start,
|
||||||
|
#[command(description = "[Admin] Manage user requests")]
|
||||||
|
Requests,
|
||||||
|
#[command(description = "[Admin] Show statistics")]
|
||||||
|
Stats,
|
||||||
|
#[command(description = "[Admin] Broadcast message", parse_with = "split")]
|
||||||
|
Broadcast { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callback data for inline keyboard buttons
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum CallbackData {
|
||||||
|
RequestAccess,
|
||||||
|
MyConfigs,
|
||||||
|
Support,
|
||||||
|
AdminRequests,
|
||||||
|
ApproveRequest(String), // request_id
|
||||||
|
DeclineRequest(String), // request_id
|
||||||
|
ViewRequest(String), // request_id
|
||||||
|
ShowServerConfigs(String), // server_name encoded
|
||||||
|
Back,
|
||||||
|
BackToConfigs, // Back to configs list from server view
|
||||||
|
BackToRequests, // Back to requests list from request view
|
||||||
|
SelectServerAccess(String), // request_id - show server selection after approval
|
||||||
|
ToggleServer(String, String), // request_id, server_id - toggle server selection
|
||||||
|
ApplyServerAccess(String), // request_id - apply selected servers
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CallbackData {
|
||||||
|
pub fn parse(data: &str) -> Option<Self> {
|
||||||
|
match data {
|
||||||
|
"request_access" => Some(CallbackData::RequestAccess),
|
||||||
|
"my_configs" => Some(CallbackData::MyConfigs),
|
||||||
|
"support" => Some(CallbackData::Support),
|
||||||
|
"admin_requests" => Some(CallbackData::AdminRequests),
|
||||||
|
"back" => Some(CallbackData::Back),
|
||||||
|
"back_to_configs" => Some(CallbackData::BackToConfigs),
|
||||||
|
"back_to_requests" => Some(CallbackData::BackToRequests),
|
||||||
|
_ => {
|
||||||
|
if let Some(id) = data.strip_prefix("approve:") {
|
||||||
|
Some(CallbackData::ApproveRequest(id.to_string()))
|
||||||
|
} else if let Some(id) = data.strip_prefix("decline:") {
|
||||||
|
Some(CallbackData::DeclineRequest(id.to_string()))
|
||||||
|
} else if let Some(id) = data.strip_prefix("view_request:") {
|
||||||
|
Some(CallbackData::ViewRequest(id.to_string()))
|
||||||
|
} else if let Some(server_name) = data.strip_prefix("server_configs:") {
|
||||||
|
Some(CallbackData::ShowServerConfigs(server_name.to_string()))
|
||||||
|
} else if let Some(id) = data.strip_prefix("s:") {
|
||||||
|
restore_uuid(id).map(CallbackData::SelectServerAccess)
|
||||||
|
} else if let Some(rest) = data.strip_prefix("t:") {
|
||||||
|
let parts: Vec<&str> = rest.split(':').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
if let (Some(request_id), Some(server_id)) = (restore_uuid(parts[0]), restore_uuid(parts[1])) {
|
||||||
|
Some(CallbackData::ToggleServer(request_id, server_id))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else if let Some(id) = data.strip_prefix("a:") {
|
||||||
|
restore_uuid(id).map(CallbackData::ApplyServerAccess)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global storage for selected servers per request
|
||||||
|
static SELECTED_SERVERS: OnceLock<Arc<Mutex<HashMap<String, Vec<String>>>>> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn get_selected_servers() -> &'static Arc<Mutex<HashMap<String, Vec<String>>>> {
|
||||||
|
SELECTED_SERVERS.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to get user language from Telegram user data
|
||||||
|
pub fn get_user_language(user: &User) -> Language {
|
||||||
|
Language::from_telegram_code(user.language_code.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main keyboard for registered users
|
||||||
|
pub fn get_main_keyboard(is_admin: bool, lang: Language) -> InlineKeyboardMarkup {
|
||||||
|
let l10n = LocalizationService::new();
|
||||||
|
|
||||||
|
let mut keyboard = vec![
|
||||||
|
vec![InlineKeyboardButton::callback(l10n.get(lang.clone(), "my_configs"), "my_configs")],
|
||||||
|
vec![InlineKeyboardButton::callback(l10n.get(lang.clone(), "support"), "support")],
|
||||||
|
];
|
||||||
|
|
||||||
|
if is_admin {
|
||||||
|
keyboard.push(vec![InlineKeyboardButton::callback(l10n.get(lang, "user_requests"), "admin_requests")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
InlineKeyboardMarkup::new(keyboard)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keyboard for new users
|
||||||
|
pub fn get_new_user_keyboard(lang: Language) -> InlineKeyboardMarkup {
|
||||||
|
let l10n = LocalizationService::new();
|
||||||
|
|
||||||
|
InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![InlineKeyboardButton::callback(l10n.get(lang, "get_vpn_access"), "request_access")],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore UUID from compact format (without dashes)
|
||||||
|
fn restore_uuid(compact: &str) -> Option<String> {
|
||||||
|
if compact.len() != 32 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert dashes at proper positions for UUID format
|
||||||
|
let uuid_str = format!(
|
||||||
|
"{}-{}-{}-{}-{}",
|
||||||
|
&compact[0..8],
|
||||||
|
&compact[8..12],
|
||||||
|
&compact[12..16],
|
||||||
|
&compact[16..20],
|
||||||
|
&compact[20..32]
|
||||||
|
);
|
||||||
|
|
||||||
|
Some(uuid_str)
|
||||||
|
}
|
||||||
669
src/services/telegram/handlers/user.rs
Normal file
669
src/services/telegram/handlers/user.rs
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
use teloxide::{prelude::*, types::{InlineKeyboardButton, InlineKeyboardMarkup}};
|
||||||
|
use base64::{Engine, engine::general_purpose};
|
||||||
|
|
||||||
|
use crate::database::DatabaseManager;
|
||||||
|
use crate::database::repository::{UserRepository, UserRequestRepository};
|
||||||
|
use crate::database::entities::user_request::{CreateUserRequestDto, RequestStatus};
|
||||||
|
use super::super::localization::{LocalizationService, Language};
|
||||||
|
use super::types::{get_user_language, get_main_keyboard, get_new_user_keyboard};
|
||||||
|
|
||||||
|
/// Handle start command and main menu
|
||||||
|
pub async fn handle_start(
|
||||||
|
bot: Bot,
|
||||||
|
chat_id: ChatId,
|
||||||
|
telegram_id: i64,
|
||||||
|
from: &teloxide::types::User,
|
||||||
|
user_repo: &UserRepository,
|
||||||
|
db: &DatabaseManager,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
handle_start_impl(bot, chat_id, telegram_id, from, user_repo, db, None, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle start with message editing support
|
||||||
|
pub async fn handle_start_edit(
|
||||||
|
bot: Bot,
|
||||||
|
q: &CallbackQuery,
|
||||||
|
db: &DatabaseManager,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let from = &q.from;
|
||||||
|
let telegram_id = from.id.0 as i64;
|
||||||
|
let user_repo = UserRepository::new(db.connection());
|
||||||
|
|
||||||
|
if let Some(msg) = &q.message {
|
||||||
|
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
|
||||||
|
let chat_id = regular_msg.chat.id;
|
||||||
|
handle_start_impl(
|
||||||
|
bot.clone(),
|
||||||
|
chat_id,
|
||||||
|
telegram_id,
|
||||||
|
from,
|
||||||
|
&user_repo,
|
||||||
|
db,
|
||||||
|
Some(regular_msg.id),
|
||||||
|
Some(q.id.clone())
|
||||||
|
).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal implementation of handle_start with optional message editing
|
||||||
|
async fn handle_start_impl(
|
||||||
|
bot: Bot,
|
||||||
|
chat_id: ChatId,
|
||||||
|
telegram_id: i64,
|
||||||
|
from: &teloxide::types::User,
|
||||||
|
user_repo: &UserRepository,
|
||||||
|
db: &DatabaseManager,
|
||||||
|
edit_message_id: Option<teloxide::types::MessageId>,
|
||||||
|
callback_query_id: Option<String>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let lang = get_user_language(from);
|
||||||
|
let l10n = LocalizationService::new();
|
||||||
|
|
||||||
|
// Check if user exists in our database
|
||||||
|
match user_repo.get_by_telegram_id(telegram_id).await {
|
||||||
|
Ok(Some(user)) => {
|
||||||
|
// Check if user is admin
|
||||||
|
let is_admin = user_repo.is_telegram_id_admin(telegram_id).await.unwrap_or(false);
|
||||||
|
|
||||||
|
// Check if user has any pending requests
|
||||||
|
let request_repo = UserRequestRepository::new(db.connection().clone());
|
||||||
|
|
||||||
|
// Check for existing requests
|
||||||
|
if let Ok(existing_requests) = request_repo.find_by_telegram_id(telegram_id).await {
|
||||||
|
if let Some(latest_request) = existing_requests.into_iter()
|
||||||
|
.filter(|r| r.status == "pending" || r.status == "approved" || r.status == "declined")
|
||||||
|
.max_by_key(|r| r.created_at) {
|
||||||
|
|
||||||
|
match latest_request.status.as_str() {
|
||||||
|
"pending" => {
|
||||||
|
let message = l10n.format(lang.clone(), "request_pending", &[
|
||||||
|
("status", "⏳ pending"),
|
||||||
|
("date", &latest_request.created_at.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||||
|
]);
|
||||||
|
|
||||||
|
let keyboard = get_new_user_keyboard(lang);
|
||||||
|
|
||||||
|
if let Some(msg_id) = edit_message_id {
|
||||||
|
bot.edit_message_text(chat_id, msg_id, message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(keyboard)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(cb_id) = callback_query_id {
|
||||||
|
bot.answer_callback_query(cb_id).await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bot.send_message(chat_id, message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(keyboard)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
"declined" => {
|
||||||
|
let message = l10n.format(lang.clone(), "request_pending", &[
|
||||||
|
("status", &l10n.get(lang.clone(), "request_declined_status")),
|
||||||
|
("date", &latest_request.created_at.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||||
|
]);
|
||||||
|
|
||||||
|
let keyboard = get_new_user_keyboard(lang);
|
||||||
|
|
||||||
|
if let Some(msg_id) = edit_message_id {
|
||||||
|
bot.edit_message_text(chat_id, msg_id, message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(keyboard)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(cb_id) = callback_query_id {
|
||||||
|
bot.answer_callback_query(cb_id).await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bot.send_message(chat_id, message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(keyboard)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ => {} // approved - continue with normal flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing user - show main menu
|
||||||
|
let message = l10n.format(lang.clone(), "welcome_back", &[("name", &user.name)]);
|
||||||
|
let keyboard = get_main_keyboard(is_admin, lang);
|
||||||
|
|
||||||
|
if let Some(msg_id) = edit_message_id {
|
||||||
|
bot.edit_message_text(chat_id, msg_id, message)
|
||||||
|
.reply_markup(keyboard)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(cb_id) = callback_query_id {
|
||||||
|
bot.answer_callback_query(cb_id).await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bot.send_message(chat_id, message)
|
||||||
|
.reply_markup(keyboard)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
// New user - show access request
|
||||||
|
let username = from.username.as_deref().unwrap_or("Unknown");
|
||||||
|
let message = l10n.format(lang.clone(), "welcome_new_user", &[("username", username)]);
|
||||||
|
let keyboard = get_new_user_keyboard(lang);
|
||||||
|
|
||||||
|
if let Some(msg_id) = edit_message_id {
|
||||||
|
bot.edit_message_text(chat_id, msg_id, message)
|
||||||
|
.reply_markup(keyboard)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(cb_id) = callback_query_id {
|
||||||
|
bot.answer_callback_query(cb_id).await?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bot.send_message(chat_id, message)
|
||||||
|
.reply_markup(keyboard)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Database error: {}", e);
|
||||||
|
bot.send_message(chat_id, "Database error occurred").await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle access request
|
||||||
|
pub async fn handle_request_access(
|
||||||
|
bot: Bot,
|
||||||
|
q: &CallbackQuery,
|
||||||
|
db: &DatabaseManager,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let from = &q.from;
|
||||||
|
let lang = get_user_language(from);
|
||||||
|
let l10n = LocalizationService::new();
|
||||||
|
let telegram_id = from.id.0 as i64;
|
||||||
|
let chat_id = q.message.as_ref().and_then(|m| {
|
||||||
|
match m {
|
||||||
|
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}).ok_or("No chat ID")?;
|
||||||
|
|
||||||
|
let user_repo = UserRepository::new(db.connection());
|
||||||
|
let request_repo = UserRequestRepository::new(db.connection().clone());
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
if let Some(_) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) {
|
||||||
|
bot.answer_callback_query(q.id.clone())
|
||||||
|
.text(l10n.get(lang, "already_approved"))
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing requests
|
||||||
|
if let Ok(existing_requests) = request_repo.find_by_telegram_id(telegram_id).await {
|
||||||
|
if let Some(latest_request) = existing_requests.iter()
|
||||||
|
.filter(|r| r.status == "pending")
|
||||||
|
.max_by_key(|r| r.created_at) {
|
||||||
|
|
||||||
|
// Show pending status in the message instead of just an alert
|
||||||
|
let message = l10n.format(lang.clone(), "request_pending", &[
|
||||||
|
("status", "⏳ pending"),
|
||||||
|
("date", &latest_request.created_at.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||||
|
]);
|
||||||
|
|
||||||
|
if let Some(message_ref) = &q.message {
|
||||||
|
if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message_ref {
|
||||||
|
let _ = bot.edit_message_text(chat_id, msg.id, message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")],
|
||||||
|
]))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.answer_callback_query(q.id.clone()).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for declined requests - allow new request after decline
|
||||||
|
let _has_declined = existing_requests.iter()
|
||||||
|
.any(|r| r.status == "declined");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new access request
|
||||||
|
let dto = CreateUserRequestDto {
|
||||||
|
telegram_id,
|
||||||
|
telegram_first_name: Some(from.first_name.clone()),
|
||||||
|
telegram_last_name: from.last_name.clone(),
|
||||||
|
telegram_username: from.username.clone(),
|
||||||
|
request_message: Some("Access request via Telegram bot".to_string()),
|
||||||
|
language: lang.code().to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match request_repo.create(dto).await {
|
||||||
|
Ok(request) => {
|
||||||
|
// Edit message to show success
|
||||||
|
if let Some(message) = &q.message {
|
||||||
|
if let teloxide::types::MaybeInaccessibleMessage::Regular(msg) = message {
|
||||||
|
let _ = bot.edit_message_text(chat_id, msg.id, l10n.get(lang.clone(), "request_submitted"))
|
||||||
|
.reply_markup(InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")],
|
||||||
|
]))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify admins
|
||||||
|
notify_admins_new_request(&bot, &request, db).await?;
|
||||||
|
|
||||||
|
bot.answer_callback_query(q.id.clone()).await?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to create request: {}", e);
|
||||||
|
bot.answer_callback_query(q.id.clone())
|
||||||
|
.text(l10n.format(lang, "request_submit_failed", &[("error", &e.to_string())]))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle my configs with message editing
|
||||||
|
pub async fn handle_my_configs_edit(
|
||||||
|
bot: Bot,
|
||||||
|
q: &CallbackQuery,
|
||||||
|
db: &DatabaseManager,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let from = &q.from;
|
||||||
|
let lang = get_user_language(from);
|
||||||
|
let l10n = LocalizationService::new();
|
||||||
|
let telegram_id = from.id.0 as i64;
|
||||||
|
let chat_id = q.message.as_ref().and_then(|m| {
|
||||||
|
match m {
|
||||||
|
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}).ok_or("No chat ID")?;
|
||||||
|
|
||||||
|
let user_repo = UserRepository::new(db.connection());
|
||||||
|
let inbound_users_repo = crate::database::repository::InboundUsersRepository::new(db.connection().clone());
|
||||||
|
let uri_service = crate::services::UriGeneratorService::new();
|
||||||
|
|
||||||
|
if let Some(user) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) {
|
||||||
|
// Get all active inbound users for this user
|
||||||
|
let inbound_users = inbound_users_repo.find_by_user_id(user.id).await.unwrap_or_default();
|
||||||
|
|
||||||
|
if inbound_users.is_empty() {
|
||||||
|
// Edit message to show no configs available
|
||||||
|
if let Some(msg) = &q.message {
|
||||||
|
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
|
||||||
|
bot.edit_message_text(chat_id, regular_msg.id, l10n.get(lang.clone(), "no_configs_available"))
|
||||||
|
.reply_markup(InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")],
|
||||||
|
]))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bot.answer_callback_query(q.id.clone()).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structure to hold config with inbound_id
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ConfigWithInbound {
|
||||||
|
client_config: crate::services::uri_generator::ClientConfig,
|
||||||
|
server_inbound_id: uuid::Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group configurations by server name
|
||||||
|
let mut servers: std::collections::HashMap<String, Vec<ConfigWithInbound>> = std::collections::HashMap::new();
|
||||||
|
|
||||||
|
for inbound_user in inbound_users {
|
||||||
|
if !inbound_user.is_active {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get client config data for this specific inbound
|
||||||
|
if let Ok(Some(config_data)) = inbound_users_repo.get_client_config_data(user.id, inbound_user.server_inbound_id).await {
|
||||||
|
match uri_service.generate_client_config(user.id, &config_data) {
|
||||||
|
Ok(client_config) => {
|
||||||
|
let config_with_inbound = ConfigWithInbound {
|
||||||
|
client_config: client_config.clone(),
|
||||||
|
server_inbound_id: inbound_user.server_inbound_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
servers.entry(client_config.server_name.clone())
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(config_with_inbound);
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to generate client config: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build message with statistics only
|
||||||
|
let mut message_lines = vec![l10n.get(lang.clone(), "your_configurations")];
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
let server_count = servers.len();
|
||||||
|
let total_configs = servers.values().map(|configs| configs.len()).sum::<usize>();
|
||||||
|
|
||||||
|
// Count unique protocols
|
||||||
|
let mut protocols = std::collections::HashSet::new();
|
||||||
|
for configs in servers.values() {
|
||||||
|
for config_with_inbound in configs {
|
||||||
|
protocols.insert(config_with_inbound.client_config.protocol.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let server_word = match lang {
|
||||||
|
Language::Russian => {
|
||||||
|
if server_count == 1 { "сервер" }
|
||||||
|
else if server_count < 5 { "сервера" }
|
||||||
|
else { "серверов" }
|
||||||
|
},
|
||||||
|
Language::English => {
|
||||||
|
if server_count == 1 { "server" }
|
||||||
|
else { "servers" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let config_word = match lang {
|
||||||
|
Language::Russian => {
|
||||||
|
if total_configs == 1 { "конфигурация" }
|
||||||
|
else if total_configs < 5 { "конфигурации" }
|
||||||
|
else { "конфигураций" }
|
||||||
|
},
|
||||||
|
Language::English => {
|
||||||
|
if total_configs == 1 { "configuration" }
|
||||||
|
else { "configurations" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let protocol_word = match lang {
|
||||||
|
Language::Russian => {
|
||||||
|
if protocols.len() == 1 { "протокол" }
|
||||||
|
else if protocols.len() < 5 { "протокола" }
|
||||||
|
else { "протоколов" }
|
||||||
|
},
|
||||||
|
Language::English => {
|
||||||
|
if protocols.len() == 1 { "protocol" }
|
||||||
|
else { "protocols" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
message_lines.push(format!(
|
||||||
|
"\n📊 {} {} • {} {} • {} {}",
|
||||||
|
server_count, server_word,
|
||||||
|
total_configs, config_word,
|
||||||
|
protocols.len(), protocol_word
|
||||||
|
));
|
||||||
|
|
||||||
|
// Create keyboard with buttons for each server
|
||||||
|
let mut keyboard_buttons = vec![];
|
||||||
|
|
||||||
|
for (server_name, configs) in servers.iter() {
|
||||||
|
// Encode server name to avoid issues with special characters
|
||||||
|
let encoded_server_name = general_purpose::STANDARD.encode(server_name.as_bytes());
|
||||||
|
let config_count = configs.len();
|
||||||
|
|
||||||
|
let config_suffix = match lang {
|
||||||
|
Language::Russian => {
|
||||||
|
if config_count == 1 {
|
||||||
|
""
|
||||||
|
} else if config_count < 5 {
|
||||||
|
"а"
|
||||||
|
} else {
|
||||||
|
"ов"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Language::English => {
|
||||||
|
if config_count == 1 {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
"s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let config_word = match lang {
|
||||||
|
Language::Russian => "конфиг",
|
||||||
|
Language::English => "config",
|
||||||
|
};
|
||||||
|
|
||||||
|
keyboard_buttons.push(vec![
|
||||||
|
InlineKeyboardButton::callback(
|
||||||
|
format!("🖥️ {} ({} {}{})", server_name, config_count, config_word, config_suffix),
|
||||||
|
format!("server_configs:{}", encoded_server_name)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboard_buttons.push(vec![
|
||||||
|
InlineKeyboardButton::callback(l10n.get(lang, "back"), "back")
|
||||||
|
]);
|
||||||
|
|
||||||
|
let message = message_lines.join("\n");
|
||||||
|
|
||||||
|
// Edit the existing message instead of sending a new one
|
||||||
|
if let Some(msg) = &q.message {
|
||||||
|
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
|
||||||
|
bot.edit_message_text(chat_id, regular_msg.id, message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(InlineKeyboardMarkup::new(keyboard_buttons))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.answer_callback_query(q.id.clone()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle show server configs callback
|
||||||
|
pub async fn handle_show_server_configs(
|
||||||
|
bot: Bot,
|
||||||
|
q: &CallbackQuery,
|
||||||
|
encoded_server_name: &str,
|
||||||
|
db: &DatabaseManager,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let from = &q.from;
|
||||||
|
let lang = get_user_language(from);
|
||||||
|
let l10n = LocalizationService::new();
|
||||||
|
let telegram_id = from.id.0 as i64;
|
||||||
|
let chat_id = q.message.as_ref().and_then(|m| {
|
||||||
|
match m {
|
||||||
|
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}).ok_or("No chat ID")?;
|
||||||
|
|
||||||
|
// Decode server name
|
||||||
|
let server_name = match general_purpose::STANDARD.decode(encoded_server_name) {
|
||||||
|
Ok(bytes) => String::from_utf8(bytes).map_err(|_| "Invalid server name encoding")?,
|
||||||
|
Err(_) => return Ok(()), // Invalid encoding, ignore
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_repo = UserRepository::new(db.connection());
|
||||||
|
let inbound_users_repo = crate::database::repository::InboundUsersRepository::new(db.connection().clone());
|
||||||
|
let uri_service = crate::services::UriGeneratorService::new();
|
||||||
|
|
||||||
|
// Get user from telegram_id
|
||||||
|
if let Some(user) = user_repo.get_by_telegram_id(telegram_id).await.unwrap_or(None) {
|
||||||
|
// Get all active inbound users for this user
|
||||||
|
let inbound_users = inbound_users_repo.find_by_user_id(user.id).await.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut server_configs = Vec::new();
|
||||||
|
|
||||||
|
for inbound_user in inbound_users {
|
||||||
|
if !inbound_user.is_active {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get client config data for this specific inbound
|
||||||
|
if let Ok(Some(config_data)) = inbound_users_repo.get_client_config_data(user.id, inbound_user.server_inbound_id).await {
|
||||||
|
if config_data.server_name == server_name {
|
||||||
|
match uri_service.generate_client_config(user.id, &config_data) {
|
||||||
|
Ok(client_config) => {
|
||||||
|
server_configs.push(client_config);
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to generate client config: {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if server_configs.is_empty() {
|
||||||
|
bot.answer_callback_query(q.id.clone())
|
||||||
|
.text(l10n.get(lang, "config_not_found"))
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build message with all configs for this server
|
||||||
|
let mut message_lines = vec![
|
||||||
|
l10n.format(lang.clone(), "server_configs_title", &[("server_name", &server_name)])
|
||||||
|
];
|
||||||
|
|
||||||
|
for config in &server_configs {
|
||||||
|
let protocol_emoji = match config.protocol.as_str() {
|
||||||
|
"vless" => "🔵",
|
||||||
|
"vmess" => "🟢",
|
||||||
|
"trojan" => "🔴",
|
||||||
|
"shadowsocks" => "🟡",
|
||||||
|
_ => "⚪"
|
||||||
|
};
|
||||||
|
|
||||||
|
message_lines.push(format!(
|
||||||
|
"\n{} <b>{} - {}</b> ({})",
|
||||||
|
protocol_emoji,
|
||||||
|
config.server_name,
|
||||||
|
config.template_name,
|
||||||
|
config.protocol.to_uppercase()
|
||||||
|
));
|
||||||
|
|
||||||
|
message_lines.push(format!("<code>{}</code>", config.uri));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create back button
|
||||||
|
let keyboard = InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![InlineKeyboardButton::callback(l10n.get(lang, "back"), "back_to_configs")],
|
||||||
|
]);
|
||||||
|
|
||||||
|
let message = message_lines.join("\n");
|
||||||
|
|
||||||
|
// Edit the existing message instead of sending a new one
|
||||||
|
if let Some(msg) = &q.message {
|
||||||
|
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
|
||||||
|
bot.edit_message_text(chat_id, regular_msg.id, message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(keyboard)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.answer_callback_query(q.id.clone()).await?;
|
||||||
|
} else {
|
||||||
|
bot.answer_callback_query(q.id.clone())
|
||||||
|
.text(l10n.get(lang, "unauthorized"))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle support button
|
||||||
|
pub async fn handle_support(
|
||||||
|
bot: Bot,
|
||||||
|
q: &CallbackQuery,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let from = &q.from;
|
||||||
|
let lang = get_user_language(from);
|
||||||
|
let l10n = LocalizationService::new();
|
||||||
|
let chat_id = q.message.as_ref().and_then(|m| {
|
||||||
|
match m {
|
||||||
|
teloxide::types::MaybeInaccessibleMessage::Regular(msg) => Some(msg.chat.id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}).ok_or("No chat ID")?;
|
||||||
|
|
||||||
|
let keyboard = InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![InlineKeyboardButton::callback(l10n.get(lang.clone(), "back"), "back")],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Edit the existing message instead of sending a new one
|
||||||
|
if let Some(msg) = &q.message {
|
||||||
|
if let teloxide::types::MaybeInaccessibleMessage::Regular(regular_msg) = msg {
|
||||||
|
bot.edit_message_text(chat_id, regular_msg.id, l10n.get(lang, "support_info"))
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(keyboard)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.answer_callback_query(q.id.clone()).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notify admins about new access request
|
||||||
|
async fn notify_admins_new_request(
|
||||||
|
bot: &Bot,
|
||||||
|
request: &crate::database::entities::user_request::Model,
|
||||||
|
db: &DatabaseManager,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let user_repo = UserRepository::new(db.connection());
|
||||||
|
|
||||||
|
// Get all admins
|
||||||
|
let admins = user_repo.get_telegram_admins().await.unwrap_or_default();
|
||||||
|
|
||||||
|
if !admins.is_empty() {
|
||||||
|
let lang = Language::English; // Default admin language
|
||||||
|
let l10n = LocalizationService::new();
|
||||||
|
|
||||||
|
let message = l10n.format(lang.clone(), "new_access_request", &[
|
||||||
|
("first_name", &request.telegram_first_name.as_deref().unwrap_or("")),
|
||||||
|
("last_name", &request.telegram_last_name.as_deref().unwrap_or("")),
|
||||||
|
("username", &request.telegram_username.as_deref().unwrap_or("unknown")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let keyboard = InlineKeyboardMarkup::new(vec![
|
||||||
|
vec![
|
||||||
|
InlineKeyboardButton::callback(l10n.get(lang.clone(), "approve"), format!("approve:{}", request.id)),
|
||||||
|
InlineKeyboardButton::callback(l10n.get(lang.clone(), "decline"), format!("decline:{}", request.id)),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
InlineKeyboardButton::callback("📋 All Requests", "back_to_requests"),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
for admin in admins {
|
||||||
|
if let Some(telegram_id) = admin.telegram_id {
|
||||||
|
let _ = bot.send_message(ChatId(telegram_id), &message)
|
||||||
|
.parse_mode(teloxide::types::ParseMode::Html)
|
||||||
|
.reply_markup(keyboard.clone())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
297
src/services/telegram/localization/mod.rs
Normal file
297
src/services/telegram/localization/mod.rs
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Language {
|
||||||
|
Russian,
|
||||||
|
English,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Language {
|
||||||
|
pub fn from_telegram_code(code: Option<&str>) -> Self {
|
||||||
|
match code {
|
||||||
|
Some("ru") | Some("by") | Some("kk") | Some("uk") => Self::Russian,
|
||||||
|
_ => Self::English, // Default to English
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn code(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Russian => "ru",
|
||||||
|
Self::English => "en",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Translations {
|
||||||
|
pub welcome_new_user: String,
|
||||||
|
pub welcome_back: String,
|
||||||
|
pub request_pending: String,
|
||||||
|
pub request_approved_status: String,
|
||||||
|
pub request_declined_status: String,
|
||||||
|
pub get_vpn_access: String,
|
||||||
|
pub my_configs: String,
|
||||||
|
pub support: String,
|
||||||
|
pub user_requests: String,
|
||||||
|
pub back: String,
|
||||||
|
pub approve: String,
|
||||||
|
pub decline: String,
|
||||||
|
|
||||||
|
// Request handling
|
||||||
|
pub already_pending: String,
|
||||||
|
pub already_approved: String,
|
||||||
|
pub already_declined: String,
|
||||||
|
pub request_submitted: String,
|
||||||
|
pub request_submit_failed: String,
|
||||||
|
|
||||||
|
// Approval/Decline messages
|
||||||
|
pub request_approved: String,
|
||||||
|
pub request_declined: String,
|
||||||
|
pub request_approved_notification: String,
|
||||||
|
pub request_declined_notification: String,
|
||||||
|
|
||||||
|
// Admin messages
|
||||||
|
pub new_access_request: String,
|
||||||
|
pub no_pending_requests: String,
|
||||||
|
pub access_request_details: String,
|
||||||
|
pub unauthorized: String,
|
||||||
|
pub request_approved_admin: String,
|
||||||
|
pub request_declined_admin: String,
|
||||||
|
pub user_creation_failed: String,
|
||||||
|
|
||||||
|
// Support
|
||||||
|
pub support_info: String,
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
pub statistics: String,
|
||||||
|
pub total_users: String,
|
||||||
|
pub total_servers: String,
|
||||||
|
pub total_inbounds: String,
|
||||||
|
pub pending_requests: String,
|
||||||
|
|
||||||
|
// Broadcast
|
||||||
|
pub broadcast_complete: String,
|
||||||
|
pub sent: String,
|
||||||
|
pub failed: String,
|
||||||
|
|
||||||
|
// Configs
|
||||||
|
pub configs_coming_soon: String,
|
||||||
|
pub your_configurations: String,
|
||||||
|
pub no_configs_available: String,
|
||||||
|
pub config_copy_message: String,
|
||||||
|
pub config_copied: String,
|
||||||
|
pub config_not_found: String,
|
||||||
|
pub server_configs_title: String,
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
pub error_occurred: String,
|
||||||
|
pub admin_not_found: String,
|
||||||
|
pub request_not_found: String,
|
||||||
|
pub invalid_request_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LocalizationService {
|
||||||
|
translations: HashMap<Language, Translations>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalizationService {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut translations = HashMap::new();
|
||||||
|
|
||||||
|
// Load English translations
|
||||||
|
translations.insert(Language::English, Self::load_english());
|
||||||
|
|
||||||
|
// Load Russian translations
|
||||||
|
translations.insert(Language::Russian, Self::load_russian());
|
||||||
|
|
||||||
|
Self { translations }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, lang: Language, key: &str) -> String {
|
||||||
|
let translations = self.translations.get(&lang)
|
||||||
|
.unwrap_or_else(|| self.translations.get(&Language::English).unwrap());
|
||||||
|
|
||||||
|
match key {
|
||||||
|
"welcome_new_user" => translations.welcome_new_user.clone(),
|
||||||
|
"welcome_back" => translations.welcome_back.clone(),
|
||||||
|
"request_pending" => translations.request_pending.clone(),
|
||||||
|
"request_approved_status" => translations.request_approved_status.clone(),
|
||||||
|
"request_declined_status" => translations.request_declined_status.clone(),
|
||||||
|
"get_vpn_access" => translations.get_vpn_access.clone(),
|
||||||
|
"my_configs" => translations.my_configs.clone(),
|
||||||
|
"support" => translations.support.clone(),
|
||||||
|
"user_requests" => translations.user_requests.clone(),
|
||||||
|
"back" => translations.back.clone(),
|
||||||
|
"approve" => translations.approve.clone(),
|
||||||
|
"decline" => translations.decline.clone(),
|
||||||
|
"already_pending" => translations.already_pending.clone(),
|
||||||
|
"already_approved" => translations.already_approved.clone(),
|
||||||
|
"already_declined" => translations.already_declined.clone(),
|
||||||
|
"request_submitted" => translations.request_submitted.clone(),
|
||||||
|
"request_submit_failed" => translations.request_submit_failed.clone(),
|
||||||
|
"request_approved" => translations.request_approved.clone(),
|
||||||
|
"request_declined" => translations.request_declined.clone(),
|
||||||
|
"request_approved_notification" => translations.request_approved_notification.clone(),
|
||||||
|
"request_declined_notification" => translations.request_declined_notification.clone(),
|
||||||
|
"new_access_request" => translations.new_access_request.clone(),
|
||||||
|
"no_pending_requests" => translations.no_pending_requests.clone(),
|
||||||
|
"access_request_details" => translations.access_request_details.clone(),
|
||||||
|
"unauthorized" => translations.unauthorized.clone(),
|
||||||
|
"request_approved_admin" => translations.request_approved_admin.clone(),
|
||||||
|
"request_declined_admin" => translations.request_declined_admin.clone(),
|
||||||
|
"user_creation_failed" => translations.user_creation_failed.clone(),
|
||||||
|
"support_info" => translations.support_info.clone(),
|
||||||
|
"statistics" => translations.statistics.clone(),
|
||||||
|
"total_users" => translations.total_users.clone(),
|
||||||
|
"total_servers" => translations.total_servers.clone(),
|
||||||
|
"total_inbounds" => translations.total_inbounds.clone(),
|
||||||
|
"pending_requests" => translations.pending_requests.clone(),
|
||||||
|
"broadcast_complete" => translations.broadcast_complete.clone(),
|
||||||
|
"sent" => translations.sent.clone(),
|
||||||
|
"failed" => translations.failed.clone(),
|
||||||
|
"configs_coming_soon" => translations.configs_coming_soon.clone(),
|
||||||
|
"your_configurations" => translations.your_configurations.clone(),
|
||||||
|
"no_configs_available" => translations.no_configs_available.clone(),
|
||||||
|
"config_copy_message" => translations.config_copy_message.clone(),
|
||||||
|
"config_copied" => translations.config_copied.clone(),
|
||||||
|
"config_not_found" => translations.config_not_found.clone(),
|
||||||
|
"server_configs_title" => translations.server_configs_title.clone(),
|
||||||
|
"error_occurred" => translations.error_occurred.clone(),
|
||||||
|
"admin_not_found" => translations.admin_not_found.clone(),
|
||||||
|
"request_not_found" => translations.request_not_found.clone(),
|
||||||
|
"invalid_request_id" => translations.invalid_request_id.clone(),
|
||||||
|
_ => format!("Missing translation: {}", key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format(&self, lang: Language, template: &str, args: &[(&str, &str)]) -> String {
|
||||||
|
let mut result = self.get(lang, template);
|
||||||
|
for (placeholder, value) in args {
|
||||||
|
result = result.replace(&format!("{{{}}}", placeholder), value);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_english() -> Translations {
|
||||||
|
Translations {
|
||||||
|
welcome_new_user: "👋 Welcome, {username}!\n\nI'm the OutFleet VPN bot. To get started, you'll need to request access.\n\nClick the button below to submit your access request:".to_string(),
|
||||||
|
welcome_back: "👋 Welcome back, {name}!\n\nWhat would you like to do?".to_string(),
|
||||||
|
request_pending: "👋 Hello!\n\nYour access request is currently <b>{status}</b>.\n\nRequest submitted: {date}".to_string(),
|
||||||
|
request_approved_status: "✅ approved".to_string(),
|
||||||
|
request_declined_status: "❌ declined".to_string(),
|
||||||
|
get_vpn_access: "🚀 Get VPN Access".to_string(),
|
||||||
|
my_configs: "📋 My Configs".to_string(),
|
||||||
|
support: "💬 Support".to_string(),
|
||||||
|
user_requests: "👥 User Requests".to_string(),
|
||||||
|
back: "🔙 Back".to_string(),
|
||||||
|
approve: "✅ Approve".to_string(),
|
||||||
|
decline: "❌ Decline".to_string(),
|
||||||
|
|
||||||
|
already_pending: "⏳ You already have a pending access request. Please wait for admin review.".to_string(),
|
||||||
|
already_approved: "✅ Your access request has already been approved. Use /start to access the main menu.".to_string(),
|
||||||
|
already_declined: "❌ Your previous access request was declined. Please contact administrators if you believe this is a mistake.".to_string(),
|
||||||
|
request_submitted: "✅ Your access request has been submitted!\n\nAn administrator will review your request soon. You'll receive a notification once it's processed.".to_string(),
|
||||||
|
request_submit_failed: "❌ Failed to submit request: {error}".to_string(),
|
||||||
|
|
||||||
|
request_approved: "✅ Request approved".to_string(),
|
||||||
|
request_declined: "❌ Request declined".to_string(),
|
||||||
|
request_approved_notification: "🎉 <b>Your access request has been approved!</b>\n\nWelcome to OutFleet VPN! Your account has been created.\n\nUser ID: <code>{user_id}</code>\n\nYou can now use /start to access the main menu.".to_string(),
|
||||||
|
request_declined_notification: "❌ Your access request has been declined.\n\nIf you believe this is a mistake, please contact the administrators.".to_string(),
|
||||||
|
|
||||||
|
new_access_request: "🔔 <b>New Access Request</b>\n\n👤 Name: {first_name} {last_name}\n🆔 Username: @{username}\n\nUse /requests to review".to_string(),
|
||||||
|
no_pending_requests: "No pending access requests".to_string(),
|
||||||
|
access_request_details: "📋 <b>Access Request</b>\n\n👤 Name: {full_name}\n🆔 Telegram: {telegram_link}\n📅 Requested: {date}\n\nMessage: {message}".to_string(),
|
||||||
|
unauthorized: "❌ You are not authorized to use this command".to_string(),
|
||||||
|
request_approved_admin: "✅ Request approved".to_string(),
|
||||||
|
request_declined_admin: "❌ Request declined".to_string(),
|
||||||
|
user_creation_failed: "❌ Failed to create user account: {error}\n\nPlease try again or contact technical support.".to_string(),
|
||||||
|
|
||||||
|
support_info: "💬 <b>Support Information</b>\n\nIf you need help, please contact the administrators.\n\nYou can also check the documentation at:\nhttps://github.com/OutFleet".to_string(),
|
||||||
|
|
||||||
|
statistics: "📊 <b>Statistics</b>\n\n👥 Total Users: {users}\n🖥️ Total Servers: {servers}\n📡 Total Inbounds: {inbounds}\n⏳ Pending Requests: {pending}".to_string(),
|
||||||
|
total_users: "👥 Total Users".to_string(),
|
||||||
|
total_servers: "🖥️ Total Servers".to_string(),
|
||||||
|
total_inbounds: "📡 Total Inbounds".to_string(),
|
||||||
|
pending_requests: "⏳ Pending Requests".to_string(),
|
||||||
|
|
||||||
|
broadcast_complete: "✅ Broadcast complete\nSent: {sent}\nFailed: {failed}".to_string(),
|
||||||
|
sent: "Sent".to_string(),
|
||||||
|
failed: "Failed".to_string(),
|
||||||
|
|
||||||
|
configs_coming_soon: "📋 Your configurations will be shown here (coming soon)".to_string(),
|
||||||
|
your_configurations: "📋 <b>Your Configurations</b>".to_string(),
|
||||||
|
no_configs_available: "📋 No configurations available\n\nYou don't have access to any VPN configurations yet. Please contact an administrator to get access.".to_string(),
|
||||||
|
config_copy_message: "📋 <b>{server_name}</b> - {inbound_tag} ({protocol})\n\nConnection URI:".to_string(),
|
||||||
|
config_copied: "✅ Configuration copied to clipboard".to_string(),
|
||||||
|
config_not_found: "❌ Configuration not found".to_string(),
|
||||||
|
server_configs_title: "🖥️ <b>{server_name}</b> - Connection Links".to_string(),
|
||||||
|
|
||||||
|
error_occurred: "An error occurred".to_string(),
|
||||||
|
admin_not_found: "Admin not found".to_string(),
|
||||||
|
request_not_found: "Request not found".to_string(),
|
||||||
|
invalid_request_id: "Invalid request ID".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_russian() -> Translations {
|
||||||
|
Translations {
|
||||||
|
welcome_new_user: "👋 Добро пожаловать, {username}!\n\nЯ бот OutFleet VPN. Чтобы начать работу, вам необходимо запросить доступ.\n\nНажмите кнопку ниже, чтобы отправить запрос на доступ:".to_string(),
|
||||||
|
welcome_back: "👋 С возвращением, {name}!\n\nЧто вы хотите сделать?".to_string(),
|
||||||
|
request_pending: "👋 Привет!\n\nВаш запрос на доступ в настоящее время <b>{status}</b>.\n\nЗапрос отправлен: {date}".to_string(),
|
||||||
|
request_approved_status: "✅ одобрен".to_string(),
|
||||||
|
request_declined_status: "❌ отклонен".to_string(),
|
||||||
|
get_vpn_access: "🚀 Получить доступ к VPN".to_string(),
|
||||||
|
my_configs: "📋 Мои конфигурации".to_string(),
|
||||||
|
support: "💬 Поддержка".to_string(),
|
||||||
|
user_requests: "👥 Запросы пользователей".to_string(),
|
||||||
|
back: "🔙 Назад".to_string(),
|
||||||
|
approve: "✅ Одобрить".to_string(),
|
||||||
|
decline: "❌ Отклонить".to_string(),
|
||||||
|
|
||||||
|
already_pending: "⏳ У вас уже есть ожидающий рассмотрения запрос на доступ. Пожалуйста, дождитесь проверки администратором.".to_string(),
|
||||||
|
already_approved: "✅ Ваш запрос на доступ уже был одобрен. Используйте /start для доступа к главному меню.".to_string(),
|
||||||
|
already_declined: "❌ Ваш предыдущий запрос на доступ был отклонен. Пожалуйста, свяжитесь с администраторами, если считаете, что это ошибка.".to_string(),
|
||||||
|
request_submitted: "✅ Ваш запрос на доступ отправлен!\n\nАдминистратор скоро рассмотрит ваш запрос. Вы получите уведомление после обработки.".to_string(),
|
||||||
|
request_submit_failed: "❌ Не удалось отправить запрос: {error}".to_string(),
|
||||||
|
|
||||||
|
request_approved: "✅ Запрос одобрен".to_string(),
|
||||||
|
request_declined: "❌ Запрос отклонен".to_string(),
|
||||||
|
request_approved_notification: "🎉 <b>Ваш запрос на доступ одобрен!</b>\n\nДобро пожаловать в OutFleet VPN! Ваш аккаунт создан.\n\nID пользователя: <code>{user_id}</code>\n\nТеперь вы можете использовать /start для доступа к главному меню.".to_string(),
|
||||||
|
request_declined_notification: "❌ Ваш запрос на доступ отклонен.\n\nЕсли вы считаете, что это ошибка, пожалуйста, свяжитесь с администраторами.".to_string(),
|
||||||
|
|
||||||
|
new_access_request: "🔔 <b>Новый запрос на доступ</b>\n\n👤 Имя: {first_name} {last_name}\n🆔 Имя пользователя: @{username}\n\nИспользуйте /requests для просмотра".to_string(),
|
||||||
|
no_pending_requests: "Нет ожидающих запросов на доступ".to_string(),
|
||||||
|
access_request_details: "📋 <b>Запрос на доступ</b>\n\n👤 Имя: {full_name}\n🆔 Telegram: {telegram_link}\n📅 Запрошено: {date}\n\nСообщение: {message}".to_string(),
|
||||||
|
unauthorized: "❌ У вас нет прав для использования этой команды".to_string(),
|
||||||
|
request_approved_admin: "✅ Запрос одобрен".to_string(),
|
||||||
|
request_declined_admin: "❌ Запрос отклонен".to_string(),
|
||||||
|
user_creation_failed: "❌ Не удалось создать аккаунт пользователя: {error}\n\nПожалуйста, попробуйте еще раз или обратитесь в техническую поддержку.".to_string(),
|
||||||
|
|
||||||
|
support_info: "💬 <b>Информация о поддержке</b>\n\nЕсли вам нужна помощь, пожалуйста, свяжитесь с администраторами.\n\nВы также можете ознакомиться с документацией по адресу:\nhttps://github.com/OutFleet".to_string(),
|
||||||
|
|
||||||
|
statistics: "📊 <b>Статистика</b>\n\n👥 Всего пользователей: {users}\n🖥️ Всего серверов: {servers}\n📡 Всего входящих подключений: {inbounds}\n⏳ Ожидающих запросов: {pending}".to_string(),
|
||||||
|
total_users: "👥 Всего пользователей".to_string(),
|
||||||
|
total_servers: "🖥️ Всего серверов".to_string(),
|
||||||
|
total_inbounds: "📡 Всего входящих подключений".to_string(),
|
||||||
|
pending_requests: "⏳ Ожидающих запросов".to_string(),
|
||||||
|
|
||||||
|
broadcast_complete: "✅ Рассылка завершена\nОтправлено: {sent}\nНе удалось: {failed}".to_string(),
|
||||||
|
sent: "Отправлено".to_string(),
|
||||||
|
failed: "Не удалось".to_string(),
|
||||||
|
|
||||||
|
configs_coming_soon: "📋 Ваши конфигурации будут показаны здесь (скоро)".to_string(),
|
||||||
|
your_configurations: "📋 <b>Ваши конфигурации</b>".to_string(),
|
||||||
|
no_configs_available: "📋 Нет доступных конфигураций\n\nУ вас пока нет доступа к конфигурациям VPN. Пожалуйста, обратитесь к администратору для получения доступа.".to_string(),
|
||||||
|
config_copy_message: "📋 <b>{server_name}</b> - {inbound_tag} ({protocol})\n\nСсылка для подключения:".to_string(),
|
||||||
|
config_copied: "✅ Конфигурация скопирована в буфер обмена".to_string(),
|
||||||
|
config_not_found: "❌ Конфигурация не найдена".to_string(),
|
||||||
|
server_configs_title: "🖥️ <b>{server_name}</b> - Ссылки для подключения".to_string(),
|
||||||
|
|
||||||
|
error_occurred: "Произошла ошибка".to_string(),
|
||||||
|
admin_not_found: "Администратор не найден".to_string(),
|
||||||
|
request_not_found: "Запрос не найден".to_string(),
|
||||||
|
invalid_request_id: "Неверный ID запроса".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use crate::database::entities::telegram_config::Model as TelegramConfig;
|
|||||||
pub mod bot;
|
pub mod bot;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod localization;
|
||||||
|
|
||||||
pub use error::TelegramError;
|
pub use error::TelegramError;
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ pub mod utils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Determine alias for the URI
|
/// Determine alias for the URI
|
||||||
pub fn generate_alias(user_name: &str, server_name: &str, inbound_tag: &str) -> String {
|
pub fn generate_alias(server_name: &str, template_name: &str) -> String {
|
||||||
format!("{}@{}-{}", user_name, server_name, inbound_tag)
|
format!("{} - {}", server_name, template_name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ impl UriBuilder for ShadowsocksUriBuilder {
|
|||||||
let encoded_credentials = general_purpose::STANDARD.encode(credentials.as_bytes());
|
let encoded_credentials = general_purpose::STANDARD.encode(credentials.as_bytes());
|
||||||
|
|
||||||
// Generate alias for the URI
|
// Generate alias for the URI
|
||||||
let alias = utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag);
|
let alias = utils::generate_alias(&config.server_name, &config.template_name);
|
||||||
|
|
||||||
// Build simple SIP002 URI (no plugin parameters for standard Shadowsocks)
|
// Build simple SIP002 URI (no plugin parameters for standard Shadowsocks)
|
||||||
let uri = format!(
|
let uri = format!(
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ impl UriBuilder for TrojanUriBuilder {
|
|||||||
|
|
||||||
// Build the URI
|
// Build the URI
|
||||||
let query_string = utils::build_query_string(¶ms);
|
let query_string = utils::build_query_string(¶ms);
|
||||||
let alias = utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag);
|
let alias = utils::generate_alias(&config.server_name, &config.template_name);
|
||||||
|
|
||||||
let uri = if query_string.is_empty() {
|
let uri = if query_string.is_empty() {
|
||||||
format!(
|
format!(
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ impl UriBuilder for VlessUriBuilder {
|
|||||||
|
|
||||||
// Build the URI
|
// Build the URI
|
||||||
let query_string = utils::build_query_string(¶ms);
|
let query_string = utils::build_query_string(¶ms);
|
||||||
let alias = utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag);
|
let alias = utils::generate_alias(&config.server_name, &config.template_name);
|
||||||
|
|
||||||
let uri = if query_string.is_empty() {
|
let uri = if query_string.is_empty() {
|
||||||
format!(
|
format!(
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ impl VmessUriBuilder {
|
|||||||
"net": transport_type,
|
"net": transport_type,
|
||||||
"path": "",
|
"path": "",
|
||||||
"port": config.port,
|
"port": config.port,
|
||||||
"ps": utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag),
|
"ps": utils::generate_alias(&config.server_name, &config.template_name),
|
||||||
"scy": "auto",
|
"scy": "auto",
|
||||||
"tls": if security == "none" { "none" } else { &security },
|
"tls": if security == "none" { "none" } else { &security },
|
||||||
"type": "none",
|
"type": "none",
|
||||||
@@ -196,7 +196,7 @@ impl VmessUriBuilder {
|
|||||||
|
|
||||||
// Build the URI
|
// Build the URI
|
||||||
let query_string = utils::build_query_string(¶ms);
|
let query_string = utils::build_query_string(¶ms);
|
||||||
let alias = utils::generate_alias(&config.user_name, &config.server_name, &config.inbound_tag);
|
let alias = utils::generate_alias(&config.server_name, &config.template_name);
|
||||||
|
|
||||||
let uri = if query_string.is_empty() {
|
let uri = if query_string.is_empty() {
|
||||||
format!(
|
format!(
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ pub struct ClientConfigData {
|
|||||||
// Metadata
|
// Metadata
|
||||||
pub server_name: String,
|
pub server_name: String,
|
||||||
pub inbound_tag: String,
|
pub inbound_tag: String,
|
||||||
|
pub template_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated client configuration
|
/// Generated client configuration
|
||||||
@@ -45,6 +46,7 @@ pub struct ClientConfig {
|
|||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub server_name: String,
|
pub server_name: String,
|
||||||
pub inbound_tag: String,
|
pub inbound_tag: String,
|
||||||
|
pub template_name: String,
|
||||||
pub protocol: String,
|
pub protocol: String,
|
||||||
pub uri: String,
|
pub uri: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -92,6 +94,7 @@ impl UriGeneratorService {
|
|||||||
user_id,
|
user_id,
|
||||||
server_name: config.server_name.clone(),
|
server_name: config.server_name.clone(),
|
||||||
inbound_tag: config.inbound_tag.clone(),
|
inbound_tag: config.inbound_tag.clone(),
|
||||||
|
template_name: config.template_name.clone(),
|
||||||
protocol: config.protocol.clone(),
|
protocol: config.protocol.clone(),
|
||||||
uri,
|
uri,
|
||||||
qr_code: None, // TODO: Implement QR code generation if needed
|
qr_code: None, // TODO: Implement QR code generation if needed
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use anyhow::{Result, anyhow};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use xray_core::Client;
|
use xray_core::Client;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio::time::{timeout, Duration};
|
||||||
|
|
||||||
// Import submodules from the same directory
|
// Import submodules from the same directory
|
||||||
use super::stats::StatsClient;
|
use super::stats::StatsClient;
|
||||||
@@ -17,17 +18,25 @@ pub struct XrayClient {
|
|||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
impl XrayClient {
|
impl XrayClient {
|
||||||
/// Connect to Xray gRPC server
|
/// Connect to Xray gRPC server with timeout
|
||||||
pub async fn connect(endpoint: &str) -> Result<Self> {
|
pub async fn connect(endpoint: &str) -> Result<Self> {
|
||||||
let client = Client::from_url(endpoint).await
|
// Apply a 5-second timeout to the connection attempt
|
||||||
.map_err(|e| anyhow!("Failed to connect to Xray at {}: {}", endpoint, e))?;
|
let connect_future = Client::from_url(endpoint);
|
||||||
|
|
||||||
// Don't clone - we'll use &self.client when calling methods
|
|
||||||
|
|
||||||
|
match timeout(Duration::from_secs(5), connect_future).await {
|
||||||
|
Ok(Ok(client)) => {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
endpoint: endpoint.to_string(),
|
endpoint: endpoint.to_string(),
|
||||||
client: Arc::new(client),
|
client: Arc::new(client),
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
Err(anyhow!("Failed to connect to Xray at {}: {}", endpoint, e))
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
Err(anyhow!("Connection to Xray at {} timed out after 5 seconds", endpoint))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get server statistics
|
/// Get server statistics
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use uuid::Uuid;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tokio::time::{Duration, Instant};
|
use tokio::time::{Duration, Instant, timeout};
|
||||||
use tracing::error;
|
use tracing::{error, warn};
|
||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
@@ -78,15 +78,24 @@ impl XrayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Test connection to Xray server
|
/// Test connection to Xray server with timeout
|
||||||
pub async fn test_connection(&self, _server_id: Uuid, endpoint: &str) -> Result<bool> {
|
pub async fn test_connection(&self, _server_id: Uuid, endpoint: &str) -> Result<bool> {
|
||||||
match self.get_or_create_client(endpoint).await {
|
// Apply a 3-second timeout to the entire test operation
|
||||||
Ok(_client) => {
|
match timeout(Duration::from_secs(3), self.get_or_create_client(endpoint)).await {
|
||||||
// Instead of getting stats (which might fail), just test connection
|
Ok(Ok(_client)) => {
|
||||||
// If we successfully created the client, connection is working
|
// Connection successful
|
||||||
Ok(true)
|
Ok(true)
|
||||||
},
|
},
|
||||||
Err(_) => Ok(false),
|
Ok(Err(e)) => {
|
||||||
|
// Connection failed with error
|
||||||
|
warn!("Failed to connect to Xray at {}: {}", endpoint, e);
|
||||||
|
Ok(false)
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
// Operation timed out
|
||||||
|
warn!("Connection test to Xray at {} timed out", endpoint);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub mod client_configs;
|
|||||||
pub mod dns_providers;
|
pub mod dns_providers;
|
||||||
pub mod tasks;
|
pub mod tasks;
|
||||||
pub mod telegram;
|
pub mod telegram;
|
||||||
|
pub mod user_requests;
|
||||||
|
|
||||||
pub use users::*;
|
pub use users::*;
|
||||||
pub use servers::*;
|
pub use servers::*;
|
||||||
@@ -15,3 +16,4 @@ pub use client_configs::*;
|
|||||||
pub use dns_providers::*;
|
pub use dns_providers::*;
|
||||||
pub use tasks::*;
|
pub use tasks::*;
|
||||||
pub use telegram::*;
|
pub use telegram::*;
|
||||||
|
pub use user_requests::*;
|
||||||
224
src/web/handlers/user_requests.rs
Normal file
224
src/web/handlers/user_requests.rs
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
Json,
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
database::entities::user_request::{CreateUserRequestDto, UpdateUserRequestDto, RequestStatus},
|
||||||
|
database::repository::UserRequestRepository,
|
||||||
|
web::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RequestsQuery {
|
||||||
|
#[serde(default = "default_page")]
|
||||||
|
page: u64,
|
||||||
|
#[serde(default = "default_per_page")]
|
||||||
|
per_page: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_page() -> u64 { 1 }
|
||||||
|
fn default_per_page() -> u64 { 20 }
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RequestsResponse {
|
||||||
|
items: Vec<UserRequestResponse>,
|
||||||
|
total: u64,
|
||||||
|
page: u64,
|
||||||
|
per_page: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UserRequestResponse {
|
||||||
|
id: Uuid,
|
||||||
|
user_id: Option<Uuid>,
|
||||||
|
telegram_id: i64,
|
||||||
|
telegram_username: Option<String>,
|
||||||
|
telegram_first_name: Option<String>,
|
||||||
|
telegram_last_name: Option<String>,
|
||||||
|
full_name: String,
|
||||||
|
telegram_link: String,
|
||||||
|
status: String,
|
||||||
|
request_message: Option<String>,
|
||||||
|
response_message: Option<String>,
|
||||||
|
processed_by_user_id: Option<Uuid>,
|
||||||
|
processed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<crate::database::entities::user_request::Model> for UserRequestResponse {
|
||||||
|
fn from(model: crate::database::entities::user_request::Model) -> Self {
|
||||||
|
Self {
|
||||||
|
id: model.id,
|
||||||
|
user_id: model.user_id,
|
||||||
|
telegram_id: model.telegram_id,
|
||||||
|
telegram_username: model.telegram_username.clone(),
|
||||||
|
telegram_first_name: model.telegram_first_name.clone(),
|
||||||
|
telegram_last_name: model.telegram_last_name.clone(),
|
||||||
|
full_name: model.get_full_name(),
|
||||||
|
telegram_link: model.get_telegram_link(),
|
||||||
|
status: model.status,
|
||||||
|
request_message: model.request_message,
|
||||||
|
response_message: model.response_message,
|
||||||
|
processed_by_user_id: model.processed_by_user_id,
|
||||||
|
processed_at: model.processed_at.map(|dt| dt.into()),
|
||||||
|
created_at: model.created_at.into(),
|
||||||
|
updated_at: model.updated_at.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get user requests with pagination
|
||||||
|
pub async fn get_requests(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<RequestsQuery>,
|
||||||
|
) -> Result<Json<RequestsResponse>, StatusCode> {
|
||||||
|
let request_repo = UserRequestRepository::new(state.db.connection());
|
||||||
|
|
||||||
|
let (items, total) = if let Some(status) = query.status {
|
||||||
|
// Filter by status
|
||||||
|
match status.as_str() {
|
||||||
|
"pending" => request_repo.find_pending(query.page, query.per_page).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||||||
|
_ => request_repo.find_all(query.page, query.per_page).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
request_repo.find_all(query.page, query.per_page).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let items: Vec<UserRequestResponse> = items.into_iter().map(Into::into).collect();
|
||||||
|
|
||||||
|
Ok(Json(RequestsResponse {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page: query.page,
|
||||||
|
per_page: query.per_page,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a single user request
|
||||||
|
pub async fn get_request(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<UserRequestResponse>, StatusCode> {
|
||||||
|
let request_repo = UserRequestRepository::new(state.db.connection());
|
||||||
|
|
||||||
|
match request_repo.find_by_id(id).await {
|
||||||
|
Ok(Some(request)) => Ok(Json(UserRequestResponse::from(request))),
|
||||||
|
Ok(None) => Err(StatusCode::NOT_FOUND),
|
||||||
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ApproveRequestDto {
|
||||||
|
response_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approve a user request
|
||||||
|
pub async fn approve_request(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(dto): Json<ApproveRequestDto>,
|
||||||
|
) -> Result<Json<UserRequestResponse>, StatusCode> {
|
||||||
|
let request_repo = UserRequestRepository::new(state.db.connection());
|
||||||
|
let user_repo = crate::database::repository::UserRepository::new(state.db.connection());
|
||||||
|
|
||||||
|
// Get the request
|
||||||
|
let request = match request_repo.find_by_id(id).await {
|
||||||
|
Ok(Some(request)) => request,
|
||||||
|
Ok(None) => return Err(StatusCode::NOT_FOUND),
|
||||||
|
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if already processed
|
||||||
|
if request.status != "pending" {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user account
|
||||||
|
let username = request.telegram_username.as_deref().unwrap_or("Unknown");
|
||||||
|
let user_dto = crate::database::entities::user::CreateUserDto {
|
||||||
|
name: request.get_full_name(),
|
||||||
|
comment: Some(format!("Telegram user: @{}", username)),
|
||||||
|
telegram_id: Some(request.telegram_id),
|
||||||
|
is_telegram_admin: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
match user_repo.create(user_dto).await {
|
||||||
|
Ok(new_user) => {
|
||||||
|
// Approve the request
|
||||||
|
let approved = match request_repo.approve(id, dto.response_message, new_user.id).await {
|
||||||
|
Ok(Some(approved)) => approved,
|
||||||
|
Ok(None) => return Err(StatusCode::NOT_FOUND),
|
||||||
|
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Send Telegram notification to user
|
||||||
|
|
||||||
|
Ok(Json(UserRequestResponse::from(approved)))
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
Err(StatusCode::BAD_REQUEST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DeclineRequestDto {
|
||||||
|
response_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decline a user request
|
||||||
|
pub async fn decline_request(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(dto): Json<DeclineRequestDto>,
|
||||||
|
) -> Result<Json<UserRequestResponse>, StatusCode> {
|
||||||
|
let request_repo = UserRequestRepository::new(state.db.connection());
|
||||||
|
|
||||||
|
// Get the request
|
||||||
|
let request = match request_repo.find_by_id(id).await {
|
||||||
|
Ok(Some(request)) => request,
|
||||||
|
Ok(None) => return Err(StatusCode::NOT_FOUND),
|
||||||
|
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if already processed
|
||||||
|
if request.status != "pending" {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a default user ID for declined requests (we can set it to the first admin user)
|
||||||
|
let dummy_user_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
// Decline the request
|
||||||
|
let declined = match request_repo.decline(id, dto.response_message, dummy_user_id).await {
|
||||||
|
Ok(Some(declined)) => declined,
|
||||||
|
Ok(None) => return Err(StatusCode::NOT_FOUND),
|
||||||
|
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: Send Telegram notification to user
|
||||||
|
|
||||||
|
Ok(Json(UserRequestResponse::from(declined)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a user request
|
||||||
|
pub async fn delete_request(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||||
|
let request_repo = UserRequestRepository::new(state.db.connection());
|
||||||
|
|
||||||
|
match request_repo.delete(id).await {
|
||||||
|
Ok(true) => Ok(Json(serde_json::json!({ "message": "User request deleted" }))),
|
||||||
|
Ok(false) => Err(StatusCode::NOT_FOUND),
|
||||||
|
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
routing::{get, post},
|
routing::{get, post, put, delete},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::web::{AppState, handlers};
|
use crate::web::{AppState, handlers};
|
||||||
@@ -17,6 +17,7 @@ pub fn api_routes() -> Router<AppState> {
|
|||||||
.nest("/dns-providers", dns_provider_routes())
|
.nest("/dns-providers", dns_provider_routes())
|
||||||
.nest("/tasks", task_routes())
|
.nest("/tasks", task_routes())
|
||||||
.nest("/telegram", telegram_routes())
|
.nest("/telegram", telegram_routes())
|
||||||
|
.nest("/user-requests", user_request_routes())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User management routes
|
/// User management routes
|
||||||
@@ -65,3 +66,12 @@ fn telegram_routes() -> Router<AppState> {
|
|||||||
.delete(handlers::remove_telegram_admin))
|
.delete(handlers::remove_telegram_admin))
|
||||||
.route("/send", post(handlers::send_test_message))
|
.route("/send", post(handlers::send_test_message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// User request management routes
|
||||||
|
fn user_request_routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(handlers::get_requests))
|
||||||
|
.route("/:id", get(handlers::get_request).delete(handlers::delete_request))
|
||||||
|
.route("/:id/approve", post(handlers::approve_request))
|
||||||
|
.route("/:id/decline", post(handlers::decline_request))
|
||||||
|
}
|
||||||
@@ -906,6 +906,103 @@
|
|||||||
color: #1d1d1f;
|
color: #1d1d1f;
|
||||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* User Requests Styles */
|
||||||
|
.request-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-info {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-info p {
|
||||||
|
margin: 8px 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background: #ffc107;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-secondary {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-success {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-success:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-danger {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-danger:hover {
|
||||||
|
background: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-small {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -940,6 +1037,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a href="#telegram" class="nav-link" onclick="showPage('telegram')">Telegram Bot</a>
|
<a href="#telegram" class="nav-link" onclick="showPage('telegram')">Telegram Bot</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="#user-requests" class="nav-link" onclick="showPage('user-requests')">User Requests</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -1293,6 +1393,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- User Requests -->
|
||||||
|
<section id="user-requests" class="page-section">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">User Requests</h1>
|
||||||
|
<p class="page-subtitle">Manage access requests from Telegram users</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request Status Overview -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Request Overview</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="stats-grid" id="requestStats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="pendingRequests">0</div>
|
||||||
|
<div class="stat-label">Pending</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="approvedRequests">0</div>
|
||||||
|
<div class="stat-label">Approved</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value" id="declinedRequests">0</div>
|
||||||
|
<div class="stat-label">Declined</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending Requests -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Pending Requests</h2>
|
||||||
|
<button class="button button-outline" onclick="loadUserRequests()">
|
||||||
|
<span class="icon">🔄</span>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="pendingRequestsTable" class="loading">Loading...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All Requests -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">All Requests</h2>
|
||||||
|
</div>
|
||||||
|
<div id="allRequestsTable" class="loading">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2966,11 +3117,246 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// User Requests Functions
|
||||||
|
async function loadUserRequests() {
|
||||||
|
await loadRequestStats();
|
||||||
|
await loadPendingRequests();
|
||||||
|
await loadAllRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRequestStats() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/user-requests`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const requests = data.items || [];
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
pending: requests.filter(r => r.status === 'pending').length,
|
||||||
|
approved: requests.filter(r => r.status === 'approved').length,
|
||||||
|
declined: requests.filter(r => r.status === 'declined').length
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('pendingRequests').textContent = stats.pending;
|
||||||
|
document.getElementById('approvedRequests').textContent = stats.approved;
|
||||||
|
document.getElementById('declinedRequests').textContent = stats.declined;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading request stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPendingRequests() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/user-requests?status=pending`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
renderPendingRequests(data.items || []);
|
||||||
|
} else {
|
||||||
|
document.getElementById('pendingRequestsTable').innerHTML = '<p class="error-message">Failed to load pending requests</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading pending requests:', error);
|
||||||
|
document.getElementById('pendingRequestsTable').innerHTML = '<p class="error-message">Error loading pending requests</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllRequests() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/user-requests`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
renderAllRequests(data.items || []);
|
||||||
|
} else {
|
||||||
|
document.getElementById('allRequestsTable').innerHTML = '<p class="error-message">Failed to load requests</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading requests:', error);
|
||||||
|
document.getElementById('allRequestsTable').innerHTML = '<p class="error-message">Error loading requests</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPendingRequests(requests) {
|
||||||
|
const container = document.getElementById('pendingRequestsTable');
|
||||||
|
|
||||||
|
if (requests.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-muted">No pending requests</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="request-cards">
|
||||||
|
${requests.map(request => `
|
||||||
|
<div class="request-card">
|
||||||
|
<div class="request-header">
|
||||||
|
<h4>${escapeHtml(request.full_name)}</h4>
|
||||||
|
<span class="badge badge-warning">Pending</span>
|
||||||
|
</div>
|
||||||
|
<div class="request-info">
|
||||||
|
<p>📱 Telegram: ${request.telegram_username ? '@' + escapeHtml(request.telegram_username) : 'ID: ' + request.telegram_id}</p>
|
||||||
|
<p>📅 Requested: ${new Date(request.created_at).toLocaleString()}</p>
|
||||||
|
${request.request_message ? `<p>💬 Message: ${escapeHtml(request.request_message)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="request-actions">
|
||||||
|
<button class="button button-success" onclick="approveRequest('${request.id}')">
|
||||||
|
✅ Approve
|
||||||
|
</button>
|
||||||
|
<button class="button button-danger" onclick="declineRequest('${request.id}')">
|
||||||
|
❌ Decline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
container.classList.remove('loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAllRequests(requests) {
|
||||||
|
const container = document.getElementById('allRequestsTable');
|
||||||
|
|
||||||
|
if (requests.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-muted">No requests found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Telegram</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Requested</th>
|
||||||
|
<th>Processed By</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${requests.map(request => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(request.full_name)}</td>
|
||||||
|
<td>${request.telegram_username ? '@' + escapeHtml(request.telegram_username) : 'ID: ' + request.telegram_id}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-${getStatusBadgeClass(request.status)}">
|
||||||
|
${escapeHtml(request.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>${new Date(request.created_at).toLocaleString()}</td>
|
||||||
|
<td>${request.processed_at ? new Date(request.processed_at).toLocaleString() : '-'}</td>
|
||||||
|
<td>
|
||||||
|
${request.status === 'pending' ? `
|
||||||
|
<button class="button button-small button-success" onclick="approveRequest('${request.id}')">
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button class="button button-small button-danger" onclick="declineRequest('${request.id}')">
|
||||||
|
Decline
|
||||||
|
</button>
|
||||||
|
` : `
|
||||||
|
<button class="button button-small button-outline" onclick="deleteRequest('${request.id}')">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
container.classList.remove('loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadgeClass(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending': return 'warning';
|
||||||
|
case 'approved': return 'success';
|
||||||
|
case 'declined': return 'danger';
|
||||||
|
default: return 'secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function approveRequest(requestId) {
|
||||||
|
const message = prompt('Optional message for the user:');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/user-requests/${requestId}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ response_message: message })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showAlert('Request approved successfully', 'success');
|
||||||
|
await loadUserRequests();
|
||||||
|
} else {
|
||||||
|
const error = await response.text();
|
||||||
|
showAlert('Failed to approve request: ' + error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('Error approving request: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function declineRequest(requestId) {
|
||||||
|
const message = prompt('Reason for declining (optional):');
|
||||||
|
|
||||||
|
if (message === null) return; // User cancelled
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/user-requests/${requestId}/decline`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ response_message: message })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showAlert('Request declined', 'info');
|
||||||
|
await loadUserRequests();
|
||||||
|
} else {
|
||||||
|
const error = await response.text();
|
||||||
|
showAlert('Failed to decline request: ' + error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('Error declining request: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRequest(requestId) {
|
||||||
|
if (!confirm('Are you sure you want to delete this request?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/user-requests/${requestId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showAlert('Request deleted', 'success');
|
||||||
|
await loadUserRequests();
|
||||||
|
} else {
|
||||||
|
showAlert('Failed to delete request', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('Error deleting request: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update loadPageData function to include telegram
|
// Update loadPageData function to include telegram
|
||||||
const originalLoadPageData = window.loadPageData;
|
const originalLoadPageData = window.loadPageData;
|
||||||
window.loadPageData = function(page) {
|
window.loadPageData = function(page) {
|
||||||
if (page === 'telegram') {
|
if (page === 'telegram') {
|
||||||
loadTelegram();
|
loadTelegram();
|
||||||
|
} else if (page === 'user-requests') {
|
||||||
|
loadUserRequests();
|
||||||
} else if (originalLoadPageData) {
|
} else if (originalLoadPageData) {
|
||||||
originalLoadPageData(page);
|
originalLoadPageData(page);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user