Major improvement, security handling, file handling +fixes

This commit is contained in:
unknown
2026-05-23 00:13:56 +02:00
parent 2129081599
commit a7b44af91a
25 changed files with 925 additions and 116 deletions

View File

@@ -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 &lt;dest&gt; &lt;review&gt; [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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
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,