Major improvement, security handling, file handling +fixes
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use teloxide::{
|
||||
dispatching::{dialogue::{InMemStorage, Storage}, UpdateFilterExt},
|
||||
@@ -5,7 +6,7 @@ use teloxide::{
|
||||
prelude::*,
|
||||
types::{
|
||||
InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageId, ParseMode, CallbackQuery,
|
||||
ChatMemberStatus, UserId, ChatPermissions,
|
||||
ChatMemberStatus, UserId, ChatPermissions, InputMedia, InputMediaPhoto, InputMediaDocument, InputFile,
|
||||
},
|
||||
RequestError,
|
||||
utils::command::BotCommands,
|
||||
@@ -55,12 +56,30 @@ pub struct StagedItem {
|
||||
pub caption: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct UploadOptions {
|
||||
pub max_views: Option<u64>,
|
||||
pub allow_download: bool,
|
||||
pub password: Option<String>,
|
||||
pub pending_forward_id: Option<i64>,
|
||||
#[serde(default = "default_show_author")]
|
||||
pub show_author: bool,
|
||||
}
|
||||
|
||||
fn default_show_author() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for UploadOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_views: None,
|
||||
allow_download: true,
|
||||
password: None,
|
||||
pending_forward_id: None,
|
||||
show_author: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type HandlerResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
@@ -271,6 +290,8 @@ async fn run_bot() {
|
||||
let repo = PunishmentRepo::new(db_clone.conn());
|
||||
match repo.list_expired().await {
|
||||
Ok(expired) => {
|
||||
// global_ban propagated punishments are naturally revoked here
|
||||
// because each chat has its own punishment row.
|
||||
for p in expired {
|
||||
let chat_id = ChatId(p.chat_id);
|
||||
let target = UserId(p.target_user_id as u64);
|
||||
@@ -365,7 +386,7 @@ async fn handle_message_inner(
|
||||
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?;
|
||||
user_repo.ensure_exists(user_id, user.username.as_deref(), &user.first_name, chat_id.0, Some(&ctx.config.uname_changes_path)).await?;
|
||||
|
||||
let db_user = match user_repo.get(user_id).await? {
|
||||
Some(u) => u,
|
||||
@@ -421,7 +442,7 @@ async fn handle_message_inner(
|
||||
/get_id — Get current chat ID.
|
||||
/get_id <@username> — Search administrators by username.
|
||||
/get_id <displayname> — Search members in this chat by display name.
|
||||
/create_submit_forward <dest> <review> [msg] — Create a submission forward.
|
||||
/create_submit_forward <dest> <review> [msg] — Create a submission forward.
|
||||
/show_c_forward [page] — List forward links.
|
||||
/add_blacklist <user_id> — Blacklist a user in all active forwards.
|
||||
/rm_blacklist <user_id> — Remove a user from blacklist in all active forwards.
|
||||
@@ -576,6 +597,7 @@ async fn handle_message_inner(
|
||||
bot.ban_chat_member(chat_id, UserId(target_id)).until_date(until).await?;
|
||||
let repo = PunishmentRepo::new(ctx.db.conn());
|
||||
repo.insert(chat_id.0, target_id as i64, "ban", Some(duration_seconds), reason.as_deref(), user_id).await?;
|
||||
propagate_punishment(&bot, &ctx, chat_id, target_id, "ban", Some(duration_seconds), reason.as_deref(), user_id).await;
|
||||
bot.send_message(chat_id, format!("Banned <code>{}</code> for {} seconds.", target_id, duration_seconds)).parse_mode(ParseMode::Html).await?;
|
||||
}
|
||||
Ok(None) => { /* shouldn't happen with 2 args */ }
|
||||
@@ -604,6 +626,7 @@ async fn handle_message_inner(
|
||||
bot.restrict_chat_member(chat_id, UserId(target_id), ChatPermissions::empty()).until_date(until).await?;
|
||||
let repo = PunishmentRepo::new(ctx.db.conn());
|
||||
repo.insert(chat_id.0, target_id as i64, "mute", Some(duration_seconds), reason.as_deref(), user_id).await?;
|
||||
propagate_punishment(&bot, &ctx, chat_id, target_id, "mute", Some(duration_seconds), reason.as_deref(), user_id).await;
|
||||
bot.send_message(chat_id, format!("Muted <code>{}</code> for {} seconds.", target_id, duration_seconds)).parse_mode(ParseMode::Html).await?;
|
||||
}
|
||||
Ok(None) => {}
|
||||
@@ -628,6 +651,7 @@ async fn handle_message_inner(
|
||||
bot.restrict_chat_member(chat_id, UserId(target_id), ChatPermissions::empty()).await?;
|
||||
let repo = PunishmentRepo::new(ctx.db.conn());
|
||||
repo.insert(chat_id.0, target_id as i64, "mute", None, reason.as_deref(), user_id).await?;
|
||||
propagate_punishment(&bot, &ctx, chat_id, target_id, "mute", None, reason.as_deref(), user_id).await;
|
||||
bot.send_message(chat_id, format!("Muted <code>{}</code> indefinitely.", target_id)).parse_mode(ParseMode::Html).await?;
|
||||
} else {
|
||||
bot.send_message(chat_id, "Could not resolve target user.").await?;
|
||||
@@ -648,6 +672,7 @@ async fn handle_message_inner(
|
||||
bot.ban_chat_member(chat_id, UserId(target_id)).await?;
|
||||
let repo = PunishmentRepo::new(ctx.db.conn());
|
||||
repo.insert(chat_id.0, target_id as i64, "ban", None, reason.as_deref(), user_id).await?;
|
||||
propagate_punishment(&bot, &ctx, chat_id, target_id, "ban", None, reason.as_deref(), user_id).await;
|
||||
bot.send_message(chat_id, format!("Banned <code>{}</code> permanently.", target_id)).parse_mode(ParseMode::Html).await?;
|
||||
} else {
|
||||
bot.send_message(chat_id, "Could not resolve target user.").await?;
|
||||
@@ -669,6 +694,7 @@ async fn handle_message_inner(
|
||||
bot.unban_chat_member(chat_id, UserId(target_id)).await?;
|
||||
let repo = PunishmentRepo::new(ctx.db.conn());
|
||||
repo.insert(chat_id.0, target_id as i64, "kick", None, reason.as_deref(), user_id).await?;
|
||||
propagate_punishment(&bot, &ctx, chat_id, target_id, "kick", None, reason.as_deref(), user_id).await;
|
||||
bot.send_message(chat_id, format!("Kicked <code>{}</code>.", target_id)).parse_mode(ParseMode::Html).await?;
|
||||
} else {
|
||||
bot.send_message(chat_id, "Could not resolve target user.").await?;
|
||||
@@ -763,6 +789,11 @@ async fn handle_message_inner(
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if !p.is_empty() && p.starts_with("report_") {
|
||||
let cxid = &p["report_".len()..];
|
||||
handle_report(&bot, chat_id, user_id, cxid, &dialogue, &ctx).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if db_user.accepted_terms_at.is_some() {
|
||||
return send_main_menu(&bot, chat_id, &dialogue, None).await;
|
||||
@@ -974,6 +1005,14 @@ async fn handle_callback_inner(
|
||||
refresh_options_message(&bot, chat_id, &items, &new_options).await?;
|
||||
}
|
||||
}
|
||||
"toggle_author" => {
|
||||
let state = dialogue.get_or_default().await?;
|
||||
if let BotState::UploadOptions { items, options } = state {
|
||||
let new_options = UploadOptions { show_author: !options.show_author, ..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 <code>password</code> (max <b>32</b> chars) or <code>/skip</code> to skip.")
|
||||
.parse_mode(ParseMode::Html)
|
||||
@@ -1154,7 +1193,7 @@ async fn send_staging_message(bot: &Bot, chat_id: ChatId, items: &[StagedItem],
|
||||
let text = if items.is_empty() {
|
||||
format!("<b>[ Staging {} ]</b>\n\n<i>Send me files to add them.</i>\n\n<code>0/{}</code> items", type_label, max_batch_size)
|
||||
} else {
|
||||
let list: String = items.iter().map(|i| format!("• <code>{}</code>\n", i.file_name)).collect();
|
||||
let list: String = items.iter().map(|i| format!("• <code>{}</code>\n", escape_html(&i.file_name))).collect();
|
||||
format!("<b>[ Staging {} ]</b> <code>{}/{}</code>\n\n{}", type_label, items.len(), max_batch_size, list)
|
||||
};
|
||||
|
||||
@@ -1304,10 +1343,15 @@ async fn refresh_options_message(
|
||||
} else {
|
||||
"Password: <i>None</i>"
|
||||
};
|
||||
let author_text = if options.show_author {
|
||||
"Show author: <b>Yes</b>"
|
||||
} else {
|
||||
"Show author: <b>No</b>"
|
||||
};
|
||||
|
||||
let text = format!(
|
||||
"<b>[ Upload Options ]</b>\n\n{}\n{}\n{}\n\n<i>Confirm when ready.</i>",
|
||||
destroy_text, download_text, password_text
|
||||
"<b>[ Upload Options ]</b>\n\n{}\n{}\n{}\n{}\n\n<i>Confirm when ready.</i>",
|
||||
destroy_text, download_text, password_text, author_text
|
||||
);
|
||||
|
||||
let keyboard = InlineKeyboardMarkup::new(vec![
|
||||
@@ -1317,6 +1361,7 @@ async fn refresh_options_message(
|
||||
],
|
||||
vec![
|
||||
InlineKeyboardButton::callback("[ Set Password ]", "v1:opt:set_password"),
|
||||
InlineKeyboardButton::callback("[ Toggle Author ]", "v1:opt:toggle_author"),
|
||||
],
|
||||
vec![
|
||||
InlineKeyboardButton::callback("[ Back ]", "v1:opt:back"),
|
||||
@@ -1391,8 +1436,10 @@ async fn finalize_upload(
|
||||
options.max_views,
|
||||
options.allow_download,
|
||||
password_hash,
|
||||
options.show_author,
|
||||
).await?;
|
||||
|
||||
let mut blocked = false;
|
||||
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();
|
||||
@@ -1431,10 +1478,28 @@ async fn finalize_upload(
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
if matches!(e, cgcx_core::CgcxError::BlockedHash) {
|
||||
blocked = true;
|
||||
break;
|
||||
}
|
||||
warn!("Ingest error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
if blocked {
|
||||
bot.delete_message(chat_id, status_msg.id).await.ok();
|
||||
for item in &items {
|
||||
if let Some(rest) = item.file_id.strip_prefix("text://") {
|
||||
if let Ok(msg_id) = rest.parse::<i32>() {
|
||||
bot.delete_message(chat_id, MessageId(msg_id)).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.pipeline.delete_content(&content_id, false).await.ok();
|
||||
dialogue.update(BotState::MainMenu { pending_forward_id: None }).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ctx.pipeline.activate_content(&content_id).await?;
|
||||
|
||||
if let Some(fid) = options.pending_forward_id {
|
||||
@@ -1448,16 +1513,73 @@ async fn finalize_upload(
|
||||
user_id,
|
||||
forward_def.id
|
||||
);
|
||||
let keyboard = InlineKeyboardMarkup::new(vec![vec![
|
||||
InlineKeyboardButton::callback("[ Approve ]", format!("v1:fwd:approve:{}", submission_id)),
|
||||
InlineKeyboardButton::callback("[ Ignore ]", format!("v1:fwd:ignore:{}", submission_id)),
|
||||
InlineKeyboardButton::callback("[ Blacklist User ]", format!("v1:fwd:blk:{}", submission_id)),
|
||||
]]);
|
||||
let sent = bot.send_message(ChatId(forward_def.review_group_id), review_text)
|
||||
let keyboard = InlineKeyboardMarkup::new(vec![
|
||||
vec![
|
||||
InlineKeyboardButton::callback("[ Approve ]", format!("v1:fwd:approve:{}", submission_id)),
|
||||
InlineKeyboardButton::callback("[ Ignore ]", format!("v1:fwd:ignore:{}", submission_id)),
|
||||
],
|
||||
vec![
|
||||
InlineKeyboardButton::callback("[ Blackl. ]", format!("v1:fwd:blk:{}", submission_id)),
|
||||
InlineKeyboardButton::callback("[ Ban ]", format!("v1:fwd:ban:{}", submission_id)),
|
||||
InlineKeyboardButton::callback("[ Ban/BL u. ]", format!("v1:fwd:banblk:{}", submission_id)),
|
||||
],
|
||||
]);
|
||||
let sent = bot.send_message(ChatId(forward_def.review_group_id), review_text.clone())
|
||||
.parse_mode(ParseMode::Html)
|
||||
.reply_markup(keyboard)
|
||||
.await?;
|
||||
forward_repo.set_review_message_id(submission_id, sent.id.0).await?;
|
||||
|
||||
// G — Send decrypted media batches to review group
|
||||
let file_repo = ContentFileRepo::new(ctx.db.conn());
|
||||
let files = file_repo.list_by_content(&content_id).await?;
|
||||
if !files.is_empty() {
|
||||
let mut decrypted = Vec::new();
|
||||
for file in &files {
|
||||
match tokio::fs::read(&file.stored_path).await {
|
||||
Ok(ciphertext) => {
|
||||
match cgcx_crypto::decrypt_bytes(&ciphertext, &file.encrypted_key_wrapped, &ctx.master_key) {
|
||||
Ok(bytes) => decrypted.push((file.mime_type.clone(), bytes)),
|
||||
Err(e) => tracing::warn!("decrypt error for {}: {}", file.file_index, e),
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("read error for {:?}: {}", file.stored_path, e),
|
||||
}
|
||||
}
|
||||
if !decrypted.is_empty() {
|
||||
let chunks: Vec<_> = decrypted.chunks(10).collect();
|
||||
let total = chunks.len();
|
||||
for (i, chunk) in chunks.iter().enumerate() {
|
||||
let is_last = i == total - 1;
|
||||
let mut batch: Vec<InputMedia> = Vec::new();
|
||||
for (mime_type, bytes) in chunk.iter() {
|
||||
let input_file = InputFile::memory(bytes.clone());
|
||||
let media = if mime_type.starts_with("image/") {
|
||||
InputMedia::Photo(InputMediaPhoto::new(input_file))
|
||||
} else {
|
||||
InputMedia::Document(InputMediaDocument::new(input_file))
|
||||
};
|
||||
batch.push(media);
|
||||
}
|
||||
if is_last {
|
||||
if let Some(last) = batch.last_mut() {
|
||||
match last {
|
||||
InputMedia::Photo(p) => {
|
||||
p.caption = Some(review_text.clone());
|
||||
p.parse_mode = Some(ParseMode::Html);
|
||||
}
|
||||
InputMedia::Document(d) => {
|
||||
d.caption = Some(review_text.clone());
|
||||
d.parse_mode = Some(ParseMode::Html);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
bot.send_media_group(ChatId(forward_def.review_group_id), batch).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1486,7 +1608,7 @@ async fn finalize_upload(
|
||||
);
|
||||
if let Some(ref pw) = options.password {
|
||||
let direct_link = format!("{}/?cxid={}&sc={}", base_url, content_id.as_str(), pw);
|
||||
result_text.push_str(&format!("\n\n<i>Direct Access Link:</i> <code>{}</code>", direct_link));
|
||||
result_text.push_str(&format!("\n\n<i>Direct Access Link:</i> <code>{}</code>", escape_html(&direct_link)));
|
||||
}
|
||||
|
||||
bot.edit_message_text(chat_id, status_msg.id, result_text)
|
||||
@@ -1786,22 +1908,124 @@ async fn handle_forward_callback(
|
||||
// 3. Update content password_hash
|
||||
let content_repo = ContentRepo::new(ctx.db.conn());
|
||||
content_repo.update_password_hash(&submission.content_id, Some(&password_hash)).await?;
|
||||
// 4. Forward content to destination
|
||||
// 4. Build links
|
||||
let link = format!("{}/?cxid={}&sc={}", ctx.config.server.base_url, submission.content_id.as_str(), password);
|
||||
let posted_msg = bot.send_message(
|
||||
ChatId(forward_def.destination_chat_id),
|
||||
format!("{}\n\nDirect link: <code>{}</code>", forward_def.forward_message, link)
|
||||
).parse_mode(ParseMode::Html).await?;
|
||||
let forward_link = format!("https://t.me/{}?start=submitfwdid{}", ctx.bot_username, forward_def.code);
|
||||
|
||||
// 5. DM user
|
||||
bot.send_message(
|
||||
ChatId(submission.user_id),
|
||||
format!("<b>Your submission was approved.</b>\n\nPosted: {}\nDirect access: <code>{}</code>",
|
||||
format!("https://t.me/c/{}/{}", forward_def.destination_chat_id, posted_msg.id),
|
||||
link)
|
||||
).parse_mode(ParseMode::Html).await.ok();
|
||||
// 5. Resolve author visibility
|
||||
let content = match content_repo.get(&submission.content_id).await? {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
bot.send_message(chat_id, "Content not found.").await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let user_repo = UserRepo::new(ctx.db.conn());
|
||||
let submitter = user_repo.get(submission.user_id).await?;
|
||||
let author_line = if content.show_author {
|
||||
if let Some(ref user) = submitter {
|
||||
if let Some(ref username) = user.telegram_username {
|
||||
format!("@{} [{}]", escape_html(username), submission.user_id)
|
||||
} else {
|
||||
format!("<code>{}</code>", submission.user_id)
|
||||
}
|
||||
} else {
|
||||
format!("<code>{}</code>", submission.user_id)
|
||||
}
|
||||
} else {
|
||||
"<i>anonymous</i>".to_string()
|
||||
};
|
||||
|
||||
// 6. Update review message
|
||||
let caption = format!(
|
||||
"{}\n\nSubmitted by: {}\nDirect link: <code>{}</code>\nForward link: <code>{}</code>",
|
||||
escape_html(&forward_def.forward_message),
|
||||
author_line,
|
||||
link,
|
||||
forward_link
|
||||
);
|
||||
|
||||
// 6. Forward content to destination (media batching or text-only)
|
||||
let file_repo = ContentFileRepo::new(ctx.db.conn());
|
||||
let files = file_repo.list_by_content(&submission.content_id).await?;
|
||||
let posted_link = if files.is_empty() {
|
||||
let posted_msg = bot.send_message(
|
||||
ChatId(forward_def.destination_chat_id),
|
||||
caption
|
||||
).parse_mode(ParseMode::Html).await?;
|
||||
format!("https://t.me/c/{}/{}", forward_def.destination_chat_id, posted_msg.id)
|
||||
} else {
|
||||
let mut decrypted = Vec::new();
|
||||
for file in &files {
|
||||
match tokio::fs::read(&file.stored_path).await {
|
||||
Ok(ciphertext) => {
|
||||
match cgcx_crypto::decrypt_bytes(&ciphertext, &file.encrypted_key_wrapped, &ctx.master_key) {
|
||||
Ok(bytes) => decrypted.push((file.mime_type.clone(), bytes)),
|
||||
Err(e) => tracing::warn!("decrypt error for {}: {}", file.file_index, e),
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("read error for {:?}: {}", file.stored_path, e),
|
||||
}
|
||||
}
|
||||
if decrypted.is_empty() {
|
||||
let posted_msg = bot.send_message(
|
||||
ChatId(forward_def.destination_chat_id),
|
||||
caption
|
||||
).parse_mode(ParseMode::Html).await?;
|
||||
format!("https://t.me/c/{}/{}", forward_def.destination_chat_id, posted_msg.id)
|
||||
} else {
|
||||
let chunks: Vec<_> = decrypted.chunks(10).collect();
|
||||
let total = chunks.len();
|
||||
let mut first_msg_id = None;
|
||||
for (i, chunk) in chunks.iter().enumerate() {
|
||||
let is_last = i == total - 1;
|
||||
let mut batch: Vec<InputMedia> = Vec::new();
|
||||
for (mime_type, bytes) in chunk.iter() {
|
||||
let input_file = InputFile::memory(bytes.clone());
|
||||
let media = if mime_type.starts_with("image/") {
|
||||
InputMedia::Photo(InputMediaPhoto::new(input_file))
|
||||
} else {
|
||||
InputMedia::Document(InputMediaDocument::new(input_file))
|
||||
};
|
||||
batch.push(media);
|
||||
}
|
||||
if is_last {
|
||||
if let Some(last) = batch.last_mut() {
|
||||
match last {
|
||||
InputMedia::Photo(p) => {
|
||||
p.caption = Some(caption.clone());
|
||||
p.parse_mode = Some(ParseMode::Html);
|
||||
}
|
||||
InputMedia::Document(d) => {
|
||||
d.caption = Some(caption.clone());
|
||||
d.parse_mode = Some(ParseMode::Html);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let sent = bot.send_media_group(ChatId(forward_def.destination_chat_id), batch).await?;
|
||||
if first_msg_id.is_none() {
|
||||
first_msg_id = sent.first().map(|m| m.id);
|
||||
}
|
||||
}
|
||||
if let Some(mid) = first_msg_id {
|
||||
format!("https://t.me/c/{}/{}", forward_def.destination_chat_id, mid)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 7. DM user
|
||||
if !posted_link.is_empty() {
|
||||
bot.send_message(
|
||||
ChatId(submission.user_id),
|
||||
format!("<b>Your submission was approved.</b>\n\nPosted: {}\nDirect access: <code>{}</code>",
|
||||
posted_link, link)
|
||||
).parse_mode(ParseMode::Html).await.ok();
|
||||
}
|
||||
|
||||
// 8. Update review message
|
||||
if let Some(mid) = submission.review_message_id {
|
||||
bot.edit_message_text(chat_id, MessageId(mid), format!("<b>[ APPROVED ]</b> #{}\nApproved by <code>{}</code>", submission_id, user_id))
|
||||
.parse_mode(ParseMode::Html)
|
||||
@@ -1809,7 +2033,7 @@ async fn handle_forward_callback(
|
||||
.await.ok();
|
||||
}
|
||||
|
||||
// 7. Update status
|
||||
// 9. Update status
|
||||
forward_repo.update_status(submission_id, "approved").await?;
|
||||
}
|
||||
"ignore" => {
|
||||
@@ -1832,6 +2056,37 @@ async fn handle_forward_callback(
|
||||
}
|
||||
forward_repo.update_status(submission_id, "blacklisted").await?;
|
||||
}
|
||||
"ban" => {
|
||||
let target = UserId(submission.user_id as u64);
|
||||
let _ = bot.ban_chat_member(ChatId(forward_def.destination_chat_id), target).await;
|
||||
let _ = bot.ban_chat_member(ChatId(forward_def.review_group_id), target).await;
|
||||
let repo = PunishmentRepo::new(ctx.db.conn());
|
||||
let _ = repo.insert(forward_def.destination_chat_id, submission.user_id, "ban", None, None, user_id).await;
|
||||
let _ = repo.insert(forward_def.review_group_id, submission.user_id, "ban", None, None, user_id).await;
|
||||
if let Some(mid) = submission.review_message_id {
|
||||
bot.edit_message_text(chat_id, MessageId(mid), format!("<b>[ BANNED ]</b> #{}\nBanned by <code>{}</code>", submission_id, user_id))
|
||||
.parse_mode(ParseMode::Html)
|
||||
.reply_markup(InlineKeyboardMarkup::new(Vec::<Vec<InlineKeyboardButton>>::new()))
|
||||
.await.ok();
|
||||
}
|
||||
forward_repo.update_status(submission_id, "banned").await?;
|
||||
}
|
||||
"banblk" => {
|
||||
let target = UserId(submission.user_id as u64);
|
||||
let _ = bot.ban_chat_member(ChatId(forward_def.destination_chat_id), target).await;
|
||||
let _ = bot.ban_chat_member(ChatId(forward_def.review_group_id), target).await;
|
||||
let repo = PunishmentRepo::new(ctx.db.conn());
|
||||
let _ = repo.insert(forward_def.destination_chat_id, submission.user_id, "ban", None, None, user_id).await;
|
||||
let _ = repo.insert(forward_def.review_group_id, submission.user_id, "ban", None, None, user_id).await;
|
||||
forward_repo.add_to_list(submission.forward_id, submission.user_id, "blacklist").await?;
|
||||
if let Some(mid) = submission.review_message_id {
|
||||
bot.edit_message_text(chat_id, MessageId(mid), format!("<b>[ BAN/BL ]</b> #{}\nBanned + Blacklisted by <code>{}</code>", submission_id, user_id))
|
||||
.parse_mode(ParseMode::Html)
|
||||
.reply_markup(InlineKeyboardMarkup::new(Vec::<Vec<InlineKeyboardButton>>::new()))
|
||||
.await.ok();
|
||||
}
|
||||
forward_repo.update_status(submission_id, "banblk").await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -1863,6 +2118,12 @@ async fn is_admin_in_chat(bot: &Bot, chat_id: ChatId, user_id: UserId) -> bool {
|
||||
is_admin(bot, chat_id, user_id).await
|
||||
}
|
||||
|
||||
fn escape_html(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
async fn handle_get_id_search(
|
||||
bot: &Bot,
|
||||
chat_id: ChatId,
|
||||
@@ -1885,7 +2146,7 @@ async fn handle_get_id_search(
|
||||
let full_name = format!("{} {}", user.first_name, user.last_name.unwrap_or_default());
|
||||
let display = full_name.trim().to_lowercase();
|
||||
if username.contains(search_term) || display.contains(search_term) || user.id.0.to_string() == query {
|
||||
let name = full_name.trim().to_string();
|
||||
let name = escape_html(full_name.trim());
|
||||
matches.push(format!("• <code>{}</code> — {}", user.id.0, name));
|
||||
}
|
||||
}
|
||||
@@ -1907,14 +2168,21 @@ async fn handle_admin_blacklist_uid(
|
||||
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?;
|
||||
if !ctx.config.groups.admin_group_ids.contains(&chat_id.0) {
|
||||
bot.send_message(chat_id, "This command is only available in the admin group.")
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
let uid = text.split_whitespace().nth(1).and_then(|s| s.parse::<i64>().ok());
|
||||
let Some(uid) = uid else {
|
||||
bot.send_message(chat_id, "Usage: /blacklist_uid <user_id>").await?;
|
||||
return Ok(());
|
||||
};
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -1924,14 +2192,21 @@ async fn handle_admin_whitelist_uid(
|
||||
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?;
|
||||
if !ctx.config.groups.admin_group_ids.contains(&chat_id.0) {
|
||||
bot.send_message(chat_id, "This command is only available in the admin group.")
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
let uid = text.split_whitespace().nth(1).and_then(|s| s.parse::<i64>().ok());
|
||||
let Some(uid) = uid else {
|
||||
bot.send_message(chat_id, "Usage: /whitelist_uid <user_id>").await?;
|
||||
return Ok(());
|
||||
};
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -1966,6 +2241,70 @@ fn parse_duration(parts: &[&str]) -> Result<Option<i64>, String> {
|
||||
Ok(Some(total as i64))
|
||||
}
|
||||
|
||||
async fn propagate_punishment(
|
||||
bot: &Bot,
|
||||
ctx: &BotContext,
|
||||
current_chat_id: ChatId,
|
||||
target_user_id: u64,
|
||||
action_type: &str,
|
||||
duration_seconds: Option<i64>,
|
||||
reason: Option<&str>,
|
||||
created_by: i64,
|
||||
) {
|
||||
if !ctx.config.groups.global_ban {
|
||||
return;
|
||||
}
|
||||
let forward_repo = ForwardRepo::new(ctx.db.conn());
|
||||
let mut chat_ids = HashSet::new();
|
||||
chat_ids.extend(ctx.config.groups.admin_group_ids.iter().copied());
|
||||
chat_ids.extend(ctx.config.groups.review_group_ids.iter().copied());
|
||||
match forward_repo.list_active_chat_ids().await {
|
||||
Ok(ids) => chat_ids.extend(ids),
|
||||
Err(e) => tracing::warn!("failed to list active forward chats: {}", e),
|
||||
}
|
||||
let target = UserId(target_user_id);
|
||||
for chat_id in chat_ids {
|
||||
if chat_id == current_chat_id.0 {
|
||||
continue;
|
||||
}
|
||||
if !is_admin(bot, ChatId(chat_id), ctx.bot_id).await {
|
||||
continue;
|
||||
}
|
||||
let chat = ChatId(chat_id);
|
||||
let res = match action_type {
|
||||
"ban" => {
|
||||
if let Some(dur) = duration_seconds {
|
||||
let until = chrono::Utc::now() + chrono::Duration::seconds(dur);
|
||||
bot.ban_chat_member(chat, target).until_date(until).await
|
||||
} else {
|
||||
bot.ban_chat_member(chat, target).await
|
||||
}
|
||||
}
|
||||
"mute" => {
|
||||
if let Some(dur) = duration_seconds {
|
||||
let until = chrono::Utc::now() + chrono::Duration::seconds(dur);
|
||||
bot.restrict_chat_member(chat, target, ChatPermissions::empty()).until_date(until).await
|
||||
} else {
|
||||
bot.restrict_chat_member(chat, target, ChatPermissions::empty()).await
|
||||
}
|
||||
}
|
||||
"kick" => {
|
||||
let _ = bot.ban_chat_member(chat, target).await;
|
||||
bot.unban_chat_member(chat, target).await
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
if let Err(e) = res {
|
||||
tracing::warn!("global_ban {} failed in chat {} for user {}: {}", action_type, chat_id, target_user_id, e);
|
||||
continue;
|
||||
}
|
||||
let repo = PunishmentRepo::new(ctx.db.conn());
|
||||
if let Err(e) = repo.insert(chat_id, target_user_id as i64, action_type, duration_seconds, reason, created_by).await {
|
||||
tracing::warn!("failed to record propagated punishment in chat {}: {}", chat_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_target_user_id(
|
||||
bot: &Bot,
|
||||
chat_id: ChatId,
|
||||
|
||||
Reference in New Issue
Block a user