Более точное поведение при пересылке.
* другой алгоритм отслеживания изменённых сообщений. По событиям вместо частых запросов. * add: portable-install.sh * add: docker-compose.yml
This commit is contained in:
parent
2bfa13b023
commit
e5a37f33e5
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1,5 @@
|
||||
.env
|
||||
venv*
|
||||
python*
|
||||
*.json
|
||||
*.session
|
||||
|
||||
31
README.md
Normal file
31
README.md
Normal 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.
|
||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
@ -0,0 +1,29 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: debian:bookworm-slim
|
||||
container_name: alert-telethon-app
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app # весь проект
|
||||
- ./userbot_session.session:/app/userbot_session.session # сессия (только файл)
|
||||
env_file:
|
||||
- .env # загружаем переменные
|
||||
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
|
||||
@ -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())
|
||||
391
main.py
391
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():
|
||||
while True:
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
|
||||
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)
|
||||
|
||||
# Удаление по возрасту
|
||||
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)}")
|
||||
@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)}")
|
||||
# Редактируем пересланное сообщение
|
||||
try:
|
||||
for str_chat_id, msg_id in stored_m_info['forwarded_msg_id'].items():
|
||||
chat_id = int(str_chat_id)
|
||||
# Получаем 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
|
||||
|
||||
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 target_msg.id == last_msg_id:
|
||||
if 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("🔁 Сообщение было последним → Заменено")
|
||||
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(
|
||||
target_group_chat_id,
|
||||
f"🔄 Текст изменён:\n{source_msg.text}",
|
||||
reply_to=stored_m_info['forwarded_msg_id']
|
||||
chat_id,
|
||||
f"🔄 Текст изменён:\n{event.message.text}",
|
||||
reply_to=stored_m_info['forwarded_msg_id'][str_chat_id]
|
||||
)
|
||||
source_messages[str(source_msg.id)] = {
|
||||
'text': source_msg.text,
|
||||
'timestamp': datetime.now().timestamp(),
|
||||
'forwarded_msg_id': new_msg.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}
|
||||
}
|
||||
log.info("💬 Сообщение не последнее → Отправлен ответ")
|
||||
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"❌ Не удалось обновить: {e}")
|
||||
|
||||
# Сохраняем состояние
|
||||
save_state(source_messages)
|
||||
log.error(f"⚠ Ошибка при получении batch-сообщений: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def check_reply_chain(event, msg_id, depth=1, max_depth=10) -> bool|int:
|
||||
"""Рекурсивная проверка цепочки ответов"""
|
||||
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(600) # раз в 10 минут
|
||||
now = datetime.now().timestamp()
|
||||
start_len = len(source_messages)
|
||||
for str_msg_id, stored_m_info in tuple(source_messages.items()):
|
||||
cutoff_time = now - 86400 # 24 час
|
||||
if stored_m_info['timestamp'] < cutoff_time:
|
||||
del source_messages[str_msg_id]
|
||||
if start_len != len(source_messages):
|
||||
log.debug(f'Очищены старые сообщения: {start_len} → {len(source_messages)}')
|
||||
save_state()
|
||||
|
||||
|
||||
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 ""
|
||||
|
||||
if check(text):
|
||||
log.info(f"✔️ Есть совпадение на уровне {depth}: {shorten(text,30)}")
|
||||
return replied_msg.id
|
||||
else:
|
||||
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 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)
|
||||
@ -288,10 +412,29 @@ async def main():
|
||||
# log.info("🔄 Клиент не подключён, пытаемся восстановить соединение…")
|
||||
# await client.connect()
|
||||
|
||||
asyncio.create_task(monitor_messages())
|
||||
|
||||
log.info("😊 Бот запущен. Ожидание событий…")
|
||||
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__':
|
||||
|
||||
@ -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
|
||||
|
||||
146
portable_install.sh
Normal file
146
portable_install.sh
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user