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

1
bot/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Bot package

57
bot/config.py Normal file
View File

@@ -0,0 +1,57 @@
import os
from pathlib import Path
from dotenv import load_dotenv
# Загрузка переменных окружения
load_dotenv()
# Пути
BASE_DIR = Path(__file__).parent.parent # Корень проекта (batch-bot/)
DATA_DIR = BASE_DIR / "data" # Примонтированная директория для данных
SESSIONS_DIR = BASE_DIR / "sessions"
LOGS_DIR = DATA_DIR / "logs"
DB_PATH = DATA_DIR / "comments.db"
# Telegram Bot (контроллер)
BOT_TOKEN = os.getenv("BOT_TOKEN")
# Telegram API (воркеры)
API_ID = int(os.getenv("TELEGRAM_API_ID", "0"))
API_HASH = os.getenv("TELEGRAM_API_HASH", "")
# Группы (добавляются через бота)
LOG_GROUP_ID = int(os.getenv("LOG_GROUP_ID", "0"))
# Ollama
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434").rstrip("/")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3:30b-a3b")
PROMPT_FILE = os.getenv("PROMPT_FILE", "prompt.txt")
# Сканирование
INITIAL_SCAN_LIMIT = int(os.getenv("INITIAL_SCAN_LIMIT", "20"))
# Комментарии
MAX_REGENERATIONS = int(os.getenv("MAX_REGENERATIONS", "3"))
COMMENT_DELAY_MIN = int(os.getenv("COMMENT_DELAY_MIN", "1"))
COMMENT_DELAY_MAX = int(os.getenv("COMMENT_DELAY_MAX", "5"))
# Админы
ADMIN_IDS = [int(x.strip()) for x in os.getenv("ADMIN_IDS", "").split(",") if x.strip()]
# Проверка конфигурации
def validate_config():
errors = []
if not BOT_TOKEN:
errors.append("BOT_TOKEN не задан")
if not API_ID or not API_HASH:
errors.append("TELEGRAM_API_ID или TELEGRAM_API_HASH не заданы")
if not LOG_GROUP_ID:
errors.append("LOG_GROUP_ID не задан")
if not ADMIN_IDS:
errors.append("ADMIN_IDS не заданы")
return errors

909
bot/controller.py Normal file
View File

