diff --git a/README.md b/README.md index 1ccfefa..6818a38 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - Команда разбана: `/РАЗБАН` (тоже можно с `!`). - Обе команды работают только как `reply` на сообщение нужного пользователя. - Список ID хранится в `data/blocked_users.json` и сохраняется между перезапусками. +- Перед удалением бот всегда пытается поставить реакцию `💩` на сообщение. - При бане дополнительно чистятся последние сообщения этого пользователя в текущем чате. ## Установка @@ -42,4 +43,5 @@ ## Ограничения - Удаление "только у вас" работает в формате `best effort` и зависит от ограничений Telegram API. -- Если Telegram ограничивает удаление в конкретном типе чата, бот залогирует ошибку и продолжит работу. +- В супергруппах Telegram может не дать локально удалять чужие сообщения без админ-прав. +- Если удаление не удалось из-за прав/ограничений, бот отправит диагностическое уведомление в `Избранное`. diff --git a/bot.py b/bot.py index 7a1c07b..037faf6 100644 --- a/bot.py +++ b/bot.py @@ -8,12 +8,14 @@ from typing import Optional from dotenv import load_dotenv from telethon import TelegramClient, events from telethon.errors import RPCError +from telethon.tl import functions, types from telethon.tl.custom.message import Message BAN_COMMAND = "/вбаннахуй" UNBAN_COMMAND = "/разбан" DATA_PATH = Path("data/blocked_users.json") RECENT_CLEANUP_LIMIT = 50 +POOP_REACTION_EMOJI = "💩" ANIMATION_FRAMES = [ "Запускаю анти-режим.", @@ -131,11 +133,13 @@ async def animate_ban(message: Message, target_user_id: int, is_new: bool) -> No async def delete_message_best_effort( client: TelegramClient, message: Message -) -> bool: +) -> tuple[bool, Optional[str]]: + last_error: Optional[str] = None try: await message.delete(revoke=False) - return True + return True, None except Exception as exc: + last_error = f"{type(exc).__name__}: {exc}" logging.debug( "Удаление через message.delete(revoke=False) не сработало: %s", exc ) @@ -143,8 +147,9 @@ async def delete_message_best_effort( try: target = message.input_chat if message.input_chat else message.chat_id await client.delete_messages(target, [message.id], revoke=False) - return True + return True, None except Exception as exc: + last_error = f"{type(exc).__name__}: {exc}" logging.debug( "Удаление через client.delete_messages(..., revoke=False) не сработало: %s", exc, @@ -152,8 +157,9 @@ async def delete_message_best_effort( try: await message.delete() - return True + return True, None except RPCError as exc: + last_error = f"{type(exc).__name__}: {exc}" logging.warning( "Не удалось удалить сообщение %s в чате %s: %s", message.id, @@ -161,13 +167,57 @@ async def delete_message_best_effort( exc, ) except Exception: + last_error = "UnexpectedError" logging.exception( "Неожиданная ошибка удаления сообщения %s в чате %s.", message.id, message.chat_id, ) - return False + return False, last_error + + +async def react_with_poop_best_effort(client: TelegramClient, message: Message) -> None: + if message.id is None: + return + + try: + peer = message.input_chat if message.input_chat else message.chat_id + await client( + functions.messages.SendReactionRequest( + peer=peer, + msg_id=message.id, + big=False, + add_to_recent=False, + reaction=[types.ReactionEmoji(emoticon=POOP_REACTION_EMOJI)], + ) + ) + except Exception as exc: + logging.debug( + "Не удалось поставить реакцию %s на сообщение %s: %s", + POOP_REACTION_EMOJI, + message.id, + exc, + ) + + +async def delete_message_with_retries( + client: TelegramClient, + message: Message, + retries: tuple[float, ...] = (0.0, 0.7, 2.0), +) -> tuple[bool, Optional[str]]: + await react_with_poop_best_effort(client, message) + + last_error: Optional[str] = None + for delay in retries: + if delay > 0: + await asyncio.sleep(delay) + deleted, error = await delete_message_best_effort(client, message) + if deleted: + return True, None + last_error = error + + return False, last_error async def cleanup_recent_messages_from_user( @@ -177,12 +227,28 @@ async def cleanup_recent_messages_from_user( async for message in client.iter_messages(chat_id, from_user=user_id, limit=limit): if message.id is None: continue - if await delete_message_best_effort(client, message): + deleted, _ = await delete_message_with_retries(client, message) + if deleted: deleted_count += 1 return deleted_count +def is_permission_like_delete_error(error_text: Optional[str]) -> bool: + if not error_text: + return False + + normalized = error_text.upper() + markers = ( + "CHATADMINREQUIRED", + "CHAT_ADMIN_REQUIRED", + "MESSAGEDELETEFORBIDDEN", + "MESSAGE_DELETE_FORBIDDEN", + "RIGHT_FORBIDDEN", + ) + return any(marker in normalized for marker in markers) + + def load_settings() -> tuple[int, str, str]: load_dotenv() api_id_raw = os.getenv("API_ID", "").strip() @@ -211,6 +277,7 @@ async def main() -> None: api_id, api_hash, session_name = load_settings() blocklist = BlocklistStore(DATA_PATH) await blocklist.load() + notified_delete_failures: set[tuple[int, int]] = set() client = TelegramClient(session_name, api_id, api_hash) @@ -237,6 +304,12 @@ async def main() -> None: status_message = await event.reply("Подготовка...") added = await blocklist.add(target_user_id) await animate_ban(status_message, target_user_id, added) + chat = await event.get_chat() + if isinstance(chat, types.Channel) and getattr(chat, "megagroup", False): + await event.reply( + "Важно: это супергруппа. В Telegram API локальное удаление чужих " + "сообщений может быть недоступно без прав администратора." + ) cleaned = await cleanup_recent_messages_from_user( client, event.chat_id, @@ -264,6 +337,9 @@ async def main() -> None: if event.is_private: return + if event.is_channel and not event.is_group: + return + sender_id = event.sender_id if sender_id is None: return @@ -271,7 +347,7 @@ async def main() -> None: if not await blocklist.contains(int(sender_id)): return - deleted = await delete_message_best_effort(client, event.message) + deleted, error = await delete_message_with_retries(client, event.message) if not deleted: logging.warning( "Пропуск удаления: сообщение %s от пользователя %s в чате %s.", @@ -279,6 +355,21 @@ async def main() -> None: sender_id, event.chat_id, ) + if is_permission_like_delete_error(error): + key = (int(event.chat_id), int(sender_id)) + if key not in notified_delete_failures: + notified_delete_failures.add(key) + try: + await client.send_message( + "me", + "Не удалось удалить сообщение по автофильтру.\n" + f"Чат: `{event.chat_id}`\n" + f"Пользователь: `{sender_id}`\n" + f"Причина: `{error}`\n" + "Обычно это ограничение прав/типа чата в Telegram.", + ) + except Exception: + logging.exception("Не удалось отправить уведомление в Избранное.") await client.start() me = await client.get_me()