Более точное поведение при пересылке.

* другой алгоритм отслеживания изменённых сообщений.
По событиям вместо частых запросов.
* add: portable-install.sh
* add: docker-compose.yml
This commit is contained in:
Евгений Панков 2025-08-15 12:54:03 +03:00
parent 2bfa13b023
commit 42a13742cf
7 changed files with 492 additions and 145 deletions

4
.gitignore vendored
View File

@ -1,3 +1,5 @@
.env .env
venv* venv*
python* python*
*.json
*.session*

31
README.md Normal file
View File

@ -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.

26
docker-compose.yml Normal file
View File

@ -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

View File

@ -8,9 +8,9 @@ print(f'Использую: {env}')
load_dotenv(env) load_dotenv(env)
# === Данные юзер-бота === # === Данные юзер-бота ===
api_id=26507458 api_id=os.getenv('api_id')
api_hash='9bf31965a06209eadd1995cec266d3ae' api_hash=os.getenv('api_hash')
session_name='Joshua_session' session_name=os.getenv('session_name') or 'userbot'
client = TelegramClient(session_name, api_id, api_hash) client = TelegramClient(session_name, api_id, api_hash)
@ -19,6 +19,7 @@ async def get_group_info():
groups = await client.get_dialogs() groups = await client.get_dialogs()
for dialog in groups: for dialog in groups:
if dialog.is_group or dialog.is_channel: 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()) client.loop.run_until_complete(get_group_info())

409
main.py
View File