@@ -0,0 +1,909 @@
import asyncio
import logging
import sqlite3
from datetime import datetime
from aiogram import Bot, Dispatcher, F
from aiogram.filters import Command, CommandStart
from aiogram.types import CallbackQuery, Message
from bot.config import BOT_TOKEN, ADMIN_IDS, LOG_GROUP_ID, INITIAL_SCAN_LIMIT, DB_PATH
from bot.db import (
init_db, get_pending_comments, update_comment_status,
save_comment, get_comment, get_comments_for_post,
increment_regeneration, get_all_sessions, get_active_sessions,
toggle_session, delete_session, get_summary_stats, get_stats,
update_stats, add_target_group, get_target_groups, get_all_target_groups,
remove_target_group, toggle_target_group, is_target_group
)
from bot.ollama import generate_comment
from bot.keyboard import (
create_moderation_keyboard, create_main_menu, create_main_keyboard,
create_session_keyboard, create_edit_cancel_keyboard,
create_groups_list_keyboard, create_group_action_keyboard
)
from bot.session_manager import session_manager
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('controller')
class CommentBot:
"""Контроллер бота для модерации комментариев"""
def __init__(self):
if not BOT_TOKEN:
raise ValueError("BOT_TOKEN не задан в .env")
self.bot = Bot(token=BOT_TOKEN)
self.dp = Dispatcher()
self.editing_comments: dict[int, dict] = {} # chat_id: {message_id, comment_data}
self._register_handlers()
def _register_handlers(self):
"""Регистрация обработчиков"""
self.dp.message(CommandStart())(self.cmd_start)
self.dp.message(Command("stats"))(self.cmd_stats)
self.dp.message(Command("pending"))(self.cmd_pending)
self.dp.message(Command("sessions"))(self.cmd_sessions)
self.dp.message(Command("groups"))(self.cmd_groups)
self.dp.message(Command("add_group"))(self.cmd_add_group)
self.dp.message(Command("help"))(self.cmd_help)
self.dp.message(F.text)(self.handle_text_message)
self.dp.callback_query()(self.handle_callback)
# Состояния для добавления группы
self.add_group_state: dict = {} # user_id: {'waiting': True}
async def cmd_start(self, message: Message):
"""Обработчик команды /start"""
if not self._is_admin(message.from_user.id):
await message.answer("У вас нет доступа к управлению ботом")
return
# Получаем список групп
groups = get_target_groups()
groups_text = "\n".join([f"• `{g['group_id']}`" for g in groups]) if groups else "Нет добавленных групп"
await message.answer(
f"🤖 **Bot Controller**\n\n"
f"📋 Группы:\n{groups_text}\n\n"
f"Используйте меню для управления:",
reply_markup=create_main_keyboard())
async def cmd_stats(self, message: Message):
"""Обработчик команды /stats"""
if not self._is_admin(message.from_user.id):
return
summary = get_summary_stats()
sessions = get_active_sessions()
text = (
"📊 **Статистика**\n\n"
f"📝 Всего комментариев: `{summary.get('total_comments', 0)}`\n"
f"⏳ Ожидают: `{summary.get('pending', 0)}`\n"
f"✅ Одобрено: `{summary.get('approved', 0)}`\n"
f"❌ Отклонено: `{summary.get('rejected', 0)}`\n"
f"📤 Отправлено: `{summary.get('sent', 0)}`\n\n"
f"👥 Активных сессий: `{len(sessions)}`"
)
await message.answer(text)
async def cmd_pending(self, message: Message):
"""Обработчик команды /pending"""
if not self._is_admin(message.from_user.id):
return
pending = get_pending_comments()
if not pending:
await message.answer("✅ Нет ожидающих комментариев")
return
await message.answer(f"📝 Ожидают модерации: {len(pending)}")
# Показываем первые 5
for comment in pending[:5]:
await self._send_moderation_message(
chat_id=message.chat.id,
comment=comment
)
async def cmd_sessions(self, message: Message):
"""Обработчик команды /sessions"""
if not self._is_admin(message.from_user.id):
return
sessions = get_all_sessions()
if not sessions:
await message.answer(
"❌ Нет активных сессий\n\n"
"Добавьте файлы сессий в папку `sessions/`"
)
return
text = "👥 **Сессии**\n\n"
for session in sessions:
status = "🟢" if session['is_active'] else "🔴"
username = f"@{session['username']}" if session['username'] else "Без username"
text += f"{status} `{session['session_file']}` — {username}\n"
await message.answer(text)
async def cmd_help(self, message: Message):
"""Обработчик команды /help"""
if not self._is_admin(message.from_user.id):
return
text = (
"📖 Справка\n\n"
"Кнопки меню:\n"
"📊 Статистика\n"
"👥 Сессии\n"
"📝 Ожидающие\n"
"📋 Группы\n"
" Добавить группу\n"
"❓ Помощь\n\n"
"Команды:\n"
"/start - Главное меню\n"
"/stats - Статистика\n"
"/pending - Ожидающие\n"
"/groups - Группы\n"
"/add_group ID - Добавить группу\n"
"/help - Справка"
)
await message.answer(text, reply_markup=create_main_keyboard())
async def handle_text_message(self, message: Message):
"""Обработчик текстовых сообщений (кнопки меню)"""
if not self._is_admin(message.from_user.id):
return
text = message.text.strip()
if text == "📊 Статистика":
await self.cmd_stats(message)
elif text == "👥 Сессии":
await self.cmd_sessions(message)
elif text == "📝 Ожидающие":
await self.cmd_pending(message)
elif text == "📋 Группы":
await self.cmd_groups(message)
elif text == " Добавить группу":
await message.answer(
"📝 Добавление группы\n\n"
"Отправьте ID группы или username:\n\n"
"Примеры:\n"
"@telegram (публичный канал)\n"
"1234567890 (ID)\n"
"-1001234567890 (ID канала)"
)
# Включаем состояние ожидания
self.add_group_state[message.from_user.id] = {'waiting': True}
elif text == "❓ Помощь":
await self.cmd_help(message)
else:
# Проверяем, не ждём ли мы ввод
if message.from_user.id in self.add_group_state:
# Пользователь отправил ID или username
await self._add_group_by_input(message, text)
del self.add_group_state[message.from_user.id]
else:
await message.answer(
"Неизвестная команда. Используйте меню:",
reply_markup=create_main_keyboard()
)
async def _add_group_by_username(self, message: Message, username: str):
"""Добавление группы по username"""
# Пробуем вступить всеми сессиями
join_results = await session_manager.join_all_sessions(username)
# Проверяем результаты
all_success = all('' in result for result in join_results.values())
if not all_success:
results_text = "\n".join([f"{k}: {v}" for k, v in join_results.items()])
await message.answer(
f"❌ Ошибка при вступлении в группу {username}\n\n"
f"Возможно группа приватная или не существует.\n\n"
f"Результаты:\n{results_text}"
)
return
# Получаем информацию
group_name = None
try:
chat = await self.bot.get_chat(username)
group_name = chat.title
except:
pass
# Добавляем в БД (username без @)
clean_username = username.lstrip('@')
if add_target_group(clean_username, group_name, clean_username):
await message.answer(
f"✅ Группа {group_name or username} добавлена!\n\n"
f"Username: {username}\n"
f"Все сессии вступили.\n"
f"Бот будет мониторить сообщения."
)
else:
await message.answer(f"❌ Ошибка при добавлении группы {username}")
async def _add_group_by_id(self, message: Message, group_id: int):
"""Добавление группы по ID с авто-вступлением"""
# Конвертируем ID в правильный формат для Telethon
# Если ID начинается с 100, добавляем -100 префикс
if str(group_id).startswith('100') and not str(group_id).startswith('-'):
telethon_id = -int(f"100{group_id}")
elif str(group_id).startswith('-100'):
telethon_id = int(group_id)
else:
telethon_id = int(group_id)
# Пробуем вступить всеми сессиями
join_results = await session_manager.join_all_sessions(telethon_id)
# Проверяем результаты
all_success = all('' in result for result in join_results.values())
if not all_success:
results_text = "\n".join([f"{k}: {v}" for k, v in join_results.items()])
await message.answer(
f"❌ Ошибка при вступлении в группу {group_id}\n\n"
f"Возможно группа приватная.\n\n"
f"Результаты:\n{results_text}\n\n"
f"Используйте публичные группы/каналы."
)
return
# Все сессии вступили — получаем информацию через Telethon (не через Bot API)
group_name = None
group_username = None
try:
# Используем сессию для получения информации
clients = session_manager.clients
if clients:
first_client = list(clients.values())[0]
entity = await first_client.get_entity(telethon_id)
group_name = getattr(entity, 'title', None)
group_username = getattr(entity, 'username', None)
except Exception as e:
logger.warning(f"Не удалось получить информацию о группе {group_id}: {e}")
# Добавляем группу в БД
if add_target_group(telethon_id, group_name, group_username):
username_text = f"@{group_username}" if group_username else "Нет"
await message.answer(
f"✅ Группа {group_name or group_id} добавлена!\n\n"
f"ID: {group_id}\n"
f"Username: {username_text}\n"
f"Все сессии вступили в группу.\n"
f"Теперь бот будет мониторить сообщения."
)
else:
await message.answer(f"❌ Ошибка при добавлении группы {group_id}")
async def cmd_groups(self, message: Message):
"""Обработчик команды /groups"""
if not self._is_admin(message.from_user.id):
return
groups = get_all_target_groups()
if not groups:
await message.answer(
"📋 **Нет добавленных групп**\n\n"
"Добавьте группу командой:\n"
"`/add_group ID_группы`\n\n"
"Или перешлите сообщение из канала, который хотите добавить.")
return
text = "📋 **Целевые группы**\n\n"
for group in groups:
status = "🟢" if group['is_active'] else "🔴"
name = group['group_name'] or f"Группа {group['group_id']}"
text += f"{status} `{name}` (ID: `{group['group_id']}`)\n"
await message.answer(
text,
reply_markup=create_groups_list_keyboard(groups)
)
async def cmd_add_group(self, message: Message):
"""Обработчик команды /add_group"""
if not self._is_admin(message.from_user.id):
return
# Проверяем, есть ли аргументы (ID группы или username)
args = message.text.split()
if len(args) > 1:
# ID или username передан в команде
group_input = args[1]
await self._add_group_by_input(message, group_input)
else:
# Нет аргументов - просим отправить ID
await message.answer(
"📝 Добавление группы\n\n"
"Отправьте ID группы или username:\n\n"
"Примеры:\n"
"@telegram (публичный канал)\n"
"1234567890 (ID)\n"
"-1001234567890 (ID канала)"
)
self.add_group_state[message.from_user.id] = {'waiting': True}
async def _add_group_by_input(self, message: Message, group_input: str):
"""Добавление группы по ID или username"""
# Определяем формат ввода
if group_input.startswith('@'):
# Username - используем как есть
await self._add_group_by_username(message, group_input)
else:
# Числовой ID
try:
group_id = int(group_input)
await self._add_group_by_id(message, group_id)
except ValueError:
await message.answer(
"❌ Неверный формат.\n\n"
"Примеры:\n"
"@telegram (username)\n"
"1234567890 (ID)\n"
"-1001234567890 (ID канала)"
)
async def handle_new_post(self, message: Message):
"""Обработка новых постов (если бот добавлен в канал)"""
# Эта функция для будущего расширения
# Сейчас посты обрабатываются через scan_previous_messages
pass
async def handle_callback(self, callback: CallbackQuery):
"""Обработчик callback-запросов"""
if not self._is_admin(callback.from_user.id):
await callback.answer("❌ Нет доступа", show_alert=True)
return
data = callback.data
logger.info(f"Callback: {data}")
try:
if data == "stats":
await self._callback_stats(callback)
elif data == "sessions":
await self._callback_sessions(callback)
elif data == "pending":
await self._callback_pending(callback)
elif data == "groups":
await self._callback_groups(callback)
elif data == "group_add":
await self._callback_group_add(callback)
elif data.startswith("group_"):
await self._callback_group_action(callback)
elif data.startswith("approve:"):
await self._callback_approve(callback)
elif data.startswith("reject:"):
await self._callback_reject(callback)
elif data.startswith("regenerate:"):
await self._callback_regenerate(callback)
elif data.startswith("edit:"):
await self._callback_edit(callback)
elif data.startswith("edit_cancel:"):
await self._callback_edit_cancel(callback)
elif data.startswith("session_"):
await self._callback_session_action(callback)
else:
await callback.answer("Неизвестная команда")
except Exception as e:
logger.error(f"Ошибка при обработке callback {data}: {e}")
await callback.answer(f"❌ Ошибка: {e}", show_alert=True)
async def _callback_stats(self, callback: CallbackQuery):
"""Callback для статистики"""
summary = get_summary_stats()
text = (
"📊 **Статистика**\n\n"
f"📝 Всего: `{summary.get('total_comments', 0)}`\n"
f"⏳ Ожидают: `{summary.get('pending', 0)}`\n"
f"✅ Одобрено: `{summary.get('approved', 0)}`\n"
f"❌ Отклонено: `{summary.get('rejected', 0)}`\n"
f"📤 Отправлено: `{summary.get('sent', 0)}`"
)
await callback.message.edit_text(
text,
reply_markup=create_main_menu()
)
await callback.answer()
async def _callback_sessions(self, callback: CallbackQuery):
"""Callback для сессий"""
sessions = get_all_sessions()
if not sessions:
text = "❌ Нет сессий\n\nДобавьте файлы в `sessions/`"
else:
text = "👥 **Сессии**\n\n"
for session in sessions:
status = "🟢" if session['is_active'] else "🔴"
username = f"@{session['username']}" if session['username'] else "Без username"
text += f"{status} `{session['session_file']}` — {username}\n"
await callback.message.edit_text(text)
await callback.answer()
async def _callback_pending(self, callback: CallbackQuery):
"""Callback для ожидающих комментариев"""
pending = get_pending_comments()
if not pending:
await callback.answer("✅ Нет ожидающих", show_alert=True)
return
await callback.message.edit_text(f"📝 Ожидают: {len(pending)}")
for comment in pending[:10]:
await self._send_moderation_message(
chat_id=callback.message.chat.id,
comment=comment
)
await callback.answer()
async def _callback_settings(self, callback: CallbackQuery):
"""Callback для настроек"""
text = (
"⚙️ **Настройки**\n\n"
f"Log Group: `{LOG_GROUP_ID}`\n"
f"Scan Limit: `{INITIAL_SCAN_LIMIT}`"
)
await callback.message.edit_text(text)
await callback.answer()
async def _callback_groups(self, callback: CallbackQuery):
"""Callback для списка групп"""
groups = get_all_target_groups()
if not groups:
text = "📋 **Нет добавленных групп**\n\n"
text += "Добавьте группу командой `/add_group ID`"
else:
text = "📋 **Целевые группы**\n\n"
for group in groups:
status = "🟢" if group['is_active'] else "🔴"
name = group['group_name'] or f"Группа {group['group_id']}"
username = f"@{group['group_username']}" if group['group_username'] else ""
text += f"{status} **{name}** {username}\n(ID: `{group['group_id']}`)\n"
await callback.message.edit_text(
text,
reply_markup=create_groups_list_keyboard(groups)
)
await callback.answer()
async def _callback_group_add(self, callback: CallbackQuery):
"""Callback для добавления группы"""
await callback.message.answer(
"📝 **Добавление группы**\n\n"
"Отправьте ID группы или перешлите сообщение из канала.\n\n"
"Пример: `/add_group 123456789`")
await callback.answer()
async def _callback_group_action(self, callback: CallbackQuery):
"""Callback для действий с группой"""
data = callback.data
parts = data.split(":")
if len(parts) < 2:
await callback.answer("❌ Ошибка данных", show_alert=True)
return
action = parts[0]
group_id = parts[1] # Может быть username или ID
if action == "group_info":
# Показываем информацию о группе
groups = get_all_target_groups()
group = next((g for g in groups if str(g['group_id']) == str(group_id)), None)
if group:
# Экранируем символы для Markdown
name = (group['group_name'] or 'Нет').replace('_', ' ')
username = group['group_username'] or 'Нет'
status = '🟢 Активна' if group['is_active'] else '🔴 Неактивна'
text = (
f"📋 Группа\n\n"
f"ID: {group['group_id']}\n"
f"Название: {name}\n"
f"Username: @{username}\n"
f"Статус: {status}"
)
await callback.message.edit_text(
text,
reply_markup=create_group_action_keyboard(group['group_id'])
)
else:
await callback.answer("❌ Группа не найдена", show_alert=True)
elif action == "group_pause":
toggle_target_group(group_id, False)
await callback.message.answer(f"⏸️ Группа {group_id} приостановлена")
await self._callback_groups(callback)
elif action == "group_resume":
toggle_target_group(group_id, True)
await callback.message.answer(f"▶️ Группа {group_id} активирована")
await self._callback_groups(callback)
elif action == "group_delete":
# Получаем информацию о группе перед удалением
groups = get_all_target_groups()
group = next((g for g in groups if str(g['group_id']) == str(group_id)), None)
if group:
group_db_id = group['group_id'] # ID как записан в БД
comments_group_id = group.get('comments_group_id')
# Выходим из группы во всех сессиях
try:
leave_results = await session_manager.leave_all_sessions(group_db_id)
logger.info(f"Выход из группы {group_db_id}: {leave_results}")
except Exception as e:
logger.warning(f"Не удалось выйти из группы {group_db_id}: {e}")
# Удаляем все комментарии этой группы из БД
conn = None
try:
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Собираем все возможные chat_id для этой группы
chat_ids_to_delete = set()
# Добавляем group_id в разных форматах
chat_ids_to_delete.add(str(group_db_id))
try:
chat_ids_to_delete.add(str(abs(int(group_db_id))))
chat_ids_to_delete.add(str(-abs(int(group_db_id))))
except (ValueError, TypeError):
pass # group_id не числовое (username)
# Добавляем comments_group_id если есть
if comments_group_id:
chat_ids_to_delete.add(str(comments_group_id))
try:
chat_ids_to_delete.add(str(abs(int(comments_group_id))))
chat_ids_to_delete.add(str(-abs(int(comments_group_id))))
except (ValueError, TypeError):
pass
# Удаляем комментарии по всем chat_id
for chat_id in chat_ids_to_delete:
try:
cursor.execute('DELETE FROM comments WHERE chat_id = ?', (chat_id,))
deleted = cursor.rowcount
if deleted > 0:
logger.info(f"Удалено {deleted} комментариев с chat_id={chat_id}")
except Exception as e:
logger.debug(f"Ошибка при удалении chat_id={chat_id}: {e}")
conn.commit()
logger.info(f"Удалено комментариев для группы {group_db_id}")
except Exception as e:
logger.error(f"Ошибка при удалении комментариев: {e}")
finally:
if conn:
conn.close()
# Удаляем группу из БД
remove_target_group(group['group_id'])
await callback.message.answer(f"🗑️ Группа {group['group_id']} удалена")
await self._callback_groups(callback)
await callback.answer()
async def _send_moderation_message(self, chat_id: int, comment: dict):
"""Отправка сообщения на модерацию"""
# Используем channel_id из БД (сохранён при генерации)
channel_id = comment.get('channel_id') or comment['chat_id']
post_url = f"https://t.me/c/{channel_id}/{comment['message_id']}"
session_info = ""
if comment.get('session_file'):
session_info = f"👤 Сессия: `{comment['session_file']}`\n\n"
post_text = comment.get('post_text', 'Текст поста не сохранён')
if len(post_text) > 300:
post_text = post_text[:300] + "..."
text = (
f"📝 **Новый комментарий**\n\n"
f"🔗 Пост: {post_url}\n"
f"{session_info}"
f"📄 Текст поста:\n"
f"_{post_text}_\n\n"
f"💬 Комментарий:\n"
f"_{comment['comment_text'][:500]}_{'...' if len(comment['comment_text']) > 500 else ''}\n\n"
f"🔄 Регенераций: {comment.get('regenerations', 0)}"
)
keyboard = create_moderation_keyboard(
comment['id'],
comment['message_id'],
comment['chat_id']
)
await self.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=keyboard
)
async def _callback_approve(self, callback: CallbackQuery):
"""Одобрение комментария"""
comment_id = self._parse_callback_data(callback.data)
if not comment_id:
await callback.answer("❌ Ошибка данных", show_alert=True)
return
# Обновляем статус
update_comment_status(comment_id, 'approved')
# Обновляем сообщение
await callback.message.edit_text(
callback.message.text + "\n\n✅ Одобрено")
# Обновляем статистику
update_stats(datetime.now().strftime('%Y-%m-%d'), '', 'approved')
await callback.answer("✅ Одобрено")
async def _callback_reject(self, callback: CallbackQuery):
"""Отклонение комментария"""
comment_id = self._parse_callback_data(callback.data)
if not comment_id:
await callback.answer("❌ Ошибка данных", show_alert=True)
return
update_comment_status(comment_id, 'rejected')
await callback.message.edit_text(
callback.message.text + "\n\n❌ Отклонено")
update_stats(datetime.now().strftime('%Y-%m-%d'), '', 'rejected')
await callback.answer("❌ Отклонено")
def _get_post_url(self, comment: dict) -> str:
"""Получение URL поста"""
channel_id = comment.get('channel_id') or comment['chat_id']
return f"https://t.me/c/{channel_id}/{comment['message_id']}"
async def _callback_regenerate(self, callback: CallbackQuery):
"""Регенерация комментария"""
comment_id = self._parse_callback_data(callback.data)
if not comment_id:
await callback.answer("❌ Ошибка данных", show_alert=True)
return
# Получаем комментарий из БД
conn = None
try:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('SELECT * FROM comments WHERE id = ?', (comment_id,))
comment = dict(cursor.fetchone())
finally:
if conn:
conn.close()
if not comment:
await callback.answer("❌ Комментарий не найден", show_alert=True)
return
# Проверяем лимит регенераций
if comment.get('regenerations', 0) >= 3:
await callback.answer("⚠️ Достигнут лимит регенераций", show_alert=True)
return
# Генерируем новый комментарий
post_text = comment.get('post_text', '')
if not post_text:
post_text = comment['comment_text'][:200] + "..."
new_comment = await generate_comment(post_text)
if not new_comment:
await callback.answer("❌ Ошибка генерации", show_alert=True)
return
# Сохраняем новый комментарий
save_comment(
comment['message_id'],
comment['chat_id'],
new_comment,
comment.get('session_file'),
comment.get('post_text'),
comment.get('channel_id')
)
increment_regeneration(comment_id)
update_comment_status(comment_id, 'pending')
# Обновляем сообщение
post_url = self._get_post_url(comment)
post_text = comment.get('post_text', 'Текст поста не сохранён')
if len(post_text) > 300:
post_text = post_text[:300] + "..."
text = (
f"📝 Новый комментарий\n\n"
f"🔗 Пост: {post_url}\n"
f"📄 Текст поста:\n"
f"_{post_text}_\n\n"
f"💬 Комментарий:\n"
f"_{new_comment[:500]}_{'...' if len(new_comment) > 500 else ''}\n\n"
f"🔄 Регенераций: {comment.get('regenerations', 0) + 1}"
)
keyboard = create_moderation_keyboard(
comment_id,
comment['message_id'],
comment['chat_id']
)
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("🔄 Сгенерирован новый вариант")
async def _callback_edit(self, callback: CallbackQuery):
"""Редактирование комментария"""
comment_id = self._parse_callback_data(callback.data)
if not comment_id:
await callback.answer("❌ Ошибка данных", show_alert=True)
return
# Сохраняем состояние редактирования
self.editing_comments[callback.message.chat.id] = {
'comment_id': comment_id,
'mod_message_id': callback.message.message_id
}
keyboard = create_edit_cancel_keyboard(comment_id)
await callback.message.edit_text(
callback.message.text + "\n\n✏️ **Отправьте новый текст комментария**",
reply_markup=keyboard
)
await callback.answer("✏️ Отправьте новый текст")
async def _callback_edit_cancel(self, callback: CallbackQuery):
"""Отмена редактирования"""
parts = callback.data.split(':')
comment_id = int(parts[1])
# Получаем оригинальный комментарий
conn = None
try:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('SELECT * FROM comments WHERE id = ?', (comment_id,))
comment = dict(cursor.fetchone())
finally:
if conn:
conn.close()
if comment:
post_url = self._get_post_url(comment)
text = (
f"📝 Новый комментарий\n\n"
f"🔗 Пост: {post_url}\n"
f"💬 Текст:\n"
f"_{comment['comment_text'][:500]}_"
)
keyboard = create_moderation_keyboard(
comment_id,
comment['message_id'],
comment['chat_id']
)
await callback.message.edit_text(text, reply_markup=keyboard)
# Удаляем из editing_comments
if callback.message.chat.id in self.editing_comments:
del self.editing_comments[callback.message.chat.id]
await callback.answer("✏️ Редактирование отменено")
async def _callback_session_action(self, callback: CallbackQuery):
"""Действия с сессией"""
action, session_file = callback.data.split(':', 1)
if action == "session_status":
session = next((s for s in get_all_sessions() if s['session_file'] == session_file), None)
if session:
status = "🟢 Активна" if session['is_active'] else "🔴 Неактивна"
await callback.answer(f"{status}", show_alert=True)
elif action == "session_pause":
toggle_session(session_file, False)
await callback.answer("⏸️ Сессия приостановлена")
elif action == "session_resume":
toggle_session(session_file, True)
await callback.answer("▶️ Сессия активирована")
elif action == "session_delete":
delete_session(session_file)
await callback.message.edit_text("🗑️ Сессия удалена")
await callback.answer()
def _parse_callback_data(self, data: str) -> int:
"""Разбор callback данных (возвращает только comment_id)"""
parts = data.split(':')[1:] # Пропускаем действие
if len(parts) >= 1:
try:
return int(parts[0])
except (ValueError, OverflowError):
return None
return None
def _is_admin(self, user_id: int) -> bool:
"""Проверка прав администратора"""
return user_id in ADMIN_IDS or not ADMIN_IDS
async def start(self):
"""Запуск бота"""
logger.info("Запуск Bot Controller...")
# Инициализация БД
init_db()
# Загрузка сессий
await session_manager.create_all_clients()
# Запуск polling
await self.dp.start_polling(self.bot)
async def stop(self):
"""Остановка бота"""
logger.info("Остановка Bot Controller...")
await session_manager.disconnect_all()
await self.bot.close()
async def main():
"""Точка входа"""
bot = CommentBot()
try:
await bot.start()
except KeyboardInterrupt:
await bot.stop()
if __name__ == '__main__':
asyncio.run(main())

