feat: react with poop before auto-delete
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
- Команда разбана: `/РАЗБАН` (тоже можно с `!`).
|
- Команда разбана: `/РАЗБАН` (тоже можно с `!`).
|
||||||
- Обе команды работают только как `reply` на сообщение нужного пользователя.
|
- Обе команды работают только как `reply` на сообщение нужного пользователя.
|
||||||
- Список ID хранится в `data/blocked_users.json` и сохраняется между перезапусками.
|
- Список ID хранится в `data/blocked_users.json` и сохраняется между перезапусками.
|
||||||
|
- Перед удалением бот всегда пытается поставить реакцию `💩` на сообщение.
|
||||||
- При бане дополнительно чистятся последние сообщения этого пользователя в текущем чате.
|
- При бане дополнительно чистятся последние сообщения этого пользователя в текущем чате.
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
@@ -42,4 +43,5 @@
|
|||||||
|
|
||||||
## Ограничения
|
## Ограничения
|
||||||
- Удаление "только у вас" работает в формате `best effort` и зависит от ограничений Telegram API.
|
- Удаление "только у вас" работает в формате `best effort` и зависит от ограничений Telegram API.
|
||||||
- Если Telegram ограничивает удаление в конкретном типе чата, бот залогирует ошибку и продолжит работу.
|
- В супергруппах Telegram может не дать локально удалять чужие сообщения без админ-прав.
|
||||||
|
- Если удаление не удалось из-за прав/ограничений, бот отправит диагностическое уведомление в `Избранное`.
|
||||||
|
|||||||
105
bot.py
105
bot.py
@@ -8,12 +8,14 @@ from typing import Optional
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from telethon import TelegramClient, events
|
from telethon import TelegramClient, events
|
||||||
from telethon.errors import RPCError
|
from telethon.errors import RPCError
|
||||||
|
from telethon.tl import functions, types
|
||||||
from telethon.tl.custom.message import Message
|
from telethon.tl.custom.message import Message
|
||||||
|
|
||||||
BAN_COMMAND = "/вбаннахуй"
|
BAN_COMMAND = "/вбаннахуй"
|
||||||
UNBAN_COMMAND = "/разбан"
|
UNBAN_COMMAND = "/разбан"
|
||||||
DATA_PATH = Path("data/blocked_users.json")
|
DATA_PATH = Path("data/blocked_users.json")
|
||||||
RECENT_CLEANUP_LIMIT = 50
|
RECENT_CLEANUP_LIMIT = 50
|
||||||
|
POOP_REACTION_EMOJI = "💩"
|
||||||
|
|
||||||
ANIMATION_FRAMES = [
|
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(
|
async def delete_message_best_effort(
|
||||||
client: TelegramClient, message: Message
|
client: TelegramClient, message: Message
|
||||||
) -> bool:
|
) -> tuple[bool, Optional[str]]:
|
||||||
|
last_error: Optional[str] = None
|
||||||
try:
|
try:
|
||||||
await message.delete(revoke=False)
|
await message.delete(revoke=False)
|
||||||
return True
|
return True, None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
last_error = f"{type(exc).__name__}: {exc}"
|
||||||
logging.debug(
|
logging.debug(
|
||||||
"Удаление через message.delete(revoke=False) не сработало: %s", exc
|
"Удаление через message.delete(revoke=False) не сработало: %s", exc
|
||||||
)
|
)
|
||||||
@@ -143,8 +147,9 @@ async def delete_message_best_effort(
|
|||||||
try:
|
try:
|
||||||
target = message.input_chat if message.input_chat else message.chat_id
|
target = message.input_chat if message.input_chat else message.chat_id
|
||||||
await client.delete_messages(target, [message.id], revoke=False)
|
await client.delete_messages(target, [message.id], revoke=False)
|
||||||
return True
|
return True, None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
last_error = f"{type(exc).__name__}: {exc}"
|
||||||
logging.debug(
|
logging.debug(
|
||||||
"Удаление через client.delete_messages(..., revoke=False) не сработало: %s",
|
"Удаление через client.delete_messages(..., revoke=False) не сработало: %s",
|
||||||
exc,
|
exc,
|
||||||
@@ -152,8 +157,9 @@ async def delete_message_best_effort(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await message.delete()
|
await message.delete()
|
||||||
return True
|
return True, None
|
||||||
except RPCError as exc:
|
except RPCError as exc:
|
||||||
|
last_error = f"{type(exc).__name__}: {exc}"
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"Не удалось удалить сообщение %s в чате %s: %s",
|
"Не удалось удалить сообщение %s в чате %s: %s",
|
||||||
message.id,
|
message.id,
|
||||||
@@ -161,13 +167,57 @@ async def delete_message_best_effort(
|
|||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
last_error = "UnexpectedError"
|
||||||
logging.exception(
|
logging.exception(
|
||||||
"Неожиданная ошибка удаления сообщения %s в чате %s.",
|
"Неожиданная ошибка удаления сообщения %s в чате %s.",
|
||||||
message.id,
|
message.id,
|
||||||
message.chat_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(
|
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):
|
async for message in client.iter_messages(chat_id, from_user=user_id, limit=limit):
|
||||||
if message.id is None:
|
if message.id is None:
|
||||||
continue
|
continue
|
||||||
if await delete_message_best_effort(client, message):
|
deleted, _ = await delete_message_with_retries(client, message)
|
||||||
|
if deleted:
|
||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
|
|
||||||
return deleted_count
|
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]:
|
def load_settings() -> tuple[int, str, str]:
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
api_id_raw = os.getenv("API_ID", "").strip()
|
api_id_raw = os.getenv("API_ID", "").strip()
|
||||||
@@ -211,6 +277,7 @@ async def main() -> None:
|
|||||||
api_id, api_hash, session_name = load_settings()
|
api_id, api_hash, session_name = load_settings()
|
||||||
blocklist = BlocklistStore(DATA_PATH)
|
blocklist = BlocklistStore(DATA_PATH)
|
||||||
await blocklist.load()
|
await blocklist.load()
|
||||||
|
notified_delete_failures: set[tuple[int, int]] = set()
|
||||||
|
|
||||||
client = TelegramClient(session_name, api_id, api_hash)
|
client = TelegramClient(session_name, api_id, api_hash)
|
||||||
|
|
||||||
@@ -237,6 +304,12 @@ async def main() -> None:
|
|||||||
status_message = await event.reply("Подготовка...")
|
status_message = await event.reply("Подготовка...")
|
||||||
added = await blocklist.add(target_user_id)
|
added = await blocklist.add(target_user_id)
|
||||||
await animate_ban(status_message, target_user_id, added)
|
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(
|
cleaned = await cleanup_recent_messages_from_user(
|
||||||
client,
|
client,
|
||||||
event.chat_id,
|
event.chat_id,
|
||||||
@@ -264,6 +337,9 @@ async def main() -> None:
|
|||||||
if event.is_private:
|
if event.is_private:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if event.is_channel and not event.is_group:
|
||||||
|
return
|
||||||
|
|
||||||
sender_id = event.sender_id
|
sender_id = event.sender_id
|
||||||
if sender_id is None:
|
if sender_id is None:
|
||||||
return
|
return
|
||||||
@@ -271,7 +347,7 @@ async def main() -> None:
|
|||||||
if not await blocklist.contains(int(sender_id)):
|
if not await blocklist.contains(int(sender_id)):
|
||||||
return
|
return
|
||||||
|
|
||||||
deleted = await delete_message_best_effort(client, event.message)
|
deleted, error = await delete_message_with_retries(client, event.message)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"Пропуск удаления: сообщение %s от пользователя %s в чате %s.",
|
"Пропуск удаления: сообщение %s от пользователя %s в чате %s.",
|
||||||
@@ -279,6 +355,21 @@ async def main() -> None:
|
|||||||
sender_id,
|
sender_id,
|
||||||
event.chat_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()
|
await client.start()
|
||||||
me = await client.get_me()
|
me = await client.get_me()
|
||||||
|
|||||||
Reference in New Issue
Block a user