Initial commit
This commit is contained in:
1
crates/cgcx-bot/src/lib.rs
Normal file
1
crates/cgcx-bot/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub fn placeholder() {}
|
||||
988
crates/cgcx-bot/src/main.rs
Normal file
988
crates/cgcx-bot/src/main.rs
Normal file
@@ -0,0 +1,988 @@
|
||||
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<StagedItem>, upload_type: UploadType },
|
||||
UploadOptions { items: Vec<StagedItem>, 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
pub struct UploadOptions {
|
||||
pub max_views: Option<u64>,
|
||||
pub allow_download: bool,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
type HandlerResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BotDialogue {
|
||||
chat_id: ChatId,
|
||||
storage: Arc<InMemStorage<BotState>>,
|
||||
}
|
||||
|
||||
impl BotDialogue {
|
||||
async fn get(&self) -> Result<BotState, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(self.storage.clone().get_dialogue(self.chat_id).await?.unwrap_or_default())
|
||||
}
|
||||
async fn update(&self, state: BotState) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(self.storage.clone().update_dialogue(self.chat_id, state).await?)
|
||||
}
|
||||
async fn reset(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(self.storage.clone().remove_dialogue(self.chat_id).await?)
|
||||
}
|
||||
async fn exit(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.reset().await
|
||||
}
|
||||
async fn get_or_default(&self) -> Result<BotState, Box<dyn std::error::Error + Send + Sync>> {
|
||||
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#"<b>Welcome to CG.CX</b>
|
||||
|
||||
Before using this service, you must read and accept the following terms:
|
||||
|
||||
1. <b>No Responsibility:</b> This service is not responsible for whatever media or files are shared by users.
|
||||
|
||||
2. <b>Content Warning:</b> Content may be uncomfortable, including bloody scenes or other disturbing material.
|
||||
|
||||
3. <b>Prohibited Content:</b> 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. <b>Enforcement:</b> Violating these rules may result in immediate deletion, reporting to authorities, blacklisting, or moderator escalation.
|
||||
|
||||
<b>By clicking "Accept", you confirm you are at least 18 years old and agree to these terms.</b>"#;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BotContext {
|
||||
db: Arc<Database>,
|
||||
#[allow(dead_code)]
|
||||
storage: Arc<CgcxStorage>,
|
||||
config: Arc<Config>,
|
||||
master_key: Arc<MasterKey>,
|
||||
moderation: Arc<ModerationEngine>,
|
||||
pipeline: Arc<FilePipeline>,
|
||||
sem: Arc<tokio::sync::Semaphore>,
|
||||
}
|
||||
|
||||
#[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::<BotState>::new(), ctx])
|
||||
.enable_ctrlc_handler()
|
||||
.build()
|
||||
.dispatch()
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn handle_message(
|
||||
bot: Bot,
|
||||
msg: Message,
|
||||
storage: Arc<InMemStorage<BotState>>,
|
||||
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<InMemStorage<BotState>>,
|
||||
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::<usize>() {
|
||||
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<StagedItem>,
|
||||
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<StagedItem>,
|
||||
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: <code>{}</code>\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!(
|
||||
"- <code>{}</code> ({} 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: <code>{}</code>\nReporter: <code>{}</code>\nOwner: <code>{}</code>\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::<i64>().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<String> {
|
||||
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::<i64>().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 <code>{}</code>", 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::<i64>().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 <code>{}</code>", uid))
|
||||
.parse_mode(ParseMode::Html).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user