591
bot/db.py Normal file
View File

@@ -0,0 +1,591 @@
import sqlite3
import logging
from datetime import datetime
from pathlib import Path
from bot.config import DB_PATH
logger = logging.getLogger('db')
def get_connection():
"""Получение соединения с БД"""
# Создаём директорию если не существует
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""Инициализация базы данных"""
try:
conn = get_connection()
cursor = conn.cursor()
# Таблица целевых групп
cursor.execute('''
CREATE TABLE IF NOT EXISTS target_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER UNIQUE NOT NULL,
group_name TEXT,
group_username TEXT,
group_type TEXT DEFAULT 'channel',
comments_group_id INTEGER,
is_active INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Таблица комментариев
cursor.execute('''
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL,
chat_id INTEGER NOT NULL,
channel_id INTEGER,
post_text TEXT,
comment_text TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
regenerations INTEGER DEFAULT 0,
session_file TEXT,
sent_message_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(message_id, chat_id, session_file)
)
''')
# Таблица сессий
cursor.execute('''
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_file TEXT UNIQUE NOT NULL,
user_id INTEGER,
username TEXT,
first_name TEXT,
last_name TEXT,
phone TEXT,
is_active INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Таблица статистики
cursor.execute('''
CREATE TABLE IF NOT EXISTS stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
session_file TEXT NOT NULL,
generated INTEGER DEFAULT 0,
approved INTEGER DEFAULT 0,
rejected INTEGER DEFAULT 0,
sent INTEGER DEFAULT 0,
UNIQUE(date, session_file)
)
''')
# Индексы
cursor.execute('CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_comments_message ON comments(message_id, chat_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_sessions_active ON sessions(is_active)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_stats_date ON stats(date)')
conn.commit()
logger.info("База данных успешно инициализирована")
return conn
except Exception as e:
logger.error(f"Ошибка при инициализации базы данных: {e}")
raise
finally:
if 'conn' in locals():
conn.close()
# === Комментарии ===
def save_comment(message_id: int, chat_id: int, comment_text: str, session_file: str = None, post_text: str = None, channel_id: int = None) -> bool:
"""Сохранение комментария в базу данных"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
INSERT INTO comments (message_id, chat_id, comment_text, session_file, post_text, channel_id)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(message_id, chat_id, session_file)
DO UPDATE SET comment_text = ?, updated_at = CURRENT_TIMESTAMP
''', (message_id, chat_id, comment_text, session_file, post_text, channel_id, comment_text))
conn.commit()
logger.info(f"Комментарий сохранён: message_id={message_id}, session={session_file}")
return True
except Exception as e:
logger.error(f"Ошибка при сохранении комментария: {e}")
return False
finally:
if 'conn' in locals():
conn.close()
def get_comment(message_id: int, chat_id: int, session_file: str = None) -> dict | None:
"""Получение комментария из базы данных"""
try:
conn = get_connection()
cursor = conn.cursor()
if session_file:
cursor.execute('''
SELECT * FROM comments
WHERE message_id = ? AND chat_id = ? AND session_file = ?
''', (message_id, chat_id, session_file))
else:
cursor.execute('''
SELECT * FROM comments
WHERE message_id = ? AND chat_id = ?
''', (message_id, chat_id))
row = cursor.fetchone()
return dict(row) if row else None
except Exception as e:
logger.error(f"Ошибка при получении комментария: {e}")
return None
finally:
if 'conn' in locals():
conn.close()
def get_comments_for_post(message_id: int, chat_id: int) -> list:
"""Получение всех комментариев для поста"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT * FROM comments
WHERE message_id = ? AND chat_id = ?
ORDER BY session_file
''', (message_id, chat_id))
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Ошибка при получении комментариев: {e}")
return []
finally:
if 'conn' in locals():
conn.close()
def update_comment_status(comment_id: int, status: str) -> bool:
"""Обновление статуса комментария"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
UPDATE comments
SET status = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (status, comment_id))
conn.commit()
logger.info(f"Статус комментария {comment_id} обновлён на {status}")
return True
except Exception as e:
logger.error(f"Ошибка при обновлении статуса: {e}")
return False
finally:
if 'conn' in locals():
conn.close()
def update_comment_sent(comment_id: int, sent_message_id: int) -> bool:
"""Обновление ID отправленного комментария"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
UPDATE comments
SET sent_message_id = ?, status = 'sent', updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (sent_message_id, comment_id))
conn.commit()
return True
except Exception as e:
logger.error(f"Ошибка при обновлении отправленного комментария: {e}")
return False
finally:
if 'conn' in locals():
conn.close()
def increment_regeneration(comment_id: int) -> bool:
"""Увеличение счётчика регенераций"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
UPDATE comments
SET regenerations = regenerations + 1, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (comment_id,))
conn.commit()
return True
except Exception as e:
logger.error(f"Ошибка при увеличении счётчика регенераций: {e}")
return False
finally:
if 'conn' in locals():
conn.close()
def get_pending_comments() -> list:
"""Получение всех ожидающих комментариев"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT * FROM comments WHERE status = 'pending'
ORDER BY created_at DESC
''')
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Ошибка при получении ожидающих комментариев: {e}")
return []
finally:
if 'conn' in locals():
conn.close()
# === Сессии ===
def save_session(session_file: str, user_info: dict) -> bool:
"""Сохранение информации о сессии"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
INSERT INTO sessions (session_file, user_id, username, first_name, last_name, phone)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(session_file)
DO UPDATE SET
user_id = ?, username = ?, first_name = ?, last_name = ?, phone = ?,
updated_at = CURRENT_TIMESTAMP
''', (
session_file, user_info.get('user_id'), user_info.get('username'),
user_info.get('first_name'), user_info.get('last_name'), user_info.get('phone'),
user_info.get('user_id'), user_info.get('username'),
user_info.get('first_name'), user_info.get('last_name'), user_info.get('phone')
))
conn.commit()
logger.info(f"Сессия сохранена: {session_file}")
return True
except Exception as e:
logger.error(f"Ошибка при сохранении сессии: {e}")
return False
finally:
if 'conn' in locals():
conn.close()
def get_all_sessions() -> list:
"""Получение всех сессий"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('SELECT * FROM sessions ORDER BY created_at')
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Ошибка при получении сессий: {e}")
return []
finally:
if 'conn' in locals():
conn.close()
def get_active_sessions() -> list:
"""Получение активных сессий"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('SELECT * FROM sessions WHERE is_active = 1 ORDER BY created_at')
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Ошибка при получении активных сессий: {e}")
return []
finally:
if 'conn' in locals():
conn.close()
def toggle_session(session_file: str, is_active: bool) -> bool:
"""Активация/деактивация сессии"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
UPDATE sessions
SET is_active = ?, updated_at = CURRENT_TIMESTAMP
WHERE session_file = ?
''', (1 if is_active else 0, session_file))
conn.commit()
return True
except Exception as e:
logger.error(f"Ошибка при переключении сессии: {e}")
return False
finally:
if 'conn' in locals():
conn.close()
def delete_session(session_file: str) -> bool:
"""Удаление сессии"""
try:
conn = get_connection()
cursor = conn.cursor()
# Удаляем комментарии этой сессии
cursor.execute('DELETE FROM comments WHERE session_file = ?', (session_file,))
# Удаляем сессию
cursor.execute('DELETE FROM sessions WHERE session_file = ?', (session_file,))
conn.commit()
return True
except Exception as e:
logger.error(f"Ошибка при удалении сессии: {e}")
return False
finally:
if 'conn' in locals():
conn.close()
# === Статистика ===
def update_stats(date: str, session_file: str, field: str, value: int = 1) -> bool:
"""Обновление статистики"""
try:
conn = get_connection()
cursor = conn.cursor()
# Проверяем существование записи
cursor.execute('''
SELECT id FROM stats WHERE date = ? AND session_file = ?
''', (date, session_file))
if cursor.fetchone():
cursor.execute(f'''
UPDATE stats SET {field} = {field} + ?
WHERE date = ? AND session_file = ?
''', (value, date, session_file))
else:
cursor.execute('''
INSERT INTO stats (date, session_file, {field})
VALUES (?, ?, ?)
'''.format(field=field), (date, session_file, value))
conn.commit()
return True
except Exception as e:
logger.error(f"Ошибка при обновлении статистики: {e}")
return False
finally:
if 'conn' in locals():
conn.close()
def get_stats(date_from: str = None, date_to: str = None) -> list:
"""Получение статистики"""
try:
conn = get_connection()
cursor = conn.cursor()
if date_from and date_to:
cursor.execute('''
SELECT * FROM stats
WHERE date BETWEEN ? AND ?
ORDER BY date DESC
''', (date_from, date_to))
elif date_from:
cursor.execute('''
SELECT * FROM stats
WHERE date >= ?
ORDER BY date DESC
''', (date_from,))
else:
cursor.execute('SELECT * FROM stats ORDER BY date DESC LIMIT 30')
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Ошибка при получении статистики: {e}")
return []
finally:
if 'conn' in locals():
conn.close()
def get_summary_stats() -> dict:
"""Получение сводной статистики"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT
COUNT(*) as total_comments,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved,
SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected,
SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END) as sent
FROM comments
''')
row = cursor.fetchone()
return dict(row) if row else {}
except Exception as e:
logger.error(f"Ошибка при получении сводной статистики: {e}")
return {}
finally:
if 'conn' in locals():
conn.close()
# === Целевые группы ===
def add_target_group(group_id: int, group_name: str = None, group_username: str = None, group_type: str = 'channel', comments_group_id: int = None) -> bool:
"""Добавление целевой группы"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
INSERT INTO target_groups (group_id, group_name, group_username, group_type, comments_group_id)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(group_id)
DO UPDATE SET
group_name = COALESCE(?, group_name),
group_username = COALESCE(?, group_username),
group_type = COALESCE(?, group_type),
comments_group_id = COALESCE(?, comments_group_id),
is_active = 1,
updated_at = CURRENT_TIMESTAMP
''', (group_id, group_name, group_username, group_type, comments_group_id, group_name, group_username, group_type, comments_group_id))
conn.commit()
logger.info(f"Группа {group_id} добавлена")
return True
except Exception as e:
logger.error(f"Ошибка при добавлении группы: {e}")
return False
finally:
if 'conn' in locals():
conn.close()
def get_target_groups() -> list:
"""Получение всех активных целевых групп"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT * FROM target_groups WHERE is_active = 1
ORDER BY created_at
''')
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Ошибка при получении групп: {e}")
return []
finally:
if 'conn' in locals():
conn.close()
def get_all_target_groups() -> list:
"""Получение всех целевых групп"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('SELECT * FROM target_groups ORDER BY created_at')
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Ошибка при получении групп: {e}")
return []
finally:
if 'conn' in locals():
conn.close()
def remove_target_group(group_id: int) -> bool:
"""Удаление целевой группы"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('DELETE FROM target_groups WHERE group_id = ?', (group_id,))
conn.commit()
logger.info(f"Группа {group_id} удалена")
return True
except Exception as e:
logger.error(f"Ошибка при удалении группы: {e}")
return False
finally:
if 'conn' in locals():
conn.close()
def toggle_target_group(group_id: int, is_active: bool) -> bool:
"""Активация/деактивация группы"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
UPDATE target_groups
SET is_active = ?, updated_at = CURRENT_TIMESTAMP
WHERE group_id = ?
''', (1 if is_active else 0, group_id))
conn.commit()
return True
except Exception as e:
logger.error(f"Ошибка при переключении группы: {e}")
return False
finally:
if 'conn' in locals():
conn.close()
def is_target_group(group_id: int) -> bool:
"""Проверка, является ли группа целевой"""
try:
conn = get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT 1 FROM target_groups
WHERE group_id = ? AND is_active = 1
''', (group_id,))
return cursor.fetchone() is not None
except Exception as e:
logger.error(f"Ошибка при проверке группы: {e}")
return False
finally:
if 'conn' in locals():
conn.close()