@ -1,6 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# from __future__ import annotations
from telethon import TelegramClient, events from telethon import TelegramClient, events
from telethon.tl.types import PeerUser, PeerChat, PeerChannel, Message
import re import re
import os import os
import json import json
@ -8,17 +10,14 @@ import logging
import asyncio import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
from dotenv import find_dotenv, load_dotenv 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 = 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() env = find_dotenv()
@ -28,19 +27,30 @@ load_dotenv(env)
# === Данные юзер-бота === # === Данные юзер-бота ===
api_id=os.getenv('api_id') api_id=os.getenv('api_id')
api_hash=os.getenv('api_hash') 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') # Канал, который слушаем 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=os.getenv('filter_keywords') # Строки или регулярное выражение для поиска
filter_keywords=filter_keywords.split(',') filter_keywords=filter_keywords.split(',')
filter_negative_keywords=os.getenv('filter_negative_keywords') # Строки или регулярное выражение для поиска filter_negative_keywords=os.getenv('filter_negative_keywords') # Строки или регулярное выражение для поиска
filter_negative_keywords=filter_negative_keywords.split(',') filter_negative_keywords=filter_negative_keywords.split(',')
DATA_FILE=os.getenv('DATA_FILE') # Файл для сохранения сообщений DATA_FILE=os.getenv('DATA_FILE') # Файл для сохранения сообщений
MAX_MESSAGES=int(os.getenv('MAX_MESSAGES')) # Количество хранящихся сообщений if not DATA_FILE:
CHECK_INTERVAL=int(os.getenv('CHECK_INTERVAL')) # Периодичность проверки удалений на канале 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): if os.path.exists(DATA_FILE):
with open(DATA_FILE, 'r', encoding='utf-8') as f: with open(DATA_FILE, 'r', encoding='utf-8') as f:
return json.load(f) return json.load(f)
return {} return [{},{}]
def save_state(data): def save_state():
with open(DATA_FILE, 'w', encoding='utf-8') as f: 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: 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 f'{text[:length-1]}'
return text 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)) @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 "" message_text = event.message.text or ""
msg_id = event.message.id last_known_msg_id[source_channel_username] = event.message.id
if message_text: if message_text:
log.info(f"Новое сообщение: {shorten(message_text)}") log.info(f"Новое сообщение: {shorten(message_text)}")
# Проверяем само сообщение # Проверяем само сообщение
if any(re.search(keyword, message_text, re.IGNORECASE) for keyword in filter_keywords) \ if check(message_text):
and not any(re.search(keyword, message_text, re.IGNORECASE) for keyword in filter_negative_keywords): await forward_to_targets(event.message)
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)
log.info(f"📩 Переслано: {shorten(message_text)}") log.info(f"📩 Переслано: {shorten(message_text)}")
return return
# Проверяем цепочку ответов # Проверяем цепочку ответов
reply = event.message.reply_to reply = event.message.reply_to
if reply and reply.reply_to_msg_id: if reply and reply.reply_to_msg_id:
founded = await check_reply_chain(event, reply.reply_to_msg_id, depth=1) source_reply_to_msg_id = await check_reply_chain(reply.reply_to_msg_id, depth=1)
if founded: if source_reply_to_msg_id:
await client.forward_messages(target_group_chat_id, event.message) await forward_to_targets(event.message, source_reply_to_msg_id=source_reply_to_msg_id)
log.info(f"⛓ Переслано по цепочке: {shorten(message_text)} | Глубина: {founded}") log.info(f"⛓ Переслано по цепочке: {shorten(message_text)}")
# ==== Фоновая задача: проверка удалений и изменений ==== @client.on(events.MessageEdited(chats=source_channel_username))
async def monitor_messages(): 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: while True:
await asyncio.sleep(CHECK_INTERVAL) await asyncio.sleep(600) # раз в 10 минут
now = datetime.now().timestamp() now = datetime.now().timestamp()
cutoff_time = now - 86400 # 24 часа cutoff_time = now - 86400 # 24 час
start_len = len(source_messages)
for str_msg_id, stored_m_info in list(source_messages.items()): for str_msg_id, stored_m_info in tuple(source_messages.items()):
msg_id = int(str_msg_id)
# Удаление по возрасту
if stored_m_info['timestamp'] < cutoff_time: if stored_m_info['timestamp'] < cutoff_time:
del source_messages[str_msg_id] del source_messages[str_msg_id]
continue if start_len != len(source_messages):
log.debug(f'Очищены старые сообщения: {start_len}{len(source_messages)}')
# Получаем данные о пересланном сообщении save_state()
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)
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: if depth > max_depth:
log.warning(f"🔁 Превышена максимальная глубина цепочки ({max_depth}), остановлено.") log.warning(f"🔁 Превышена максимальная глубина цепочки ({max_depth}), остановлено.")
return False return False
try: try:
replied_msg = await event.get_reply_message() replied_msg = await client.get_messages(source_channel_username, ids=msg_id)
except Exception as e: except Exception as e:
log.warning(f"Не удалось получить сообщение по ID {msg_id}: {e}") log.warning(f"Не удалось получить сообщение по ID {msg_id}: {e}")
return False 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 "" text = replied_msg.text or ""
log.info(f"🔍 Проверяю сообщение на уровне {depth}: {shorten(text,30)}") if check(text):
log.info(f"✔️ Есть совпадение на уровне {depth}: {shorten(text,30)}")
if any(re.search(keyword, text, re.IGNORECASE) for keyword in filter_keywords) \ return replied_msg.id
and not any(re.search(keyword, text, re.IGNORECASE) for keyword in filter_negative_keywords): else:
return depth log.info(f"🔍 Проверяю сообщение на уровне {depth}: {shorten(text,30)}")
if replied_msg.reply_to: 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 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): async def del_join_leave(event: events):
actions = { actions = {
'добавлен': event.user_added, 'добавлен': event.user_added,
@ -227,16 +352,15 @@ async def del_join_leave(event: events):
action = ', '.join(action) action = ', '.join(action)
full_name = ' '.join([name for name in (event.user.first_name, event.user.last_name) if name]) full_name = ' '.join([name for name in (event.user.first_name, event.user.last_name) if name])
await event.delete() 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]: async def fetch_system_messages(days_to_check=5) -> tuple[int, int]:
"""Собирает системные сообщения за последние N дней""" """Собирает системные сообщения за последние N дней"""
system_messages = [] system_messages = []
for chat_id in groups_clean_srv_msgs:
async with client:
try: try:
chat = await client.get_entity(target_group_chat_id) chat = await client.get_entity(chat_id)
except ValueError: except ValueError:
log.info("Не удалось найти чат. Убедитесь, что вы состоите в группе.") log.info("Не удалось найти чат. Убедитесь, что вы состоите в группе.")
return system_messages 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)) system_messages.append((chat.id, message.id))
log.info(f"✅ Найдено {len(system_messages)} системных сообщений для удаления") log.info(f"✅ Найдено {len(system_messages)} системных сообщений для удаления")
return system_messages return system_messages
@ -279,7 +404,6 @@ async def delete_system_msgs(messages_list):
# ==== Запуск бота ==== # ==== Запуск бота ====
async def main(): async def main():
await client.start()
# log.info("🧹 Начинаем очистку старых системных сообщений…") # log.info("🧹 Начинаем очистку старых системных сообщений…")
# messages = await fetch_system_messages(days_to_check=5) # messages = await fetch_system_messages(days_to_check=5)
@ -287,11 +411,30 @@ async def main():
# if not client.is_connected(): # if not client.is_connected():
# log.info("🔄 Клиент не подключён, пытаемся восстановить соединение…") # log.info("🔄 Клиент не подключён, пытаемся восстановить соединение…")
# await client.connect() # await client.connect()
asyncio.create_task(monitor_messages()) asyncio.create_task(clear_old_messages())
try:
log.info("😊 Бот запущен. Ожидание событий…") await client.start()
await client.run_until_disconnected() 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__': if __name__ == '__main__':

