use std::sync::Arc; use teloxide::{ dispatching::{dialogue::{InMemStorage, Storage}, UpdateFilterExt}, net::Download, prelude::*, types::{ InlineKeyboardButton, InlineKeyboardMarkup, Message, ParseMode, CallbackQuery, ChatMemberStatus, UserId, }, utils::command::BotCommands, }; use cgcx_config::Config; use cgcx_core::{ContentId, ContentStatus, ReportStatus, UserRole}; use cgcx_crypto::MasterKey; use cgcx_db::{Database, UserRepo, ContentRepo, ContentFileRepo, ReportRepo}; use cgcx_file_pipeline::FilePipeline; use cgcx_moderation::ModerationEngine; use cgcx_storage::Storage as CgcxStorage; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; #[derive(Clone, Default, Serialize, Deserialize)] pub enum BotState { #[default] Start, TermsPending, MainMenu, UploadStaging { items: Vec, upload_type: UploadType }, UploadOptions { items: Vec, options: UploadOptions }, UploadFinalizing, Reporting, ViewingPrevious { page: usize }, } #[derive(Clone, Copy, Serialize, Deserialize)] pub enum UploadType { Media, Document, Text, } impl Default for UploadType { fn default() -> Self { UploadType::Media } } #[derive(Clone, Serialize, Deserialize)] pub struct StagedItem { pub file_id: String, pub file_name: String, pub mime_type: String, pub size: u64, pub caption: Option, } #[derive(Clone, Default, Serialize, Deserialize)] pub struct UploadOptions { pub max_views: Option, pub allow_download: bool, pub password: Option, } type HandlerResult = Result<(), Box>; #[derive(Clone)] struct BotDialogue { chat_id: ChatId, storage: Arc>, } impl BotDialogue { async fn get(&self) -> Result> { Ok(self.storage.clone().get_dialogue(self.chat_id).await?.unwrap_or_default()) } async fn update(&self, state: BotState) -> Result<(), Box> { Ok(self.storage.clone().update_dialogue(self.chat_id, state).await?) } async fn reset(&self) -> Result<(), Box> { Ok(self.storage.clone().remove_dialogue(self.chat_id).await?) } async fn exit(&self) -> Result<(), Box> { self.reset().await } async fn get_or_default(&self) -> Result> { self.get().await } } #[derive(BotCommands, Clone)] #[command(rename_rule = "lowercase", description = "Commands:")] #[allow(dead_code)] enum Command { #[command(description = "Start the bot")] Start, } const TERMS_TEXT: &str = r#"Welcome to CG.CX Before using this service, you must read and accept the following terms: 1. No Responsibility: This service is not responsible for whatever media or files are shared by users. 2. Content Warning: Content may be uncomfortable, including bloody scenes or other disturbing material. 3. Prohibited Content: You must NOT upload: • Doxes / doxxing material • CSAM or any sexual content involving minors • Animal cruelty material • Malware, stealers, droppers, loaders, ransomware, weaponized files, or intentionally malicious binaries/scripts • Suicide guides, self-harm instructions, or material explicitly intended to facilitate suicide 4. Enforcement: Violating these rules may result in immediate deletion, reporting to authorities, blacklisting, or moderator escalation. By clicking "Accept", you confirm you are at least 18 years old and agree to these terms."#; #[derive(Clone)] struct BotContext { db: Arc, #[allow(dead_code)] storage: Arc, config: Arc, master_key: Arc, moderation: Arc, pipeline: Arc, sem: Arc, } #[tokio::main] async fn main() { tracing_subscriber::fmt::init(); let config = Arc::new(Config::load().expect("Failed to load config")); let db = Arc::new(Database::open("data/db.sqlite").expect("Failed to open database")); db.run_migrations().await.expect("Failed to run migrations"); let storage = Arc::new(CgcxStorage::new(config.storage.paths.clone())); storage.ensure_dirs().await.expect("Failed to ensure storage dirs"); let master_key = match &config.crypto.aes_master_key_source { cgcx_config::KeySource::Env { var } => MasterKey::load_from_env(var).expect("Failed to load master key"), cgcx_config::KeySource::File { path } => MasterKey::load_from_file(path).expect("Failed to load master key"), }; master_key.log_startup(false); let moderation = Arc::new(ModerationEngine::new(&config, std::path::PathBuf::from("data"))); moderation.load().await.expect("Failed to load moderation lists"); let pipeline = Arc::new(FilePipeline::new( (*storage).clone(), (*db).clone(), (*config).clone(), )); let sem = Arc::new(tokio::sync::Semaphore::new( (1024 * 1024 * 1024 / config.storage.chunk_size_bytes.max(1)).max(1) )); let ctx = BotContext { db, storage, config: config.clone(), master_key: Arc::new(master_key), moderation, pipeline, sem, }; let bot = Bot::new(&config.telegram.bot_token); info!("Bot started"); let handler = dptree::entry() .branch(Update::filter_message().endpoint(handle_message)) .branch(Update::filter_callback_query().endpoint(handle_callback)); Dispatcher::builder(bot, handler) .dependencies(dptree::deps![InMemStorage::::new(), ctx]) .enable_ctrlc_handler() .build() .dispatch() .await; } async fn handle_message( bot: Bot, msg: Message, storage: Arc>, ctx: BotContext, ) -> HandlerResult { let user = match &msg.from { Some(u) => u.clone(), None => return Ok(()), }; let chat_id = msg.chat.id; let user_id = user.id.0 as i64; let dialogue = BotDialogue { chat_id, storage }; let user_repo = UserRepo::new(ctx.db.conn()); user_repo.ensure_exists(user_id, user.username.as_deref(), &user.first_name).await?; let db_user = match user_repo.get(user_id).await? { Some(u) => u, None => return Ok(()), }; if matches!(db_user.role, UserRole::Banned) || !ctx.moderation.is_allowed(user_id).await { bot.send_message(chat_id, "[ Banned ] You are not allowed to use this service.").await?; dialogue.exit().await?; return Ok(()); } // Admin commands in groups if msg.chat.is_group() || msg.chat.is_supergroup() { if let Some(text) = msg.text() { let cmd = text.split_whitespace().next().unwrap_or(""); match cmd { "/reload" => { if is_admin(&bot, msg.chat.id, user.id).await { ctx.moderation.load().await?; bot.send_message(chat_id, "Moderation lists reloaded.").await?; } return Ok(()); } "/blacklist_uid" => { if is_admin(&bot, msg.chat.id, user.id).await { handle_admin_blacklist_uid(&bot, chat_id, text, &ctx).await?; } return Ok(()); } "/whitelist_uid" => { if is_admin(&bot, msg.chat.id, user.id).await { handle_admin_whitelist_uid(&bot, chat_id, text, &ctx).await?; } return Ok(()); } _ => {} } } } // DM commands if let Some(text) = msg.text() { let cmd = text.split_whitespace().next().unwrap_or("").split('@').next().unwrap_or(""); match cmd { "/start" => { if db_user.accepted_terms_at.is_some() { return send_main_menu(&bot, chat_id, &dialogue).await; } else { return send_terms(&bot, chat_id, &dialogue).await; } } "/cancel" => { dialogue.update(BotState::MainMenu).await?; return send_main_menu(&bot, chat_id, &dialogue).await; } _ => {} } } let state = dialogue.get_or_default().await?; match state { BotState::Start | BotState::TermsPending => { if msg.text().map(|t| t == "/start").unwrap_or(false) { send_terms(&bot, chat_id, &dialogue).await?; } } BotState::UploadStaging { items, upload_type } => { handle_staging_message(&bot, msg, &dialogue, &ctx, items, upload_type).await?; } BotState::UploadOptions { items, options } => { if let Some(text) = msg.text() { if !text.starts_with('/') && options.password.is_none() { let mut new_options = options.clone(); new_options.password = Some(text.to_string()); let items_cloned = items.clone(); dialogue.update(BotState::UploadOptions { items: items_cloned.clone(), options: new_options.clone() }).await?; refresh_options_message(&bot, chat_id, &items_cloned, &new_options).await?; } } } BotState::Reporting => { if let Some(text) = msg.text() { if !text.starts_with('/') { handle_report(&bot, chat_id, user_id, text, &dialogue, &ctx).await?; } } } _ => {} } Ok(()) } async fn handle_callback( bot: Bot, q: CallbackQuery, storage: Arc>, ctx: BotContext, ) -> HandlerResult { let data = q.data.as_deref().unwrap_or(""); let user = q.from; let user_id = user.id.0 as i64; if !ctx.moderation.is_allowed(user_id).await { bot.answer_callback_query(&q.id).text("Not allowed").await?; return Ok(()); } let chat_id = q.message.as_ref().map(|m| m.chat().id).unwrap_or(ChatId(user_id)); let dialogue = BotDialogue { chat_id, storage }; let parts: Vec<&str> = data.split(':').collect(); if parts.len() < 3 || parts[0] != "v1" { bot.answer_callback_query(&q.id).await?; return Ok(()); } match parts[1] { "terms" => match parts[2] { "accept" => { let user_repo = UserRepo::new(ctx.db.conn()); user_repo.set_accepted_terms(user_id).await?; if let Some(msg) = &q.message { bot.delete_message(chat_id, msg.id()).await.ok(); } send_main_menu(&bot, chat_id, &dialogue).await?; } "reject" => { if let Some(msg) = &q.message { bot.delete_message(chat_id, msg.id()).await.ok(); } dialogue.reset().await?; } _ => {} }, "menu" => match parts[2] { "upload_media" => { dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Media }).await?; send_staging_message(&bot, chat_id, &[], UploadType::Media).await?; } "upload_doc" => { dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Document }).await?; send_staging_message(&bot, chat_id, &[], UploadType::Document).await?; } "upload_text" => { dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Text }).await?; send_staging_message(&bot, chat_id, &[], UploadType::Text).await?; } "prev_uploads" => { dialogue.update(BotState::ViewingPrevious { page: 0 }).await?; show_previous_uploads(&bot, chat_id, user_id, 0, &ctx).await?; } "report" => { dialogue.update(BotState::Reporting).await?; bot.send_message(chat_id, "Send me the content link or content ID to report.").await?; } "main" => { send_main_menu(&bot, chat_id, &dialogue).await?; } _ => {} }, "stage" => match parts[2] { "confirm" => { let state = dialogue.get_or_default().await?; if let BotState::UploadStaging { items, .. } = state { if items.is_empty() { bot.answer_callback_query(&q.id).text("No items to upload.").await?; } else { let options = UploadOptions { allow_download: true, ..Default::default() }; dialogue.update(BotState::UploadOptions { items, options: options.clone() }).await?; refresh_options_message(&bot, chat_id, &vec![], &options).await?; } } } "cancel" => { if let Some(msg) = &q.message { bot.edit_message_text(chat_id, msg.id(), "Upload cancelled.").await.ok(); } dialogue.update(BotState::MainMenu).await?; } _ => {} }, "opt" => match parts[2] { "toggle_destroy" => { let state = dialogue.get_or_default().await?; if let BotState::UploadOptions { items, options } = state { let cycle = [None, Some(1), Some(3), Some(5), Some(10), Some(50)]; let current = options.max_views; let next = cycle.iter().skip_while(|&&x| x != current).nth(1).copied().unwrap_or(None); let new_options = UploadOptions { max_views: next, ..options }; dialogue.update(BotState::UploadOptions { items: items.clone(), options: new_options.clone() }).await?; refresh_options_message(&bot, chat_id, &items, &new_options).await?; } } "toggle_download" => { let state = dialogue.get_or_default().await?; if let BotState::UploadOptions { items, options } = state { let new_options = UploadOptions { allow_download: !options.allow_download, ..options }; dialogue.update(BotState::UploadOptions { items: items.clone(), options: new_options.clone() }).await?; refresh_options_message(&bot, chat_id, &items, &new_options).await?; } } "set_password" => { bot.send_message(chat_id, "Send the password (max 32 chars) or /skip to skip.").await?; } "confirm_final" => { let state = dialogue.get_or_default().await?; if let BotState::UploadOptions { items, options } = state { dialogue.update(BotState::UploadFinalizing).await?; finalize_upload(&bot, chat_id, user_id, items, options, &dialogue, &ctx).await?; } } "back" => { dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Media }).await?; send_staging_message(&bot, chat_id, &[], UploadType::Media).await?; } _ => {} }, "prev" => { if parts[2] == "page" { if let Ok(page) = parts[3].parse::() { dialogue.update(BotState::ViewingPrevious { page }).await?; show_previous_uploads(&bot, chat_id, user_id, page, &ctx).await?; } } } "admin" => { if parts.len() >= 4 { handle_admin_callback(&bot, chat_id, user_id, &parts, &ctx).await?; } } _ => {} } bot.answer_callback_query(&q.id).await?; Ok(()) } async fn send_terms(bot: &Bot, chat_id: ChatId, dialogue: &BotDialogue) -> HandlerResult { let keyboard = InlineKeyboardMarkup::new(vec![vec![ InlineKeyboardButton::callback("[ Accept ]", "v1:terms:accept"), InlineKeyboardButton::callback("[ Decline ]", "v1:terms:reject"), ]]); bot.send_message(chat_id, TERMS_TEXT) .parse_mode(ParseMode::Html) .reply_markup(keyboard) .await?; dialogue.update(BotState::TermsPending).await?; Ok(()) } async fn send_main_menu(bot: &Bot, chat_id: ChatId, dialogue: &BotDialogue) -> HandlerResult { let keyboard = InlineKeyboardMarkup::new(vec![ vec![ InlineKeyboardButton::callback("[ Upload Media ]", "v1:menu:upload_media"), InlineKeyboardButton::callback("[ Upload Docs ]", "v1:menu:upload_doc"), ], vec![ InlineKeyboardButton::callback("[ Upload Text ]", "v1:menu:upload_text"), InlineKeyboardButton::callback("[ Previous Uploads ]", "v1:menu:prev_uploads"), ], vec![ InlineKeyboardButton::callback("[ Report Content ]", "v1:menu:report"), ], ]); bot.send_message(chat_id, "Choose from the menu below. Administrators can be contacted here: @harmfulmeowbot") .reply_markup(keyboard) .await?; dialogue.update(BotState::MainMenu).await?; Ok(()) } async fn send_staging_message(bot: &Bot, chat_id: ChatId, items: &[StagedItem], upload_type: UploadType) -> HandlerResult { let type_label = match upload_type { UploadType::Media => "Media", UploadType::Document => "Documents", UploadType::Text => "Text", }; let text = if items.is_empty() { format!("[ Staging {} (0/10) ]\n\nSend me files to add them.", type_label) } else { let list: String = items.iter().map(|i| format!("- {}\n", i.file_name)).collect(); format!("[ Staging {} ({}/10) ]\n\n{}", type_label, items.len(), list) }; let keyboard = InlineKeyboardMarkup::new(vec![vec![ InlineKeyboardButton::callback("[ Confirm ]", "v1:stage:confirm"), InlineKeyboardButton::callback("[ Cancel ]", "v1:stage:cancel"), ]]); bot.send_message(chat_id, text) .reply_markup(keyboard) .await?; Ok(()) } async fn handle_staging_message( bot: &Bot, msg: Message, dialogue: &BotDialogue, ctx: &BotContext, mut items: Vec, upload_type: UploadType, ) -> HandlerResult { if items.len() >= ctx.config.upload_limits.max_batch_size { bot.send_message(msg.chat.id, "Maximum batch size reached.").await?; return Ok(()); } let mut new_item = None; match upload_type { UploadType::Media => { if let Some(photo) = msg.photo() { let largest = photo.iter().max_by_key(|p| p.file.size); if let Some(p) = largest { new_item = Some(StagedItem { file_id: p.file.id.clone(), file_name: format!("photo_{}.jpg", items.len()), mime_type: "image/jpeg".to_string(), size: p.file.size as u64, caption: msg.caption().map(|s| s.to_string()), }); } } else if let Some(video) = msg.video() { new_item = Some(StagedItem { file_id: video.file.id.clone(), file_name: video.file_name.clone().unwrap_or_else(|| format!("video_{}.mp4", items.len())), mime_type: "video/mp4".to_string(), size: video.file.size as u64, caption: msg.caption().map(|s| s.to_string()), }); } else if let Some(audio) = msg.audio() { new_item = Some(StagedItem { file_id: audio.file.id.clone(), file_name: audio.file_name.clone().unwrap_or_else(|| format!("audio_{}.mp3", items.len())), mime_type: "audio/mpeg".to_string(), size: audio.file.size as u64, caption: msg.caption().map(|s| s.to_string()), }); } } UploadType::Document => { if let Some(doc) = msg.document() { new_item = Some(StagedItem { file_id: doc.file.id.clone(), file_name: doc.file_name.clone().unwrap_or_else(|| format!("file_{}", items.len())), mime_type: doc.mime_type.clone().map(|m| m.to_string()).unwrap_or_else(|| "application/octet-stream".to_string()), size: doc.file.size as u64, caption: msg.caption().map(|s| s.to_string()), }); } } UploadType::Text => { if let Some(text) = msg.text() { if !text.starts_with('/') { new_item = Some(StagedItem { file_id: format!("text://{}", msg.id.0), file_name: "text.txt".to_string(), mime_type: "text/plain".to_string(), size: text.len() as u64, caption: Some(text.to_string()), }); } } } } if let Some(item) = new_item { items.push(item); dialogue.update(BotState::UploadStaging { items: items.clone(), upload_type }).await?; send_staging_message(bot, msg.chat.id, &items, upload_type).await?; } Ok(()) } async fn refresh_options_message( bot: &Bot, chat_id: ChatId, _items: &[StagedItem], options: &UploadOptions, ) -> HandlerResult { let destroy_text = match options.max_views { Some(n) => format!("Auto-destroy: {} views", n), None => "Auto-destroy: Off".to_string(), }; let download_text = if options.allow_download { "Allow download: Yes" } else { "Allow download: No" }; let password_text = if options.password.is_some() { "Password: Set" } else { "Password: None" }; let text = format!( "[ Upload Options ]\n\n{}\n{}\n{}\n\nConfirm when ready.", destroy_text, download_text, password_text ); let keyboard = InlineKeyboardMarkup::new(vec![ vec![ InlineKeyboardButton::callback("[ Toggle Destroy ]", "v1:opt:toggle_destroy"), InlineKeyboardButton::callback("[ Toggle Download ]", "v1:opt:toggle_download"), ], vec![ InlineKeyboardButton::callback("[ Set Password ]", "v1:opt:set_password"), ], vec![ InlineKeyboardButton::callback("[ Back ]", "v1:opt:back"), InlineKeyboardButton::callback("[ Confirm & Upload ]", "v1:opt:confirm_final"), ], ]); bot.send_message(chat_id, text) .reply_markup(keyboard) .await?; Ok(()) } async fn finalize_upload( bot: &Bot, chat_id: ChatId, user_id: i64, items: Vec, options: UploadOptions, dialogue: &BotDialogue, ctx: &BotContext, ) -> HandlerResult { let status_msg = bot.send_message(chat_id, "[ Encrypting and storing... ]").await?; let total_size: u64 = items.iter().map(|i| i.size).sum(); if total_size > ctx.config.upload_limits.max_total_batch_bytes { bot.edit_message_text(chat_id, status_msg.id, "[ Error: total batch size exceeds limit. ]").await?; dialogue.update(BotState::MainMenu).await?; return Ok(()); } // Check available disk space in temp dir if let Ok(temp_path) = std::fs::canonicalize(&ctx.config.storage.paths.temp) { if let Ok(info) = fs2::available_space(&temp_path) { if info < total_size * 2 { bot.edit_message_text(chat_id, status_msg.id, "[ Error: insufficient storage space. ]").await?; dialogue.update(BotState::MainMenu).await?; return Ok(()); } } } let content_id = ContentId::generate(); let repo = ContentRepo::new(ctx.db.conn()); let mut attempts = 0; while repo.get(&content_id).await?.is_some() && attempts < 5 { attempts += 1; } let password_hash = options.password.as_ref().map(|p| { use argon2::{Argon2, PasswordHasher, password_hash::SaltString}; use rand::rngs::OsRng; let salt = SaltString::generate(&mut OsRng); let argon2 = Argon2::default(); argon2.hash_password(p.as_bytes(), &salt) .map(|h| h.to_string()) .unwrap_or_default() }); ctx.pipeline.create_content_entry( content_id.clone(), user_id, options.max_views, options.allow_download, password_hash, ).await?; for (idx, item) in items.iter().enumerate() { let result = if item.file_id.starts_with("text://") { let data = item.caption.clone().unwrap_or_default().into_bytes(); let mut cursor = std::io::Cursor::new(data); ctx.pipeline.ingest_file( &content_id, idx as u32, &mut cursor, &item.file_name, &ctx.master_key, &ctx.sem, ).await } else { match bot.get_file(&item.file_id).await { Ok(file) => { let mut data = Vec::new(); if let Err(e) = bot.download_file(&file.path, &mut data).await { warn!("Download error: {}", e); continue; } let mut cursor = std::io::Cursor::new(data); ctx.pipeline.ingest_file( &content_id, idx as u32, &mut cursor, &item.file_name, &ctx.master_key, &ctx.sem, ).await } Err(e) => { warn!("Get file error: {}", e); continue; } } }; if let Err(e) = result { warn!("Ingest error: {}", e); } } ctx.pipeline.activate_content(&content_id).await?; let base_url = &ctx.config.server.base_url; let link = format!("{}/?cxid={}", base_url, content_id.as_str()); let mut attrs = vec![]; if let Some(v) = options.max_views { attrs.push(format!("auto-burn after {}", v)); } if options.allow_download { attrs.push("download allowed".to_string()); } if options.password.is_some() { attrs.push("password set".to_string()); } let attr_text = if attrs.is_empty() { "no special options".to_string() } else { attrs.join(", ") }; let result_text = format!( "[ Upload Complete ]\n\nLink: {}\n\nFiles: {} | {}", link, items.len(), attr_text ); bot.edit_message_text(chat_id, status_msg.id, result_text) .parse_mode(ParseMode::Html) .await?; dialogue.update(BotState::MainMenu).await?; Ok(()) } async fn show_previous_uploads( bot: &Bot, chat_id: ChatId, user_id: i64, page: usize, ctx: &BotContext, ) -> HandlerResult { let repo = ContentRepo::new(ctx.db.conn()); let total = repo.count_by_user(user_id).await?; let items = repo.list_by_user(user_id, 10, page * 10).await?; let total_pages = (total + 9) / 10; if items.is_empty() { bot.send_message(chat_id, "You have no uploads.").await?; return Ok(()); } let base_url = &ctx.config.server.base_url; let mut text = format!("[ Your Uploads ] Page {}/{}\n\n", page + 1, total_pages.max(1)); for content in &items { let file_repo = ContentFileRepo::new(ctx.db.conn()); let files = file_repo.list_by_content(&content.id).await?; let mut attrs = vec![]; if let Some(v) = content.max_views { attrs.push(format!("auto-burn after {}", v)); } if content.allow_download { attrs.push("download allowed".to_string()); } if content.password_hash.is_some() { attrs.push("password set".to_string()); } let attr_text = if attrs.is_empty() { "no options".to_string() } else { attrs.join(", ") }; text.push_str(&format!( "- {} ({} files) [{}]\n {}?cxid={}\n\n", content.id.as_str(), files.len(), attr_text, base_url, content.id.as_str() )); } let mut buttons = vec![]; if page > 0 { buttons.push(InlineKeyboardButton::callback("<<", format!("v1:prev:page:{}", page - 1))); } buttons.push(InlineKeyboardButton::callback(format!("Page {}/{}", page + 1, total_pages.max(1)), "noop")); if page + 1 < total_pages { buttons.push(InlineKeyboardButton::callback(">>", format!("v1:prev:page:{}", page + 1))); } let keyboard = InlineKeyboardMarkup::new(vec![buttons, vec![ InlineKeyboardButton::callback("[ Main Menu ]", "v1:menu:main"), ]]); bot.send_message(chat_id, text) .parse_mode(ParseMode::Html) .reply_markup(keyboard) .await?; Ok(()) } async fn handle_report( bot: &Bot, chat_id: ChatId, reporter_id: i64, text: &str, dialogue: &BotDialogue, ctx: &BotContext, ) -> HandlerResult { let cxid = extract_cxid(text).ok_or("Invalid content ID or link")?; let content_id = ContentId::try_from(cxid.as_str())?; let repo = ContentRepo::new(ctx.db.conn()); let content = repo.get(&content_id).await?.ok_or("Content not found")?; let report_repo = ReportRepo::new(ctx.db.conn()); let report_id = report_repo.insert(&content_id, reporter_id, text).await?; for &group_id in &ctx.config.groups.review_group_ids { let report_text = format!( "[ NEW REPORT ] #{}\n\nCXID: {}\nReporter: {}\nOwner: {}\nUploaded: {}\nFiles: {}", report_id, cxid, reporter_id, content.user_id, content.created_at.format("%Y-%m-%d %H:%M"), 1 ); let keyboard = InlineKeyboardMarkup::new(vec![ vec![ InlineKeyboardButton::callback("[ Delete + Blacklist ]", format!("v1:admin:delblk:{}", report_id)), InlineKeyboardButton::callback("[ Delete Only ]", format!("v1:admin:del:{}", report_id)), ], vec![ InlineKeyboardButton::callback("[ Blacklist Only ]", format!("v1:admin:blk:{}", report_id)), InlineKeyboardButton::callback("[ Ignore ]", format!("v1:admin:ign:{}", report_id)), ], ]); bot.send_message(ChatId(group_id), report_text) .parse_mode(ParseMode::Html) .reply_markup(keyboard) .await .ok(); } bot.send_message(chat_id, "Report submitted. Moderators will review it shortly.").await?; dialogue.update(BotState::MainMenu).await?; Ok(()) } async fn handle_admin_callback( bot: &Bot, chat_id: ChatId, user_id: i64, parts: &[&str], ctx: &BotContext, ) -> HandlerResult { if !is_admin_in_chat(bot, chat_id, UserId(user_id as u64)).await { bot.send_message(chat_id, "Unauthorized.").await?; return Ok(()); } let report_id = parts[3].parse::().unwrap_or(0); let report_repo = ReportRepo::new(ctx.db.conn()); let report = match report_repo.get(report_id).await? { Some(r) => r, None => { bot.send_message(chat_id, "Report not found.").await?; return Ok(()); } }; let content_repo = ContentRepo::new(ctx.db.conn()); let content = match content_repo.get(&report.content_id).await? { Some(c) => c, None => { bot.send_message(chat_id, "Content not found.").await?; return Ok(()); } }; match parts[2] { "delblk" => { ctx.pipeline.delete_content(&report.content_id, !ctx.config.content.keep_content).await.ok(); content_repo.set_status(&report.content_id, ContentStatus::Deleted).await.ok(); ctx.moderation.blacklist(content.user_id).await.ok(); let user_repo = UserRepo::new(ctx.db.conn()); user_repo.set_role(content.user_id, "banned").await.ok(); report_repo.resolve(report_id, ReportStatus::Actioned, user_id).await.ok(); bot.send_message(chat_id, format!("Deleted content {} and blacklisted user {}", report.content_id.as_str(), content.user_id)) .parse_mode(ParseMode::Html).await?; } "del" => { ctx.pipeline.delete_content(&report.content_id, !ctx.config.content.keep_content).await.ok(); content_repo.set_status(&report.content_id, ContentStatus::Deleted).await.ok(); report_repo.resolve(report_id, ReportStatus::Actioned, user_id).await.ok(); bot.send_message(chat_id, format!("Deleted content {}", report.content_id.as_str())) .parse_mode(ParseMode::Html).await?; } "blk" => { ctx.moderation.blacklist(content.user_id).await.ok(); let user_repo = UserRepo::new(ctx.db.conn()); user_repo.set_role(content.user_id, "banned").await.ok(); report_repo.resolve(report_id, ReportStatus::Actioned, user_id).await.ok(); bot.send_message(chat_id, format!("Blacklisted user {}", content.user_id)) .parse_mode(ParseMode::Html).await?; } "ign" => { report_repo.resolve(report_id, ReportStatus::Dismissed, user_id).await.ok(); bot.send_message(chat_id, format!("Ignored report #{}", report_id)) .parse_mode(ParseMode::Html).await?; } _ => {} } Ok(()) } fn extract_cxid(input: &str) -> Option { let re = regex::Regex::new(r"[?&]cxid=([a-zA-Z0-9]{12})").ok()?; if let Some(cap) = re.captures(input) { return cap.get(1).map(|m| m.as_str().to_string()); } let trimmed = input.trim(); if trimmed.len() == 12 && trimmed.chars().all(|c| c.is_ascii_alphanumeric()) { return Some(trimmed.to_string()); } None } async fn is_admin(bot: &Bot, chat_id: ChatId, user_id: UserId) -> bool { match bot.get_chat_member(chat_id, user_id).await { Ok(member) => { matches!(member.status(), ChatMemberStatus::Administrator | ChatMemberStatus::Owner) } Err(_) => false, } } async fn is_admin_in_chat(bot: &Bot, chat_id: ChatId, user_id: UserId) -> bool { is_admin(bot, chat_id, user_id).await } async fn handle_admin_blacklist_uid( bot: &Bot, chat_id: ChatId, text: &str, ctx: &BotContext, ) -> HandlerResult { let uid = text.split_whitespace().nth(1).and_then(|s| s.parse::().ok()); if let Some(uid) = uid { ctx.moderation.blacklist(uid).await?; let user_repo = UserRepo::new(ctx.db.conn()); user_repo.set_role(uid, "banned").await?; bot.send_message(chat_id, format!("Blacklisted UID {}", uid)) .parse_mode(ParseMode::Html).await?; } Ok(()) } async fn handle_admin_whitelist_uid( bot: &Bot, chat_id: ChatId, text: &str, ctx: &BotContext, ) -> HandlerResult { let uid = text.split_whitespace().nth(1).and_then(|s| s.parse::().ok()); if let Some(uid) = uid { ctx.moderation.remove_blacklist(uid).await?; let user_repo = UserRepo::new(ctx.db.conn()); user_repo.set_role(uid, "user").await?; bot.send_message(chat_id, format!("Whitelisted UID {}", uid)) .parse_mode(ParseMode::Html).await?; } Ok(()) }