120
bot/keyboard.py Normal file
View File

@@ -0,0 +1,120 @@
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton, ReplyKeyboardMarkup, KeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
def create_moderation_keyboard(comment_id: int, message_id: int, chat_id: int) -> InlineKeyboardMarkup:
"""Создание клавиатуры модерации для комментария"""
# Используем только comment_id для callback data (чтобы не превышать лимит 64 байта)
callback_data = f"{comment_id}"
builder = InlineKeyboardBuilder()
# Кнопки одобрения/отклонения
builder.button(text="✅ Одобрить", callback_data=f"approve:{callback_data}")
builder.button(text="❌ Отклонить", callback_data=f"reject:{callback_data}")
builder.button(text="🔄 Регенерировать", callback_data=f"regenerate:{callback_data}")
builder.button(text="✏️ Редактировать", callback_data=f"edit:{callback_data}")
builder.adjust(2, 2)
return builder.as_markup()
def create_session_keyboard(session_file: str) -> InlineKeyboardMarkup:
"""Клавиатура управления сессией"""
builder = InlineKeyboardBuilder()
builder.button(text="🔘 Статус", callback_data=f"session_status:{session_file}")
builder.button(text="⏸️ Пауза", callback_data=f"session_pause:{session_file}")
builder.button(text="▶️ Активировать", callback_data=f"session_resume:{session_file}")
builder.button(text="🗑️ Удалить", callback_data=f"session_delete:{session_file}")
builder.adjust(2, 2)
return builder.as_markup()
def create_edit_cancel_keyboard(comment_id: int, message_id: int = 0, chat_id: int = 0) -> InlineKeyboardMarkup:
"""Клавиатура для отмены редактирования"""
builder = InlineKeyboardBuilder()
builder.button(text="❌ Отмена", callback_data=f"edit_cancel:{comment_id}")
return builder.as_markup()
def create_main_menu() -> InlineKeyboardMarkup:
"""Главное меню бота (inline)"""
builder = InlineKeyboardBuilder()
builder.button(text="📊 Статистика", callback_data="stats")
builder.button(text="👥 Сессии", callback_data="sessions")
builder.button(text="📝 Ожидающие", callback_data="pending")
builder.button(text="📋 Группы", callback_data="groups")
builder.button(text="⚙️ Настройки", callback_data="settings")
builder.adjust(2, 2, 1)
return builder.as_markup()
def create_main_keyboard() -> ReplyKeyboardMarkup:
"""Главное меню бота (постоянная клавиатура)"""
builder = ReplyKeyboardBuilder()
builder.button(text="📊 Статистика")
builder.button(text="👥 Сессии")
builder.button(text="📝 Ожидающие")
builder.button(text="📋 Группы")
builder.button(text=" Добавить группу")
builder.button(text="❓ Помощь")
builder.adjust(2, 2, 2)
return builder.as_markup(resize_keyboard=True)
def create_groups_list_keyboard(groups: list) -> InlineKeyboardMarkup:
"""Клавиатура со списком групп"""
builder = InlineKeyboardBuilder()
for group in groups:
group_id = group['group_id']
status = "🟢" if group['is_active'] else "🔴"
name = group['group_name'] or f"Группа {group_id}"
builder.button(
text=f"{status} {name}",
callback_data=f"group_info:{group_id}"
)
builder.adjust(1)
# Кнопка добавления новой группы
builder.button(text=" Добавить группу", callback_data="group_add")
return builder.as_markup()
def create_group_action_keyboard(group_id: int) -> InlineKeyboardMarkup:
"""Клавиатура действий с группой"""
builder = InlineKeyboardBuilder()
builder.button(text="⏸️ Пауза", callback_data=f"group_pause:{group_id}")
builder.button(text="▶️ Активировать", callback_data=f"group_resume:{group_id}")
builder.button(text="🗑️ Удалить", callback_data=f"group_delete:{group_id}")
builder.button(text="🔙 Назад", callback_data="groups")
builder.adjust(2, 2)
return builder.as_markup()
def create_sessions_list_keyboard(sessions: list) -> InlineKeyboardMarkup:
"""Клавиатура со списком сессий"""
builder = InlineKeyboardBuilder()
for session in sessions:
session_file = session['session_file']
status = "🟢" if session['is_active'] else "🔴"
builder.button(
text=f"{status} {session_file}",
callback_data=f"session_info:{session_file}"
)
builder.adjust(1)
return builder.as_markup()