View File

@ -101,13 +101,11 @@ if exist %python_dir%\Lib\site-packages\sitecustomize.py (
echo import site echo import site
echo import os echo import os
echo import sys echo import sys
echo project_root = os.path.abspath^(os.path.join^(os.getenv^('VIRTUAL_ENV'^), '..'^)^) echo virtual_env = os.getenv^('VIRTUAL_ENV'^)
echo if project_root in sys.path: echo sys.prefix = virtual_env
echo sys.path.remove^(project_root^) echo sys.exec_prefix = virtual_env
echo sys.prefix = os.getenv^('VIRTUAL_ENV'^)
echo sys.exec_prefix = os.getenv^('VIRTUAL_ENV'^)
echo def getsitepackages^(^): 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 echo site.getsitepackages = getsitepackages
) > "%python_dir%\Lib\site-packages\sitecustomize.py" ) > "%python_dir%\Lib\site-packages\sitecustomize.py"
:skip_sitecustomize :skip_sitecustomize
@ -156,7 +154,6 @@ echo [*] Создание %python_dir%\Scripts\activate.bat
echo set "_OLD_VIRTUAL_PATH=%%PATH%%" echo set "_OLD_VIRTUAL_PATH=%%PATH%%"
echo ^) echo ^)
echo set "PATH=%%VIRTUAL_ENV%%;%%VIRTUAL_ENV%%\Scripts;%%PATH%%" echo set "PATH=%%VIRTUAL_ENV%%;%%VIRTUAL_ENV%%\Scripts;%%PATH%%"
echo set "PYTHONPATH=%%VIRTUAL_ENV%%\Lib\site-packages"
echo chcp %%_OLD_CODEPAGE%% ^> nul echo chcp %%_OLD_CODEPAGE%% ^> nul
) > %python_dir%\Scripts\activate.bat ) > %python_dir%\Scripts\activate.bat
:skip_venv :skip_venv
@ -169,6 +166,7 @@ if exist run_me.bat (
) )
echo [+] Создание run_me.bat echo [+] Создание run_me.bat
( (
echo @echo off
echo cd /d "%%~dp0%%" echo cd /d "%%~dp0%%"
echo call %python_dir%\Scripts\activate.bat echo call %python_dir%\Scripts\activate.bat
echo call python main.py echo call python main.py

146
portable_install.sh Normal file
View File

@ -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"