From 42a13742cf721dbfc51655cf6db961fa323985f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=95=D0=B2=D0=B3=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=9F=D0=B0?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE=D0=B2?= Date: Fri, 15 Aug 2025 12:54:03 +0300 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=BE=D0=BB=D0=B5=D0=B5=20=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=B2=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=8B=D0=BB=D0=BA=D0=B5.=20*=20=D0=B4=D1=80=D1=83?= =?UTF-8?q?=D0=B3=D0=BE=D0=B9=20=D0=B0=D0=BB=D0=B3=D0=BE=D1=80=D0=B8=D1=82?= =?UTF-8?q?=D0=BC=20=D0=BE=D1=82=D1=81=D0=BB=D0=B5=D0=B6=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D1=91=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9.=20=D0=9F=D0=BE=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D1=8F=D0=BC=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D1=87=D0=B0=D1=81=D1=82=D1=8B=D1=85=20=D0=B7=D0=B0=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D0=BE=D0=B2.=20*=20add:=20portable-install.sh=20*?= =?UTF-8?q?=20add:=20docker-compose.yml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- README.md | 31 ++ docker-compose.yml | 26 ++ telethon_get_chat_list.py => get_chat_list.py | 9 +- main.py | 409 ++++++++++++------ portable_install.bat | 12 +- portable_install.sh | 146 +++++++ 7 files changed, 492 insertions(+), 145 deletions(-) create mode 100644 README.md create mode 100644 docker-compose.yml rename telethon_get_chat_list.py => get_chat_list.py (66%) create mode 100644 portable_install.sh diff --git a/.gitignore b/.gitignore index 6c96afa..293bfaf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .env venv* -python* \ No newline at end of file +python* +*.json +*.session* diff --git a/README.md b/README.md new file mode 100644 index 0000000..f4745ea --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Alerts_bot +Телеграм бот для пересылки постов по ключевым словам. Использует библиотеку Telethon и User bot API. То есть для работы бота нужен аккаунт пользователя телеграм. +## Установка, настройка и запуск +1. Запустить `portable_install.bat` для Виндовс или `portable_install.sh` для Linux. +Это скачает портбельный Пайтон и загрузит необходимые пакеты для работы. Создаст файл +2. Появится файл `run_me.bat` или `run-me.sh` соответсвенно для Windows или Linux +3. Нужно настроить файл `.env` +```.env +api_id=12345678 +api_hash=1234567890.... +session_name=userbot_session +source_channel_username=public_tg_channel +target_groups=1234567890,9876543210 +groups_clean_srv_msgs=1234567890,9876543210 # если не указать, то берётся из target_groups +filter_keywords=триггерное выражение,слово,ещё слова +filter_negative_keywords=негативные выражения,исключение,то что не должно пересылаться +``` +* api_id, api_hash получается на [my.telegram.org](https://my.telegram.org/auth) созданием приложения. +* Записав эти данные api_id, api_hash нужно получить список доступных чатов через +`python312\python.exe get_chat_list.py`. +Так же эта процедура создаст необходимый файл сессии. +Нужный чат для пересылки в него сообщений записать в `target_groups=` в файле `.env`. +Их может быть перечислено несколько через запятую. +* `source_channel_username` - публичная ссылка на исходный канал. Может сработать и chat_id из полуенного списка выше. +* `groups_clean_srv_msgs` Можно указать в каких группах ведётся очистка сервисных сообщений. По умолчанию в тех же что и target_groups +* Фильтры перечисляются через запятую без пробелов +3. После настройки запустить `run_me.bat` или `run-me.sh` соответсвенно для Windows или Linux + +## Запуск на доккер +1. Нужно настроить локально по инструкции выше +2. Пример docker-compose содержится в `docker-compose.yml`. Проверить пути для файлов сессии и путь к коду, который будет подгужен в Volume. \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..195afbd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3.8' + +services: + app: + image: debian:bookworm-slim + container_name: alert-telethon-app + working_dir: /app + volumes: + - .:/app # весь проект + environment: + - TZ=Europe/Moscow # часовой пояс Москва + command: > + /bin/bash -c " + apt-get update && + apt-get install -y --no-install-recommends curl tar bash && + chmod +x portable_install.sh && + ./portable_install.sh && + ./run_me.sh + " + restart: unless-stopped + networks: + - alert-telethon_net + +networks: + alert-telethon_net: + driver: bridge \ No newline at end of file diff --git a/telethon_get_chat_list.py b/get_chat_list.py similarity index 66% rename from telethon_get_chat_list.py rename to get_chat_list.py index f666a09..f65a5a4 100644 --- a/telethon_get_chat_list.py +++ b/get_chat_list.py @@ -8,9 +8,9 @@ print(f'Использую: {env}') load_dotenv(env) # === Данные юзер-бота === -api_id=26507458 -api_hash='9bf31965a06209eadd1995cec266d3ae' -session_name='Joshua_session' +api_id=os.getenv('api_id') +api_hash=os.getenv('api_hash') +session_name=os.getenv('session_name') or 'userbot' client = TelegramClient(session_name, api_id, api_hash) @@ -19,6 +19,7 @@ async def get_group_info(): groups = await client.get_dialogs() for dialog in groups: if dialog.is_group or dialog.is_channel: - print(f'Название: {dialog.name} | ID: {dialog.entity.id}') + uname = dialog.entity.username or 'None' + print(f'Название: {dialog.name:25} | username: {uname:15} | ID: {dialog.entity.id}') client.loop.run_until_complete(get_group_info()) \ No newline at end of file diff --git a/main.py b/main.py index 11021da..7dd6734 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- +# from __future__ import annotations from telethon import TelegramClient, events +from telethon.tl.types import PeerUser, PeerChat, PeerChannel, Message import re import os import json @@ -8,17 +10,14 @@ import logging import asyncio from datetime import datetime, timedelta from dotenv import find_dotenv, load_dotenv +from typing import Optional # ==== Настройка логирования ==== -logging.basicConfig( - format='[%(levelname)s] %(asctime)s | %(message)s', - level=logging.INFO, - handlers=[ - logging.StreamHandler() # Вывод в консоль - # logging.FileHandler("bot.log", encoding="utf-8") # Раскомментируй, чтобы сохранять логи в файл - ] -) log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) +console_handler = logging.StreamHandler() +console_handler.setFormatter(logging.Formatter('[%(levelname)s] %(asctime)s | %(message)s')) +log.addHandler(console_handler) # === Загрузка переменных окружения === env = find_dotenv() @@ -28,19 +27,30 @@ load_dotenv(env) # === Данные юзер-бота === api_id=os.getenv('api_id') api_hash=os.getenv('api_hash') -session_name=os.getenv('session_name') +session_name=os.getenv('session_name') or 'userbot' # === Настройки === source_channel_username=os.getenv('source_channel_username') # Канал, который слушаем -target_group_chat_id=int(os.getenv('target_group_chat_id')) # Группа, куда пересылаем - +target_groups=os.getenv('target_groups').split(',') # Группы, куда пересылаем +for i, s in enumerate(target_groups): + if s.isdigit() or s.startswith('-') and s[1:].isdigit(): + target_groups[i] = int(s) +target_entities=[] +groups_clean_srv_msgs=os.getenv('groups_clean_srv_msgs').split(',') # Группы, где удаляем сообщения о присоединившихся +if not groups_clean_srv_msgs: + groups_clean_srv_msgs = list(target_groups) +for i, s in enumerate(groups_clean_srv_msgs): + if s.isdigit() or s.startswith('-') and s[1:].isdigit(): + groups_clean_srv_msgs[i] = int(s) filter_keywords=os.getenv('filter_keywords') # Строки или регулярное выражение для поиска filter_keywords=filter_keywords.split(',') filter_negative_keywords=os.getenv('filter_negative_keywords') # Строки или регулярное выражение для поиска filter_negative_keywords=filter_negative_keywords.split(',') DATA_FILE=os.getenv('DATA_FILE') # Файл для сохранения сообщений -MAX_MESSAGES=int(os.getenv('MAX_MESSAGES')) # Количество хранящихся сообщений -CHECK_INTERVAL=int(os.getenv('CHECK_INTERVAL')) # Периодичность проверки удалений на канале +if not DATA_FILE: + DATA_FILE='messages.json' +MAX_MESSAGES = os.getenv('MAX_MESSAGES') # Количество хранящихся сообщений +MAX_MESSAGES=int(MAX_MESSAGES) if MAX_MESSAGES else 50 # ======================= @@ -51,14 +61,17 @@ def load_state(): if os.path.exists(DATA_FILE): with open(DATA_FILE, 'r', encoding='utf-8') as f: return json.load(f) - return {} + return [{},{}] -def save_state(data): +def save_state(): with open(DATA_FILE, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=2) + json.dump([last_known_msg_id, source_messages], f, ensure_ascii=False, indent=2) + log.debug('Состояние сохранено') # ==== Хранение данных ==== -source_messages = load_state() # {msg_id: {'text': ..., 'forwarded_msg_id': ...}} +last_known_msg_id, source_messages = load_state() +# last_known_msg_id {chat_id: msg_id} +# source_messages {msg_id: {'text': ..., 'forwarded_msg_id': {chat_id: message_id}}} # ======================= def shorten(text: str, length: int = 80) -> str: @@ -68,132 +81,244 @@ def shorten(text: str, length: int = 80) -> str: return f'{text[:length-1]}…' return text +async def prepare_entities(target_groups: list) -> list: + """ + Обрабатывает список target_groups и возвращает список сущностей для рассылки сообщений. + + :param client: Экземпляр TelegramClient + :param target_groups: Список, содержащий chat_id, пригласительные ссылки или имена пользователей + :return: Список сущностей, готовых для отправки сообщений + """ + for item in target_groups: + try: + # Проверяем, является ли элемент числом (chat_id) + if isinstance(item, (int, str)) and str(item).isdigit() or str(item).startswith('-') and str(item)[1:].isdigit(): + # Если это chat_id, преобразуем в Peer + chat_id = int(item) + entity = await client.get_entity(chat_id) + target_entities.append(entity) + + # Проверяем, является ли элемент username (начинается с @) + elif isinstance(item, str) and item.startswith('@'): + entity = await client.get_entity(item) + target_entities.append(entity) + + # Проверяем, является ли элемент пригласительной ссылкой + elif isinstance(item, str) and (item.startswith('t.me/') or item.startswith('https://t.me/')): + # Удаляем https://, если есть + link = item.replace('https://', '') + # Проверяем, является ли ссылка на публичный канал/группу (@username) + if re.match(r'^t\.me/@[a-zA-Z0-9_]+$', link): + entity = await client.get_entity(link.replace('t.me/', '')) + target_entities.append(entity) + # Проверяем, является ли ссылка на приватный чат/канал (t.me/joinchat/XXXX или t.me/+XXXX) + elif re.match(r'^t\.me/(joinchat/|\+)[a-zA-Z0-9_-]+$', link): + # Для приватных чатов нужно сначала присоединиться + from telethon.tl.functions.messages import ImportChatInviteRequest + hash = link.split('/')[-1].replace('+', '') + try: + result = await client(ImportChatInviteRequest(hash)) + entity = result.chats[0] # Получаем сущность чата + target_entities.append(entity) + except Exception as e: + print(f"Ошибка при попытке присоединиться к чату {link}: {e}") + else: + print(f"Неподдерживаемый формат ссылки: {item}") + + else: + print(f"Неподдерживаемый формат элемента: {item}") + + except ValueError as e: + print(f"Ошибка при обработке {item}: {e}") + except Exception as e: + print(f"Неизвестная ошибка при обработке {item}: {e}") + + # return target_entities + + +async def forward_to_targets( + message: Message, + targets: Optional[list[str | int]] = None, + source_reply_to_msg_id: Optional[str | int] = None + ) -> list[Message]: + # Пересылаем сообщения + new_msgs = [] + for entity in (targets or target_entities): + if source_reply_to_msg_id and (forwarded_info:=source_messages.get(str(source_reply_to_msg_id))): + for str_chat_id, target_msg_id in forwarded_info['forwarded_msg_id'].items(): + if str_chat_id == entity.chat.id: + link = await client.get_message_link(message) + text = f'Переслано из [{message.chat.title}]({link})\n{message.text}' + new_msg = await client.send_message(str_chat_id, text, reply_to=target_msg_id, parse_mode='markdown') + else: + new_msg = await client.forward_messages(entity, message) + new_msgs.append(new_msg) + # Запоминаем пересланные данные сообщения + source_messages[str(message.id)] = { + 'text': message.text, + 'timestamp': message.date.timestamp(), + 'forwarded_msg_id': {str(msg.chat.id): msg.id for msg in new_msgs} + } + # Обновляем последние известные id для канала + for new_msg in new_msgs: + last_known_msg_id[str(new_msg.chat.id)] = new_msg.id + # Ограничиваем размер словаря + if len(source_messages) > MAX_MESSAGES: + oldest = min(source_messages.keys(), key=lambda m_id: source_messages[m_id]['timestamp']) + del source_messages[oldest] + # Сохраняем + save_state() + return new_msgs + + +# ==== Обработчик сообщений из получателей ==== +@client.on(events.NewMessage(chats=target_entities)) +async def handler(event: events.NewMessage.Event): + last_known_msg_id[str(event.chat.id)] = event.message.id + log.debug(f'last_known_msg_id[{event.chat.id}]={event.message.id} {event.chat.title}') + + +def check(text) -> Optional[bool]: + text = text or "" + if any(re.search(keyword, text, re.IGNORECASE) for keyword in filter_keywords) \ + and not any(re.search(keyword, text, re.IGNORECASE) for keyword in filter_negative_keywords): + return True + + # ==== Обработчик сообщений из источника ==== @client.on(events.NewMessage(chats=source_channel_username)) -async def handler(event): +async def handler_NewMessages(event: events.NewMessage.Event): message_text = event.message.text or "" - msg_id = event.message.id + last_known_msg_id[source_channel_username] = event.message.id if message_text: log.info(f"Новое сообщение: {shorten(message_text)}") # Проверяем само сообщение - if any(re.search(keyword, message_text, re.IGNORECASE) for keyword in filter_keywords) \ - and not any(re.search(keyword, message_text, re.IGNORECASE) for keyword in filter_negative_keywords): - new_msg = await client.forward_messages(target_group_chat_id, event.message) - source_messages[msg_id] = { - 'text': message_text, - 'timestamp': datetime.now().timestamp(), - 'forwarded_msg_id': new_msg.id - } - # Ограничиваем размер словаря - if len(source_messages) > MAX_MESSAGES: - oldest = min(source_messages.keys(), key=lambda k: source_messages[k]['timestamp']) - del source_messages[oldest] - save_state(source_messages) + if check(message_text): + await forward_to_targets(event.message) log.info(f"📩 Переслано: {shorten(message_text)}") return # Проверяем цепочку ответов reply = event.message.reply_to if reply and reply.reply_to_msg_id: - founded = await check_reply_chain(event, reply.reply_to_msg_id, depth=1) - if founded: - await client.forward_messages(target_group_chat_id, event.message) - log.info(f"⛓ Переслано по цепочке: {shorten(message_text)} | Глубина: {founded}") + source_reply_to_msg_id = await check_reply_chain(reply.reply_to_msg_id, depth=1) + if source_reply_to_msg_id: + await forward_to_targets(event.message, source_reply_to_msg_id=source_reply_to_msg_id) + log.info(f"⛓ Переслано по цепочке: {shorten(message_text)}") -# ==== Фоновая задача: проверка удалений и изменений ==== -async def monitor_messages(): +@client.on(events.MessageEdited(chats=source_channel_username)) +async def handler_edited(event: events.MessageEdited.Event): + stored_m_info = source_messages.get(str(event.message.id)) + if stored_m_info and stored_m_info['text'] != event.message.text: + log.info(f"📝 Сообщение изменено: {shorten(stored_m_info['text'])} → {shorten(event.message.text)}") + # Редактируем пересланное сообщение + for str_chat_id, msg_id in stored_m_info['forwarded_msg_id'].items(): + chat_id = int(str_chat_id) + # Получаем ID последнего сообщения в целевом чате + last_msg_id = last_known_msg_id.get(str_chat_id) + if not last_msg_id: + last_msg_id = await update_last_msg_id(chat_id) + log.debug(f'chat_id={chat_id}, stored_msg_id={msg_id}, last_msg_id_on_channel={last_msg_id}') + # Проверяем, является ли наше пересланное сообщение последним + if msg_id == last_msg_id: + # Это последнее сообщение — можно удалить и переслать заново + await client.delete_messages(chat_id, msg_id) + forward_to_targets(event.message, targets=[chat_id]) + log.info(f"🔁 {chat_id} Сообщение было последним → Заменено") + else: + # Не последнее → отправляем изменённый текст как ответ + new_msg = await client.send_message( + chat_id, + f"🔄 Текст изменён:\n{event.message.text}", + reply_to=stored_m_info['forwarded_msg_id'][str_chat_id] + ) + # Запоминаем пересланные данные сообщения + sm = source_messages.get(str(event.message.id)) + if not sm: + source_messages[str(event.message.id)] = { + 'text': event.message.text, + 'timestamp': event.message.date.timestamp(), + 'forwarded_msg_id': {str(new_msg.chat.id): new_msg.id} + } + else: + sm['text'] = event.message.text + sm['timestamp'] = event.message.date.timestamp() + sm['forwarded_msg_id'][str(new_msg.chat.id)] = new_msg.id + # Обновляем последние известные id для канала + last_known_msg_id[str(new_msg.chat.id)] = new_msg.id + + log.info(f"💬 {chat_id} Сообщение не последнее → Отправлен ответ") + + +@client.on(events.MessageDeleted(chats=source_channel_username)) +async def handler_deleted(event: events.MessageDeleted.Event): + for msg_id in event.deleted_ids: + stored_m_info = source_messages.get(str(msg_id)) + if stored_m_info: + log.info(f"🗑 Исходное сообщение удалено. Удаляю пересланное: {shorten(stored_m_info['text'])}") + for str_chat_id, fwd_msg_id in tuple(stored_m_info['forwarded_msg_id'].items()): + chat_id = int(str_chat_id) + await client.delete_messages(chat_id, fwd_msg_id) + del stored_m_info['forwarded_msg_id'][str_chat_id] + if not stored_m_info['forwarded_msg_id']: + del source_messages[str(msg_id)] + + +async def get_batch_messages(chat_id, min_id, max_id): + chat_id = int(chat_id) + approx_count = max_id - min_id + 1 + limit = int(approx_count * 1.05) # Увеличиваем лимит на 5%, чтобы точно зацепить все нужные + limit = min(limit, 3000) # Максимум 3000 (Telegram API limit) + + log.debug(f"🔍 Запрашиваю {limit} сообщений для чата {chat_id} из диапазона {min_id}–{max_id}") + + try: + # Получаем batch сообщений + batch = await client.get_messages(chat_id, limit=limit) + batch_dict = {msg.id: msg for msg in batch} + return batch_dict + except Exception as e: + log.error(f"⚠ Ошибка при получении batch-сообщений: {e}") + return None + + +async def update_last_msg_id(chat_id: int|str) -> int: + # Получаем ID последнего сообщения в целевом чате + chat_id = int(chat_id) + last_msg = await client.get_messages(chat_id, limit=1) + last_msg_id = last_msg[0].id if last_msg else None + if last_msg_id: + last_known_msg_id[str(chat_id)] = last_msg_id + return last_msg_id + + +# ==== Фоновая задача: очистки старых сообщений ==== +async def clear_old_messages(): while True: - await asyncio.sleep(CHECK_INTERVAL) - + await asyncio.sleep(600) # раз в 10 минут now = datetime.now().timestamp() - cutoff_time = now - 86400 # 24 часа - - for str_msg_id, stored_m_info in list(source_messages.items()): - msg_id = int(str_msg_id) - - # Удаление по возрасту + cutoff_time = now - 86400 # 24 час + start_len = len(source_messages) + for str_msg_id, stored_m_info in tuple(source_messages.items()): if stored_m_info['timestamp'] < cutoff_time: del source_messages[str_msg_id] - continue - - # Получаем данные о пересланном сообщении - try: - target_msg = await client.get_messages(target_group_chat_id, ids=stored_m_info['forwarded_msg_id']) - except Exception as e: - log.warning(f"⚠ Не удалось получить пересланное сообщение {msg_id}: {e}") - target_msg = None - continue - # Если пересланного сообщения нет (удалено) - if target_msg is None or getattr(target_msg, 'empty', False): - log.info(f"🗑 Пересланное сообщение отсутвует (удалено администратором): {shorten(stored_m_info['text'])}") - del source_messages[str_msg_id] - continue - - # Получаем сообщение из источника - try: - source_msg = await client.get_messages(source_channel_username, ids=msg_id) - except Exception as e: - log.warning(f"⚠ Не удалось получить исходное сообщение {msg_id}: {e}") - source_msg = None - continue - # Если исходного сообщения нет (удалено) - if source_msg is None or getattr(source_msg, 'empty', False): - log.info(f"🗑 Исходное сообщение удалено. Удаляю пересланное: {shorten(stored_m_info['text'])}") - await client.delete_messages(target_group_chat_id, msg_id) - del source_messages[str_msg_id] - continue - - # Если текст изменился - if source_msg.text != target_msg.text: - log.info(f"📝 Сообщение изменено: {shorten(target_msg.text)} → {shorten(source_msg.text)}") - # Редактируем пересланное сообщение - try: - # Получаем ID последнего сообщения в целевом чате - last_msg = await client.get_messages(target_group_chat_id, limit=1) - last_msg_id = last_msg[0].id if last_msg else None - - # Проверяем, является ли наше пересланное сообщение последним - if target_msg.id == last_msg_id: - # Это последнее сообщение — можно удалить и переслать заново - await client.delete_messages(target_group_chat_id, target_msg.id) - new_msg = await client.forward_messages(target_group_chat_id, source_msg) - source_messages[str(source_msg.id)] = { - 'text': source_msg.text, - 'timestamp': datetime.now().timestamp(), - 'forwarded_msg_id': new_msg.id - } - log.info("🔁 Сообщение было последним → Заменено") - else: - # Не последнее → отправляем изменённый текст как ответ - new_msg = await client.send_message( - target_group_chat_id, - f"🔄 Текст изменён:\n{source_msg.text}", - reply_to=stored_m_info['forwarded_msg_id'] - ) - source_messages[str(source_msg.id)] = { - 'text': source_msg.text, - 'timestamp': datetime.now().timestamp(), - 'forwarded_msg_id': new_msg.id - } - log.info("💬 Сообщение не последнее → Отправлен ответ") - except Exception as e: - log.error(f"❌ Не удалось обновить: {e}") - - # Сохраняем состояние - save_state(source_messages) + if start_len != len(source_messages): + log.debug(f'Очищены старые сообщения: {start_len} → {len(source_messages)}') + save_state() -async def check_reply_chain(event, msg_id, depth=1, max_depth=10) -> bool|int: - """Рекурсивная проверка цепочки ответов""" +async def check_reply_chain(msg_id, depth=1, max_depth=10) -> bool|int: + """Рекурсивная проверка цепочки ответов + В случае совпадения возвращает replied_msg.id""" if depth > max_depth: log.warning(f"🔁 Превышена максимальная глубина цепочки ({max_depth}), остановлено.") return False try: - replied_msg = await event.get_reply_message() + replied_msg = await client.get_messages(source_channel_username, ids=msg_id) except Exception as e: log.warning(f"⚠ Не удалось получить сообщение по ID {msg_id}: {e}") return False @@ -203,19 +328,19 @@ async def check_reply_chain(event, msg_id, depth=1, max_depth=10) -> bool|int: text = replied_msg.text or "" - log.info(f"🔍 Проверяю сообщение на уровне {depth}: {shorten(text,30)}") - - if any(re.search(keyword, text, re.IGNORECASE) for keyword in filter_keywords) \ - and not any(re.search(keyword, text, re.IGNORECASE) for keyword in filter_negative_keywords): - return depth + if check(text): + log.info(f"✔️ Есть совпадение на уровне {depth}: {shorten(text,30)}") + return replied_msg.id + else: + log.info(f"🔍 Проверяю сообщение на уровне {depth}: {shorten(text,30)}") if replied_msg.reply_to: - return await check_reply_chain(event, replied_msg.reply_to.reply_to_msg_id, depth + 1) + return await check_reply_chain(replied_msg.reply_to.reply_to_msg_id, depth + 1) return False # ==== Удаление системных сообщений о входе/выходе в целевой группе ==== -@client.on(events.ChatAction(chats=target_group_chat_id)) +@client.on(events.ChatAction(chats=groups_clean_srv_msgs)) async def del_join_leave(event: events): actions = { 'добавлен': event.user_added, @@ -227,16 +352,15 @@ async def del_join_leave(event: events): action = ', '.join(action) full_name = ' '.join([name for name in (event.user.first_name, event.user.last_name) if name]) await event.delete() - log.info(f"Удалено сообщение: Пользователь {full_name}({event.user_id}) {action} чат {target_group_chat_id}") + log.info(f"Удалено сообщение: Пользователь {full_name}({event.user_id}) {action} чат {event.chat.id}") async def fetch_system_messages(days_to_check=5) -> tuple[int, int]: """Собирает системные сообщения за последние N дней""" system_messages = [] - - async with client: + for chat_id in groups_clean_srv_msgs: try: - chat = await client.get_entity(target_group_chat_id) + chat = await client.get_entity(chat_id) except ValueError: log.info("❌ Не удалось найти чат. Убедитесь, что вы состоите в группе.") return system_messages @@ -258,6 +382,7 @@ async def fetch_system_messages(days_to_check=5) -> tuple[int, int]: system_messages.append((chat.id, message.id)) log.info(f"✅ Найдено {len(system_messages)} системных сообщений для удаления") + return system_messages @@ -279,7 +404,6 @@ async def delete_system_msgs(messages_list): # ==== Запуск бота ==== async def main(): - await client.start() # log.info("🧹 Начинаем очистку старых системных сообщений…") # messages = await fetch_system_messages(days_to_check=5) @@ -287,11 +411,30 @@ async def main(): # if not client.is_connected(): # log.info("🔄 Клиент не подключён, пытаемся восстановить соединение…") # await client.connect() - - asyncio.create_task(monitor_messages()) - - log.info("😊 Бот запущен. Ожидание событий…") - await client.run_until_disconnected() + + asyncio.create_task(clear_old_messages()) + try: + await client.start() + await prepare_entities(target_groups) + log.info("Env Parameters:" + "\n" + f"\nsource_channel_username = {source_channel_username}" + f"\ntarget_groups = {target_groups}" + f"\ngroups_clean_srv_msgs = {groups_clean_srv_msgs}" + f"\nfilter_keywords = {filter_keywords}" + f"\nfilter_negative_keywords= {filter_negative_keywords}" + "\n" + "\n😊 Бот запущен. Ожидание событий…" + ) + await client.run_until_disconnected() + except ConnectionError as e: + log.error(f"Ошибка подключения к Telegram: {e}. Повторная попытка через 10 секунд...") + await asyncio.sleep(10) # Подождать перед повторной попыткой + except KeyboardInterrupt: + log.info("🛑 Бот остановлен вручную.") + finally: + if client.is_connected(): + await client.disconnect() # ======================= if __name__ == '__main__': diff --git a/portable_install.bat b/portable_install.bat index deee269..583b04d 100644 --- a/portable_install.bat +++ b/portable_install.bat @@ -101,13 +101,11 @@ if exist %python_dir%\Lib\site-packages\sitecustomize.py ( echo import site echo import os echo import sys - echo project_root = os.path.abspath^(os.path.join^(os.getenv^('VIRTUAL_ENV'^), '..'^)^) - echo if project_root in sys.path: - echo sys.path.remove^(project_root^) - echo sys.prefix = os.getenv^('VIRTUAL_ENV'^) - echo sys.exec_prefix = os.getenv^('VIRTUAL_ENV'^) + echo virtual_env = os.getenv^('VIRTUAL_ENV'^) + echo sys.prefix = virtual_env + echo sys.exec_prefix = virtual_env echo def getsitepackages^(^): - echo return [os.path.join^(os.getenv^('VIRTUAL_ENV'^), 'Lib', 'site-packages'^)] + echo return [os.path.join^(virtual_env, 'Lib', 'site-packages'^)] echo site.getsitepackages = getsitepackages ) > "%python_dir%\Lib\site-packages\sitecustomize.py" :skip_sitecustomize @@ -156,7 +154,6 @@ echo [*] Создание %python_dir%\Scripts\activate.bat echo set "_OLD_VIRTUAL_PATH=%%PATH%%" echo ^) echo set "PATH=%%VIRTUAL_ENV%%;%%VIRTUAL_ENV%%\Scripts;%%PATH%%" - echo set "PYTHONPATH=%%VIRTUAL_ENV%%\Lib\site-packages" echo chcp %%_OLD_CODEPAGE%% ^> nul ) > %python_dir%\Scripts\activate.bat :skip_venv @@ -169,6 +166,7 @@ if exist run_me.bat ( ) echo [+] Создание run_me.bat ( + echo @echo off echo cd /d "%%~dp0%%" echo call %python_dir%\Scripts\activate.bat echo call python main.py diff --git a/portable_install.sh b/portable_install.sh new file mode 100644 index 0000000..d0dc176 --- /dev/null +++ b/portable_install.sh @@ -0,0 +1,146 @@ +#!/bin/bash + +# Настройки +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHON_DIR="$SCRIPT_DIR/python312" +PYTHON_URL="https://github.com/astral-sh/python-build-standalone/releases/download/20250612/cpython-3.12.11+20250612-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" +PYTHON_EXE="$PYTHON_DIR/bin/python3.12" + +# Цвета +GREEN='\033[32m' +YELLOW='\033[33m' +RED='\033[31m' +NC='\033[0m' + +echo_color() { + echo -e "${1}${2}${NC}" +} + +run_cmd() { + if ! eval "$1"; then + echo_color "$RED" "[-] Ошибка выполнения: $1" + exit 1 + fi +} + +# Проверка, установлен ли Python +if [[ -f "$PYTHON_EXE" ]]; then + echo_color "$GREEN" "[ ] Python уже установлен" + "$PYTHON_EXE" --version +else + echo_color "$YELLOW" "[*] Установка Python..." + + mkdir -p "$PYTHON_DIR" || { + echo_color "$RED" "[-] Ошибка создания папки $PYTHON_DIR" + exit 1 + } + + curl -L -o python.tar.gz "$PYTHON_URL" || { + echo_color "$RED" "[-] Ошибка загрузки Python" + exit 1 + } + + tar --strip-components=1 -xzf python.tar.gz -C "$PYTHON_DIR" || { + echo_color "$RED" "[-] Ошибка распаковки" + rm -f python.tar.gz + exit 1 + } + + rm -f python.tar.gz + + # Ссылка для удобства + ln -sf python3.12 "$PYTHON_DIR/bin/python" + + echo_color "$GREEN" "[+] Python установлен" + + # Установка pip + echo_color "$YELLOW" "[*] Установка pip" + curl -L -o get-pip.py 'https://bootstrap.pypa.io/get-pip.py' || { + echo_color "$RED" "[-] Не удалось скачать get-pip.py" + exit 1 + } + + "$PYTHON_EXE" get-pip.py --no-warn-script-location || { + echo_color "$RED" "[-] Ошибка установки pip" + rm -f get-pip.py + exit 1 + } + + rm -f get-pip.py + + # Обновление базовых пакетов + echo_color "$YELLOW" "[*] Обновление pip, setuptools, wheel" + "$PYTHON_EXE" -m pip install --upgrade pip setuptools wheel --no-cache-dir || { + echo_color "$RED" "[-] Ошибка обновления" + exit 1 + } + + # Установка requirements.txt + if [[ -f "$SCRIPT_DIR/requirements.txt" ]]; then + echo_color "$YELLOW" "[*] Установка зависимостей" + "$PYTHON_EXE" -m pip install -r "$SCRIPT_DIR/requirements.txt" --no-cache-dir || { + echo_color "$RED" "[-] Ошибка при установке пакетов" + exit 1 + } + fi +fi + +# === Создание run_me.sh (без запуска!) === +RUN_ME="$SCRIPT_DIR/run_me.sh" +if [[ -f "$RUN_ME" ]]; then + echo_color "$GREEN" "[ ] run_me.sh уже существует" +else + echo_color "$YELLOW" "[+] Создание run_me.sh" + cat > "$RUN_ME" << 'EOF' +#!/bin/bash +cd "$(dirname "$0")" +source python312/bin/activate +python main.py +EOF + chmod +x "$RUN_ME" + echo_color "$GREEN" "[✔] run_me.sh создан" +fi + +# === Создание activate и pyvenv.cfg === +ACTIVATE_SCRIPT="$PYTHON_DIR/bin/activate" +PYVENV_CFG="$PYTHON_DIR/pyvenv.cfg" +SITE_PACKAGES="$PYTHON_DIR/lib/python3.12/site-packages" + +mkdir -p "$PYTHON_DIR/bin" "$SITE_PACKAGES" + +# sitecustomize.py +cat > "$SITE_PACKAGES/sitecustomize.py" << 'EOF' +import site +import os +import sys +virtual_env = os.getenv("VIRTUAL_ENV") +if virtual_env: + sys.prefix = virtual_env + sys.exec_prefix = virtual_env + def getsitepackages(): + return [os.path.join(virtual_env, "lib", "python3.12", "site-packages")] + site.getsitepackages = getsitepackages +EOF + +# pyvenv.cfg +cat > "$PYVENV_CFG" << EOF +home = $SCRIPT_DIR/python312 +include-system-site-packages = false +version = 3.12.11 +executable = $PYTHON_DIR/bin/python3.12 +command = $PYTHON_DIR/bin/python3.12 +EOF + +# activate +cat > "$ACTIVATE_SCRIPT" << 'EOF' +#!/bin/bash +export VIRTUAL_ENV="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export VIRTUAL_ENV_PROMPT=$(basename "$VIRTUAL_ENV") +export _OLD_VIRTUAL_PATH="$PATH" +export PATH="$VIRTUAL_ENV/bin:$PATH" +unset PYTHONHOME +export PS1="($VIRTUAL_ENV_PROMPT) $PS1" +EOF +chmod +x "$ACTIVATE_SCRIPT" + +echo_color "$GREEN" "[✔️] Установка завершена. Запуск через rum-me.sh" \ No newline at end of file