58
bot/ollama.py Normal file
View File

@@ -0,0 +1,58 @@
import logging
import asyncio
import aiohttp
from bot.config import OLLAMA_URL, OLLAMA_MODEL, PROMPT_FILE
logger = logging.getLogger('ollama')
def load_prompt() -> str:
"""Загрузка промпта из файла"""
try:
with open(PROMPT_FILE, 'r', encoding='utf-8') as f:
return f.read().strip()
except Exception as e:
logger.error(f"Ошибка при загрузке промпта: {e}")
return "Напиши комментарий к следующему посту:\n\n{text}"
PROMPT_TEMPLATE = load_prompt()
async def generate_comment(text: str, max_retries: int = 3) -> str | None:
"""Генерация комментария с помощью Ollama"""
prompt = PROMPT_TEMPLATE.format(text=text)
for attempt in range(max_retries):
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{OLLAMA_URL}/api/generate",
json={
"model": OLLAMA_MODEL,
"prompt": prompt,
"stream": False
},
timeout=aiohttp.ClientTimeout(total=60)
) as response:
response.raise_for_status()
data = await response.json()
comment = data.get('response', '')
# Очистка от тегов
comment = comment.replace('<think>', '').replace('</think>', '').strip()
logger.info(f"Комментарий сгенерирован (попытка {attempt + 1})")
return comment
except aiohttp.ClientError as e:
logger.warning(f"Ошибка подключения к Ollama (попытка {attempt + 1}): {e}")
if attempt < max_retries - 1:
await asyncio.sleep(2 ** attempt) # Exponential backoff
else:
logger.error(f"Не удалось подключиться к Ollama после {max_retries} попыток")
except Exception as e:
logger.error(f"Ошибка при генерации комментария: {e}")
return None
return None

