Initial commit: Batch Bot - Telegram Comment Bot

0.0.1
Features:
- Multi-account support via session files
- AI comments generation via Ollama (local LLM)
- Telegram bot for moderation (approve/reject/regenerate)
- Docker support (controller + worker)
- Auto-join public groups
- Comment regeneration on group re-add
- Statistics tracking

Tech stack:
- Python 3.11
- Telethon 1.34 (Telegram user client)
- Aiogram 3.4 (Telegram bot framework)
- SQLite (Database)
- Docker & Docker Compose
- Ollama (Local LLM)
This commit is contained in:
2026-02-24 04:40:07 +03:00
commit a18ad30961
20 changed files with 3431 additions and 0 deletions

405
bot/worker.py Normal file
View File

@@ -0,0 +1,405 @@
import asyncio
import logging
import random
import sqlite3
from datetime import datetime, timedelta
from telethon import TelegramClient, events
from telethon.tl.types import PeerChannel
from bot.config import (
API_ID, API_HASH, LOG_GROUP_ID,
INITIAL_SCAN_LIMIT, COMMENT_DELAY_MIN, COMMENT_DELAY_MAX
)
from bot.db import (
init_db, save_comment, get_comment, get_comments_for_post,
update_comment_status, update_comment_sent, get_active_sessions,
update_stats, get_pending_comments, get_target_groups, is_target_group, DB_PATH
)
from bot.ollama import generate_comment
from bot.session_manager import session_manager
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('worker')
class CommentWorker:
"""Воркер для отправки комментариев от имени пользователей"""
def __init__(self):
self.clients: dict[str, TelegramClient] = {}
self.running = False
async def start(self):
"""Запуск воркера"""
logger.info("Запуск Comment Worker...")
# Инициализация БД
init_db()
# Подключение сессий
self.clients = await session_manager.create_all_clients()
logger.info(f"Подключено сессий: {len(self.clients)}")
if not self.clients:
logger.warning("Нет активных сессий. Добавьте файлы в sessions/")
return
self.running = True
# Запускаем задачи параллельно
await asyncio.gather(
self.listen_for_new_posts(),
self.monitor_approved_comments()
)
async def stop(self):
"""Остановка воркера"""
logger.info("Остановка Comment Worker...")
self.running = False
await session_manager.disconnect_all()
async def scan_previous_messages(self):
"""Сканирование предыдущих сообщений"""
logger.info(f"Сканирование {INITIAL_SCAN_LIMIT} сообщений...")
# Получаем список целевых групп из БД
target_groups = get_target_groups()
if not target_groups:
logger.warning("Нет групп в БД! Добавьте группу через бота: /add_group ID")
return
logger.info(f"Мониторинг групп: {[g['group_id'] for g in target_groups]}")
for group in target_groups:
group_id = group['group_id']
for session_file, client in self.clients.items():
try:
target_group = await client.get_entity(PeerChannel(abs(int(group_id))))
messages = await client.get_messages(target_group, limit=INITIAL_SCAN_LIMIT)
logger.info(f"Сессия {session_file}: получено {len(messages)} сообщений из группы {group_id}")
for message in reversed(messages):
if not self.running:
break
await self.process_message(message, session_file, client, group_id)
await asyncio.sleep(0.5)
except Exception as e:
logger.error(f"Ошибка сканирования для {session_file} в группе {group_id}: {e}")
async def process_message(self, message, session_file: str, client: TelegramClient, group_id: int = None, comments_group_id: int = None):
"""Обработка сообщения"""
try:
if not message.text or len(message.text) < 10:
return
if not message.replies or not message.replies.comments:
logger.info(f"Сообщение {message.id} не поддерживает комментарии")
return
msg_comments_group_id = message.replies.channel_id
if not msg_comments_group_id:
msg_comments_group_id = comments_group_id
if not msg_comments_group_id:
logger.error(f"Не удалось получить ID группы комментариев для {message.id}")
return
existing = get_comment(message.id, msg_comments_group_id, session_file)
if existing:
return
from telethon.tl.functions.channels import GetFullChannelRequest
channel_id = None
try:
comments_group = await client.get_entity(PeerChannel(abs(int(msg_comments_group_id))))
channel_full = await client(GetFullChannelRequest(comments_group))
if channel_full.full_chat.linked_chat_id:
channel_id = abs(int(channel_full.full_chat.linked_chat_id))
logger.info(f"Linked chat для {msg_comments_group_id}: {channel_id}")
except Exception as e:
logger.warning(f"Не удалось получить linked_chat: {e}")
channel_id = group_id
logger.info(f"Генерация комментария для сообщения {message.id} ({session_file})")
comment_text = await generate_comment(message.text)
if not comment_text:
logger.error(f"Не удалось сгенерировать комментарий для {message.id}")
return
save_comment(
message.id,
msg_comments_group_id,
comment_text,
session_file,
message.text,
channel_id
)
update_stats(
datetime.now().strftime('%Y-%m-%d'),
session_file,
'generated'
)
logger.info(f"Комментарий сохранён: message_id={message.id}, session={session_file}")
except Exception as e:
logger.error(f"Ошибка обработки сообщения: {e}")
async def listen_for_new_posts(self):
"""Прослушивание новых постов с авто-обновлением списка групп"""
logger.info("Запуск прослушивания новых постов...")
last_groups_check = 0
group_handlers = {}
scanned_groups = set()
while self.running:
current_time = datetime.now().timestamp()
if current_time - last_groups_check > 10:
target_groups = get_target_groups()
new_group_ids = [str(g['group_id']) for g in target_groups]
# Проверяем, есть ли группы которые были удалены и добавлены заново
for group_id in new_group_ids:
if group_id not in group_handlers:
logger.info(f"Добавлена группа для мониторинга: {group_id}")
# Сканируем последние 10 сообщений новой группы
# Всегда сканируем при добавлении, даже если уже было
await self.scan_group(group_id, limit=10)
scanned_groups.add(group_id)
for session_file, client in self.clients.items():
@client.on(events.NewMessage(chats=group_id))
async def handle_new_post(event):
message = event.message
logger.info(f"Новый пост в группе {group_id}: {message.id}")
await self.process_message(message, session_file, client, group_id)
group_handlers[group_id] = True
else:
# Группа уже обрабатывается, но проверяем не была ли она удалена и добавлена снова
# Если была - очищаем scanned_groups для этой группы
if group_id in scanned_groups:
# Проверяем что группа всё ещё в БД
group_exists = any(str(g['group_id']) == group_id for g in target_groups)
if not group_exists:
scanned_groups.discard(group_id)
del group_handlers[group_id]
logger.info(f"Группа {group_id} удалена, сброс кэша")
# Удаляем обработчики для удалённых групп
for group_id in list(group_handlers.keys()):
if group_id not in new_group_ids:
del group_handlers[group_id]
scanned_groups.discard(group_id)
logger.info(f"Группа {group_id} удалена из мониторинга")
last_groups_check = current_time
await asyncio.sleep(5)
async def scan_group(self, group_id: int | str, limit: int = 10):
"""Сканирование последней сообщений из группы/канала"""
logger.info(f"Сканирование {limit} сообщений из группы {group_id}...")
for session_file, client in self.clients.items():
try:
target_channel = None
comments_group_id = None
try:
target_channel = await client.get_entity(group_id)
except:
async for dialog in client.iter_dialogs():
dialog_id = dialog.id
dialog_username = getattr(dialog.entity, 'username', None) if hasattr(dialog, 'entity') and dialog.entity else None
if str(dialog_id) == str(group_id) or dialog_username == str(group_id).lstrip('@'):
target_channel = dialog.entity
break
if not target_channel:
logger.error(f"Не удалось найти группу/канал {group_id}")
continue
group_name = getattr(target_channel, 'title', None)
group_username = getattr(target_channel, 'username', None)
from telethon.tl.functions.channels import GetFullChannelRequest
try:
channel_full = await client(GetFullChannelRequest(target_channel))
if hasattr(channel_full.full_chat, 'linked_chat_id') and channel_full.full_chat.linked_chat_id:
comments_group_id = channel_full.full_chat.linked_chat_id
logger.info(f"Канал {group_id} имеет группу комментариев: {comments_group_id}")
try:
comments_group = await client.get_entity(comments_group_id)
if getattr(comments_group, 'username', None):
await session_manager.join_channel(session_file, comments_group_id)
logger.info(f"Вступил в группу комментариев: {comments_group_id}")
except Exception as e:
logger.warning(f"Не удалось вступить в группу комментариев: {e}")
except Exception as e:
logger.warning(f"Не удалось получить linked_chat: {e}")
from bot.db import add_target_group
add_target_group(group_id, group_name, group_username, 'channel', comments_group_id)
logger.info(f"Обновлена информация: {group_name}, comments_group={comments_group_id}")
messages = await client.get_messages(target_channel, limit=limit)
logger.info(f"Сессия {session_file}: получено {len(messages)} сообщений")
for message in reversed(messages):
if not self.running:
break
await self.process_message(message, session_file, client, group_id, comments_group_id)
await asyncio.sleep(0.5)
except Exception as e:
logger.error(f"Ошибка сканирования для {session_file} в группе {group_id}: {e}")
async def monitor_approved_comments(self):
"""Мониторинг одобренных комментариев"""
logger.info("Запуск мониторинга одобренных комментариев...")
while self.running:
try:
conn = None
try:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('''
SELECT * FROM comments
WHERE status = 'approved' AND sent_message_id IS NULL
ORDER BY created_at
LIMIT 10
''')
comments = [dict(row) for row in cursor.fetchall()]
finally:
if conn:
conn.close()
if comments:
logger.info(f"Найдено {len(comments)} одобренных комментариев для отправки")
for comment in comments:
if not self.running:
break
await self.send_comment(comment)
await asyncio.sleep(2)
else:
logger.debug("Нет комментариев для отправки")
await asyncio.sleep(10)
except Exception as e:
logger.error(f"Ошибка мониторинга: {e}")
await asyncio.sleep(10)
async def send_comment(self, comment: dict):
"""Отправка комментария"""
session_file = comment.get('session_file')
if not session_file:
logger.error(f"Нет session_file для комментария {comment['id']}")
return
client = self.clients.get(session_file)
if not client:
client = await session_manager.get_client(session_file)
if not client:
logger.error(f"Не удалось подключить сессию {session_file}")
update_comment_status(comment['id'], 'rejected')
return
try:
comments_group_id = abs(int(comment['chat_id']))
message_id_in_channel = comment['message_id']
channel_id = comment.get('channel_id')
logger.info(f"Отправка комментария {comment['id']}: message_id={message_id_in_channel}, comments_group={comments_group_id}, channel={channel_id}")
comments_group = await client.get_entity(PeerChannel(comments_group_id))
logger.info(f"Группа комментариев: {comments_group.title} (ID: {comments_group_id})")
from telethon.tl.functions.channels import GetFullChannelRequest
channel_full = await client(GetFullChannelRequest(comments_group))
linked_chat_id = channel_full.full_chat.linked_chat_id
if not linked_chat_id:
logger.error("Не удалось найти связанный канал")
return
logger.info(f"Linked chat ID: {linked_chat_id}")
# Получаем сообщение в КАНАЛЕ
target_message_in_channel = await client.get_messages(
PeerChannel(abs(int(linked_chat_id))),
ids=message_id_in_channel
)
if not target_message_in_channel:
logger.error(f"Сообщение {message_id_in_channel} не найдено в канале {linked_chat_id}")
return
logger.info(f"Найдено сообщение в канале: {target_message_in_channel.id}")
# Вступаем в группу комментариев (если ещё не вступили)
# Это требуется для некоторых каналов
try:
await session_manager.join_channel(session_file, comments_group_id)
logger.info(f"Вступил в группу комментариев: {comments_group_id}")
except Exception as e:
logger.warning(f"Не удалось вступить в группу комментариев: {e}")
# ОТПРАВЛЯЕМ В КАНАЛ с comment_to= (как в старом проекте)
# Telegram автоматически направит комментарий в группу комментариев
delay = random.uniform(COMMENT_DELAY_MIN, COMMENT_DELAY_MAX)
logger.info(f"Задержка {delay:.1f}с...")
await asyncio.sleep(delay)
sent_message = await client.send_message(
PeerChannel(abs(int(linked_chat_id))), # КАНАЛ
comment['comment_text'],
comment_to=target_message_in_channel.id # ID в КАНАЛЕ
)
logger.info(f"✅ Отправлен в канал как comment_to {target_message_in_channel.id} -> {sent_message.id}")
logger.info(f"Комментарий отправлен: {comment['id']} -> message {sent_message.id}")
update_comment_sent(comment['id'], sent_message.id)
update_stats(datetime.now().strftime('%Y-%m-%d'), session_file, 'sent')
except Exception as e:
logger.error(f"Ошибка отправки комментария: {e}")
update_comment_status(comment['id'], 'pending')
async def main():
"""Точка входа"""
worker = CommentWorker()
try:
await worker.start()
except KeyboardInterrupt:
await worker.stop()
except Exception as e:
logger.error(f"Критическая ошибка: {e}")
await worker.stop()
if __name__ == '__main__':
asyncio.run(main())