262
bot/session_manager.py Normal file
View File

@@ -0,0 +1,262 @@
import asyncio
import logging
from pathlib import Path
from telethon import TelegramClient
from telethon.sessions import StringSession
from bot.config import API_ID, API_HASH, SESSIONS_DIR
from bot.db import save_session
logger = logging.getLogger('session_manager')
class SessionManager:
"""Менеджер сессий Telegram"""
def __init__(self):
self.sessions_dir = SESSIONS_DIR
self.sessions_dir.mkdir(exist_ok=True)
self.clients: dict[str, TelegramClient] = {}
self.session_info: dict[str, dict] = {}
def get_session_files(self) -> list[str]:
"""Получение списка файлов сессий"""
session_files = list(self.sessions_dir.glob("*.session"))
return [f.name for f in session_files]
def load_session_string(self, session_file: str) -> str | None:
"""Загрузка строки сессии из файла"""
try:
session_path = self.sessions_dir / session_file
if not session_path.exists():
logger.error(f"Файл сессии не найден: {session_file}")
return None
session_string = session_path.read_text(encoding='utf-8').strip()
if not session_string:
logger.error(f"Файл сессии пуст: {session_file}")
return None
return session_string
except Exception as e:
logger.error(f"Ошибка при загрузке сессии {session_file}: {e}")
return None
async def create_client(self, session_file: str) -> TelegramClient | None:
"""Создание клиента Telegram для сессии"""
try:
session_string = self.load_session_string(session_file)
if not session_string:
return None
# Проверяем, не подключён ли уже клиент
if session_file in self.clients:
if self.clients[session_file].is_connected():
logger.info(f"Клиент для {session_file} уже подключён")
return self.clients[session_file]
# Создаём новую сессию из строки
session = StringSession(session_string)
client = TelegramClient(
session,
API_ID,
API_HASH,
device_model="comment_bot",
system_version="Linux",
app_version="1.0",
lang_code="ru"
)
await client.connect()
if not await client.is_user_authorized():
logger.warning(f"Сессия {session_file} не авторизована")
await client.disconnect()
return None
# Получаем информацию о пользователе
me = await client.get_me()
user_info = {
'user_id': me.id,
'username': me.username or '',
'first_name': me.first_name or '',
'last_name': me.last_name or '',
'phone': me.phone or ''
}
# Сохраняем информацию в БД
save_session(session_file, user_info)
# Сохраняем клиента и информацию
self.clients[session_file] = client
self.session_info[session_file] = user_info
logger.info(f"Клиент подключён: {session_file} (@{user_info['username'] or 'no_username'})")
return client
except Exception as e:
logger.error(f"Ошибка при создании клиента для {session_file}: {e}")
return None
async def create_all_clients(self) -> dict[str, TelegramClient]:
"""Создание клиентов для всех сессий"""
session_files = self.get_session_files()
logger.info(f"Найдено сессий: {len(session_files)}")
tasks = [self.create_client(sf) for sf in session_files]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Фильтруем успешные результаты
for session_file, result in zip(session_files, results):
if isinstance(result, Exception):
logger.error(f"Ошибка при подключении {session_file}: {result}")
return {sf: client for sf, client in zip(session_files, results)
if isinstance(client, TelegramClient)}
async def get_client(self, session_file: str) -> TelegramClient | None:
"""Получение клиента для сессии"""
if session_file in self.clients and self.clients[session_file].is_connected():
return self.clients[session_file]
return await self.create_client(session_file)
async def disconnect_client(self, session_file: str):
"""Отключение клиента"""
if session_file in self.clients:
try:
await self.clients[session_file].disconnect()
logger.info(f"Клиент {session_file} отключён")
except Exception as e:
logger.error(f"Ошибка при отключении клиента {session_file}: {e}")
finally:
del self.clients[session_file]
if session_file in self.session_info:
del self.session_info[session_file]
async def disconnect_all(self):
"""Отключение всех клиентов"""
tasks = [self.disconnect_client(sf) for sf in list(self.clients.keys())]
await asyncio.gather(*tasks)
logger.info("Все клиенты отключены")
def get_session_info(self, session_file: str) -> dict | None:
"""Получение информации о сессии"""
return self.session_info.get(session_file)
def get_all_info(self) -> list[dict]:
"""Получение информации о всех сессиях"""
return [
{
'session_file': sf,
**self.session_info.get(sf, {})
}
for sf in self.get_session_files()
]
async def join_channel(self, session_file: str, channel_id: int | str) -> tuple[bool, str]:
"""Вступление сессии в канал/группу (по ID или username)"""
try:
client = await self.get_client(session_file)
if not client:
return False, "Не удалось подключить сессию"
# Пытаемся получить сущность по ID или username
entity = await client.get_entity(channel_id)
# Проверяем, участник ли уже
from telethon.tl.functions.channels import GetParticipantRequest
try:
await client(GetParticipantRequest(entity, await client.get_me()))
return True, "Уже участник"
except:
# Не участник, вступаем
pass
# Вступаем в канал/группу
from telethon.tl.functions.channels import JoinChannelRequest
if hasattr(entity, 'megagroup') or hasattr(entity, 'broadcast'):
# Канал или супергруппа
await client(JoinChannelRequest(entity))
else:
# Обычная группа
from telethon.tl.functions.messages import AddChatUserRequest
await client(AddChatUserRequest(entity.id, 0, fwd_limit=0))
return True, "Вступил успешно"
except Exception as e:
error_msg = str(e)
if "PRIVATE" in error_msg.upper() or "CHANNEL_PRIVATE" in error_msg:
return False, "Частный канал/группа"
elif "INVITE" in error_msg.upper():
return False, "Нужно приглашение"
elif "Could not find" in error_msg:
return False, "Группа не найдена"
else:
return False, f"Ошибка: {error_msg[:50]}"
async def join_all_sessions(self, channel_id: int | str) -> dict[str, str]:
"""Все сессии вступают в канал/группу (по ID или username)"""
results = {}
for session_file in self.get_session_files():
success, message = await self.join_channel(session_file, channel_id)
results[session_file] = f"{'' if success else ''} {message}"
return results
async def leave_channel(self, session_file: str, channel_id: int | str) -> tuple[bool, str]:
"""Выход сессии из канала/группы"""
try:
client = await self.get_client(session_file)
if not client:
return False, "Не удалось подключить сессию"
# Пробуем разные форматы ID
entity = None
for try_id in [channel_id, str(channel_id), str(channel_id).lstrip('-')]:
try:
entity = await client.get_entity(try_id)
break
except:
continue
if not entity:
# Если не нашли, пробуем получить из диалогов
async for dialog in client.iter_dialogs():
if str(dialog.id) == str(channel_id) or str(dialog.id).lstrip('-') == str(channel_id).lstrip('-'):
entity = dialog.entity
break
if not entity:
return False, "Группа не найдена в диалогах"
# Выходим из канала/группы
from telethon.tl.functions.channels import LeaveChannelRequest
await client(LeaveChannelRequest(entity))
return True, "Вышел успешно"
except Exception as e:
error_msg = str(e)
if "PRIVATE" in error_msg.upper():
return False, "Частный канал/группа"
elif "BOT" in error_msg.upper() or "ADMIN" in error_msg.upper():
return False, "Нельзя выйти (бот/админ)"
elif "Cannot find" in error_msg or "not found" in error_msg.lower():
return True, "Уже не в группе" # Считаем успешным - уже вышел
else:
return False, f"Ошибка: {error_msg[:50]}"
async def leave_all_sessions(self, channel_id: int | str) -> dict[str, str]:
"""Все сессии выходят из канала/группы"""
results = {}
for session_file in self.get_session_files():
success, message = await self.leave_channel(session_file, channel_id)
results[session_file] = f"{'' if success else ''} {message}"
return results
# Глобальный экземпляр
session_manager = SessionManager()

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())