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

29
.env.example Normal file
View File

@@ -0,0 +1,29 @@
# Telegram Bot Token (для контроллера - создайте через @BotFather)
BOT_TOKEN=your_bot_token_here
# Telegram API credentials (для воркеров - аккаунты пользователей)
TELEGRAM_API_ID=your_api_id
TELEGRAM_API_HASH=your_api_hash
# Log group ID where controller sends moderation messages (с -100)
LOG_GROUP_ID=-100your_log_group_id
# Admin IDs (Telegram user IDs who can moderate, через запятую)
ADMIN_IDS=your_user_id
# Groups are added via bot command: /add_group ID
# No need to specify TARGET_GROUP_ID in .env
# Ollama configuration (внешний сервис)
# Linux (Docker bridge): http://172.17.0.1:11434
# Или через host.docker.internal: http://host.docker.internal:11434
OLLAMA_URL=http://172.17.0.1:11434
OLLAMA_MODEL=qwen3:30b-a3b
PROMPT_FILE=prompt.txt
# Scan configuration
INITIAL_SCAN_LIMIT=20
# Comment settings (задержка в секундах для естественности)
COMMENT_DELAY_MIN=1
COMMENT_DELAY_MAX=5

71
.gitignore vendored Normal file
View File

@@ -0,0 +1,71 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
env/
ENV/
.venv
# Environment variables
.env
.env.local
.env.*.local
# Database
*.db
*.sqlite
*.sqlite3
data/
# Sessions
sessions/*.session
sessions/*.session-journal
*.session
# Logs
logs/
*.log
# Docker
.dockerignore
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
# Testing
.pytest_cache/
.coverage
htmlcov/
# Documentation
docs/_build/
# Temporary files
tmp/
temp/
*.tmp

168
DOCKER.md Normal file
View File

@@ -0,0 +1,168 @@
# 🐳 Docker Guide
## Предварительные требования
- ✅ Docker установлен
- ✅ Ollama запущена отдельно (на хосте)
- ✅ Есть токен бота от @BotFather
## 1. Настройка
```bash
cd batch-bot
# Скопируйте конфиг
cp .env.example .env
# Отредактируйте
nano .env
```
**Обязательные параметры:**
```bash
BOT_TOKEN=1234567890:AABBccDDeeFFggHHiiJJkkLLmmNNooP
TELEGRAM_API_ID=12345678
TELEGRAM_API_HASH=abc123def456ghi789
TARGET_GROUP_ID=1416283017
LOG_GROUP_ID=-1004804863247
ADMIN_IDS=123456789
# Ollama URL (Linux)
OLLAMA_URL=http://172.17.0.1:11434
```
## 2. Создание сессий
Сессии создаются **на хосте** (вне Docker):
```bash
pip install -r requirements.txt
python auth.py
```
Введите:
1. Номер телефона
2. Код из Telegram
**Для нескольких аккаунтов:**
```bash
python auth.py # первый
python auth.py # второй
```
Проверьте:
```bash
ls sessions/
# user_123456789.session user_987654321.session
```
## 3. Запуск
```bash
# Сборка
docker-compose build
# Запуск
docker-compose up -d
# Проверка
docker-compose ps
# Логи
docker-compose logs -f
```
**Ожидается:**
```
NAME STATUS
batch-bot-controller Up
batch-bot-worker Up
```
## 4. Проверка
1. Откройте бота в Telegram
2. Отправьте `/start`
3. Проверьте `/stats`
## 🔧 Управление
```bash
# Перезапуск
docker-compose restart
# Пересборка
docker-compose build --no-cache
# Остановка
docker-compose down
# Логи controller
docker-compose logs -f controller
# Логи worker
docker-compose logs -f worker
```
## ⚠️ Troubleshooting
**"BOT_TOKEN не задан":**
```bash
docker-compose config
docker-compose restart controller
```
**"Нет сессий":**
```bash
ls sessions/
docker-compose restart worker
```
**"Ollama не отвечает":**
```bash
# Проверка с хоста
curl http://172.17.0.1:11434/api/tags
# Проверка из контейнера
docker run --rm alpine wget -qO- http://172.17.0.1:11434/api/tags
```
**"Сессия не авторизована":**
```bash
rm sessions/user_*.session
python auth.py
docker-compose restart worker
```
## 📁 Тома
| Том | Описание |
|-----|----------|
| `./sessions:/app/sessions` | Файлы сессий |
| `./logs:/app/logs` | Логи |
| `./comments.db:/app/comments.db` | База данных |
## 🌐 Сетевые настройки
Контейнеры используют Docker bridge сеть.
**Доступ к хосту:**
- `172.17.0.1` — IP хоста в Docker bridge
- `host.docker.internal` — альтернативное имя (добавлено через `extra_hosts`)
**Если Ollama на другом сервере:**
```bash
OLLAMA_URL=http://192.168.1.100:11434
```
## 📝 Обновление
```bash
# Обновление кода
git pull
# Пересборка и перезапуск
docker-compose build
docker-compose up -d
```

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
# Установка зависимостей
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копирование файлов проекта
COPY . .
# Создание директорий для данных, сессий и логов
RUN mkdir -p /app/data/logs /app/sessions /app/logs
# По умолчанию запускаем worker (controller запускается отдельно)
CMD ["python", "bot/worker.py"]

167
GIT_INSTRUCTIONS.md Normal file
View File

@@ -0,0 +1,167 @@
# Инструкция по отправке в Gitea
## 1. Инициализация Git
```bash
cd /Users/bilal/Documents/code/batch-bot
# Инициализация репозитория
git init
# Проверка статуса
git status
```
## 2. Добавление файлов
```bash
# Добавить все файлы
git add .
# Или выборочно:
git add README.md
git add bot/
git add docker-compose.yml
git add Dockerfile
git add requirements.txt
git add auth.py
git add prompt.txt
git add .env.example
git add .gitignore
git add DOCKER.md
git add QUICKSTART.md
# Проверка что будет закоммичено
git status
```
## 3. Первый коммит
```bash
git commit -m "Initial commit: Batch Bot - Telegram Comment Bot
Features:
- Multi-account support
- AI comments via Ollama
- Moderation via Telegram bot
- Docker support
- Auto-join groups
- Comment regeneration
Tech stack:
- Python 3.11
- Telethon (Telegram client)
- Aiogram (Bot framework)
- SQLite (Database)
- Docker & Docker Compose"
```
## 4. Добавление удалённого репозитория
```bash
# Добавить remote
git remote add origin https://git.core.com.ru/bilal/batch-bot.git
# Или по SSH:
git remote add origin ssh://git@git.core.com.ru/bilal/batch-bot.git
# Проверка
git remote -v
```
## 5. Отправка в Gitea
```bash
# Отправка main ветки
git push -u origin main
# Если ветка называется master:
git branch -M main
git push -u origin main
```
## 6. Последующие изменения
```bash
# Внесение изменений
# ... редактирование файлов ...
# Добавление изменений
git add .
# Коммит
git commit -m "Описание изменений"
# Отправка
git push
```
## 📋 Что НЕ попадает в репозиторий:
-`.env` — содержит секреты
-`sessions/*.session` — файлы сессий
-`data/comments.db` — база данных
-`logs/` — логи
-`__pycache__/` — кэш Python
-`.DS_Store` — системные файлы
## 🔐 Проверка перед отправкой
```bash
# Проверить что не попадает в репозиторий
git status
# Проверить .gitignore
git check-ignore -v .env
git check-ignore -v sessions/
git check-ignore -v data/
```
## 📊 Структура в Gitea
После отправки в репозитории будет:
```
batch-bot/
├── README.md # Документация
├── DOCKER.md # Docker инструкция
├── QUICKSTART.md # Быстрый старт
├── .env.example # Пример конфигурации
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
├── auth.py
├── prompt.txt
└── bot/
├── config.py
├── controller.py
├── worker.py
├── db.py
├── keyboard.py
├── ollama.py
├── session_manager.py
└── __init__.py
```
## 🚀 Развёртывание из Gitea
На сервере:
```bash
# Клонирование
git clone https://git.core.com.ru/bilal/batch-bot.git
cd batch-bot
# Настройка
cp .env.example .env
nano .env
# Создание сессий
pip install -r requirements.txt
python auth.py
# Запуск
docker-compose build
docker-compose up -d
```

112
QUICKSTART.md Normal file
View File

@@ -0,0 +1,112 @@
# 🚀 Quick Start
## 1. Настройка
```bash
cd batch-bot
# Скопируйте .env.example в .env
cp .env.example .env
# Отредактируйте .env
nano .env
```
**Обязательные параметры:**
| Параметр | Где взять |
|----------|-----------|
| `BOT_TOKEN` | @BotFather в Telegram |
| `TELEGRAM_API_ID` | my.telegram.org |
| `TELEGRAM_API_HASH` | my.telegram.org |
| `TARGET_GROUP_ID` | ID канала (через @RawDataBot) |
| `LOG_GROUP_ID` | ID чата модерации (с -100) |
| `ADMIN_IDS` | Ваш Telegram ID (через @userinfobot) |
## 2. Создание сессии
```bash
# Установка зависимостей
pip install -r requirements.txt
# Создание сессии
python auth.py
```
Введите:
1. Номер телефона
2. Код из Telegram
**Для нескольких аккаунтов:**
- Запустите `python auth.py` несколько раз
- Или скопируйте `.session` файлы в `sessions/`
## 3. Запуск Docker
```bash
# Сборка и запуск
docker-compose build
docker-compose up -d
# Логи
docker-compose logs -f
```
## 4. Проверка
1. Откройте бота в Telegram
2. Отправьте `/start`
3. Проверьте `/stats`
---
## 📝 Команды
| Команда | Описание |
|---------|----------|
| `/start` | Главное меню |
| `/stats` | Статистика |
| `/pending` | Ожидающие комментарии |
| `/sessions` | Сессии |
| `/groups` | Список групп |
| `/add_group` | Добавить группу |
| `/help` | Справка |
---
## ⚙️ Настройка Ollama URL
**Linux (Docker bridge):**
```
OLLAMA_URL=http://172.17.0.1:11434
```
**Проверка:**
```bash
curl http://172.17.0.1:11434/api/tags
```
---
## ⚠️ Важно
1. **Ollama должна быть запущена отдельно** (не в Docker)
2. **Бот должен быть администратором** в `LOG_GROUP_ID`
3. **Сессии должны быть в папке** `sessions/`
---
## 🐛 Ошибки
**"BOT_TOKEN не задан":**
- Проверьте `.env` и `BOT_TOKEN`
**"Нет сессий":**
- Запустите `python auth.py`
**"Ollama не отвечает":**
- Проверьте: `curl http://172.17.0.1:11434/api/tags`
**"Не удалось найти группу":**
- Добавьте бота в группу модерации
- Проверьте `LOG_GROUP_ID` (должен быть с `-100`)

267
README.md Normal file
View File

@@ -0,0 +1,267 @@
# Batch Bot - Telegram Comment Bot
Автоматический бот для генерации и публикации комментариев в Telegram от имени нескольких пользователей.
## 🏗 Архитектура
```
┌─────────────────────────────────────────────────────────────┐
│ Telegram Bot (Controller) │
│ @BotFather bot для управления и модерации │
└─────────────────────────────────────────────────────────────┤
┌─────────────────────────────────────────────────────────────┐
│ Session Workers │
│ Воркеры для каждого пользователя (user sessions) │
└─────────────────────────────────────────────────────────────┤
┌───────────────┼───────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ user_1.session│ │ user_2.session│ │ user_3.session│
└────────────┘ └────────────┘ └────────────┘
```
## 📋 Возможности
-**Мультиаккаунт** — работа с несколькими сессиями одновременно
-**AI генерация** — комментарии через Ollama (локальная LLM)
-**Модерация** — inline-кнопки для одобрения/отклонения
-**Редактирование** — возможность изменить текст перед отправкой
-**Статистика** — учёт сгенерированных/отправленных комментариев
-**Безопасность** — разделение контроллера и воркеров
-**Docker** — полная контейнеризация
## 🚀 Быстрый старт
### 1. Настройка
```bash
# Скопируйте .env.example в .env
cp .env.example .env
# Отредактируйте .env
nano .env
```
**Обязательные параметры:**
| Параметр | Описание |
|----------|----------|
| `BOT_TOKEN` | Токен бота от @BotFather |
| `TELEGRAM_API_ID` | API ID аккаунта |
| `TELEGRAM_API_HASH` | API Hash аккаунта |
| `LOG_GROUP_ID` | ID чата для модерации (с -100) |
| `ADMIN_IDS` | Telegram ID администраторов |
| `OLLAMA_URL` | URL внешнего Ollama |
### 2. Создание сессий
```bash
# Установка зависимостей
pip install -r requirements.txt
# Запуск авторизации
python auth.py
```
Введите номер телефона и код из Telegram.
**Для нескольких аккаунтов:**
- Запустите `python auth.py` несколько раз
- Или скопируйте `.session` файлы в `sessions/`
### 3. Запуск Docker
```bash
# Сборка и запуск
docker-compose build
docker-compose up -d
# Логи
docker-compose logs -f
# Остановка
docker-compose down
```
**Сервисы:**
- `controller` — бот для модерации
- `worker` — отправка комментариев
## 📖 Использование
### Команды бота
| Команда | Описание |
|---------|----------|
| `/start` | Главное меню |
| `/stats` | Статистика |
| `/pending` | Ожидающие комментарии |
| `/sessions` | Сессии |
| `/groups` | Управление группами |
| `/add_group ID` | Добавить группу |
| `/help` | Справка |
### Добавление группы
**Через команду:**
```
/add_group 1416283017
```
**Через меню:**
1. Отправьте `/groups`
2. Нажмите " Добавить группу"
3. Отправьте ID группы
**Управление группами:**
- `/groups` — показать список
- Нажмите на группу для управления (пауза/удаление)
### Модерация комментариев
1. Бот получает новый пост из группы
2. Генерирует комментарий через Ollama
3. Отправляет в `LOG_GROUP_ID` на модерацию
4. Администратор нажимает:
- **✅ Одобрить** — опубликовать комментарий
- **❌ Отклонить** — удалить комментарий
- **🔄 Регенерировать** — новый вариант
- **✏️ Редактировать** — изменить текст
## 🔧 Конфигурация
### Переменные окружения
| Параметр | По умолчанию | Описание |
|----------|--------------|----------|
| `BOT_TOKEN` | - | Токен Telegram бота |
| `TELEGRAM_API_ID` | - | API ID для воркеров |
| `TELEGRAM_API_HASH` | - | API Hash для воркеров |
| `LOG_GROUP_ID` | - | ID чата для модерации |
| `ADMIN_IDS` | - | ID администраторов |
| `OLLAMA_URL` | `http://host.docker.internal:11434` | URL Ollama |
| `OLLAMA_MODEL` | `qwen3:30b-a3b` | Модель для генерации |
| `INITIAL_SCAN_LIMIT` | `20` | Кол-во сообщений для сканирования |
| `COMMENT_DELAY_MIN` | `1` | Мин. задержка перед отправкой (сек) |
| `COMMENT_DELAY_MAX` | `5` | Макс. задержка перед отправкой (сек) |
## 📁 Структура проекта
```
batch-bot/
├── .env.example # Пример конфигурации
├── .gitignore
├── docker-compose.yml # Docker конфигурация
├── Dockerfile # Образ для controller/worker
├── requirements.txt # Python зависимости
├── auth.py # Скрипт авторизации
├── prompt.txt # Шаблон для LLM
├── bot/
│ ├── config.py # Конфигурация
│ ├── controller.py # Бот для модерации
│ ├── worker.py # Воркер для отправки
│ ├── db.py # База данных
│ ├── keyboard.py # Inline-клавиатуры
│ ├── ollama.py # Ollama API
│ └── session_manager.py # Управление сессиями
├── sessions/ # .session файлы (том Docker)
├── logs/ # Логи (том Docker)
└── data/ # БД и данные (том Docker)
```
## 🐳 Docker
### Запуск
```bash
# Сборка
docker-compose build
# Запуск
docker-compose up -d
# Логи
docker-compose logs -f controller
docker-compose logs -f worker
# Остановка
docker-compose down
```
### Тома
| Том | Описание |
|-----|----------|
| `./sessions:/app/sessions` | Файлы сессий |
| `./logs:/app/logs` | Логи |
| `./data:/app/data` | База данных |
### Сетевые настройки
**Linux:**
```
OLLAMA_URL=http://172.17.0.1:11434
```
**macOS/Windows:**
```
OLLAMA_URL=http://host.docker.internal:11434
```
## 🔐 Безопасность
- `.env` и `*.session` файлы добавлены в `.gitignore`
- Не коммитьте сессии в репозиторий
- Используйте разные аккаунты для воркеров
- Ограничьте доступ через `ADMIN_IDS`
## 📊 База данных
**Таблицы:**
| Таблица | Описание |
|---------|----------|
| `comments` | Комментарии (текст, статус, сессия) |
| `sessions` | Информация о сессиях |
| `stats` | Статистика по дням |
| `target_groups` | Целевые группы для мониторинга |
## ⚠️ Важные замечания
1. **Telegram ToS** — использование нескольких аккаунтов может нарушать условия Telegram
2. **Задержки** — настройте `COMMENT_DELAY_MIN/MAX` для естественности
3. **Лимиты** — не отправляйте слишком много комментариев с одного аккаунта
4. **Ollama** — должен быть доступен по сети для воркера
## 🐛 Решение проблем
**Бот не подключается:**
- Проверьте `BOT_TOKEN` в `.env`
- Убедитесь, что бот добавлен в `LOG_GROUP_ID` как администратор
**Сессия не авторизуется:**
- Запустите `python auth.py` заново
- Проверьте `TELEGRAM_API_ID` и `TELEGRAM_API_HASH`
**Ollama не отвечает:**
- Проверьте URL в `.env`
- Убедитесь, что модель загружена: `ollama list`
**"Нет групп в БД":**
- Добавьте группу через бота: `/add_group ID`
**Комментарии не отправляются:**
- Проверьте что аккаунт вступил в группу комментариев
- Worker автоматически вступает при отправке
## 📝 Лицензия
MIT
## 📞 Контакты
- Telegram: @your_username
- Email: your@email.com

130
auth.py Normal file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""
Скрипт для создания сессии Telegram
Запустите этот скрипт для авторизации и создания файла сессии
"""
import asyncio
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
from loguru import logger
from telethon import TelegramClient
from telethon.sessions import StringSession
# Настройка логирования
logger.remove()
logger.add(
sys.stdout,
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <level>{message}</level>",
level="INFO"
)
logger.add(
"logs/auth.log",
rotation="1 day",
retention="7 days",
level="DEBUG"
)
# Загрузка переменных окружения
load_dotenv()
# Конфигурация
API_ID = int(os.getenv("TELEGRAM_API_ID", "0"))
API_HASH = os.getenv("TELEGRAM_API_HASH", "")
PHONE = os.getenv("TELEGRAM_PHONE", "")
SESSIONS_DIR = Path("sessions")
async def main():
"""Создание сессии"""
try:
# Проверка конфигурации
if not all([API_ID, API_HASH]):
logger.error("❌ TELEGRAM_API_ID и TELEGRAM_API_HASH должны быть заданы в .env")
return
# Создаём директорию для сессий
SESSIONS_DIR.mkdir(exist_ok=True)
logger.info("🔐 Telegram Session Creator")
logger.info("=" * 40)
logger.info(f"API ID: {API_ID}")
logger.info(f"API Hash: {'*' * len(API_HASH) if API_HASH else 'Не задан'}")
logger.info(f"Phone: {PHONE or 'Будет запрошен'}")
logger.info("=" * 40)
# Запрос телефона если не задан
phone = PHONE
if not phone:
phone = input("📱 Введите номер телефона (с +7): ").strip()
# Создаём клиента
client = TelegramClient(
StringSession(),
API_ID,
API_HASH,
device_model="comment_bot",
system_version="Linux",
app_version="1.0",
lang_code="ru"
)
logger.info("Подключение к Telegram...")
await client.connect()
if not await client.is_user_authorized():
logger.info("Отправка кода подтверждения...")
try:
await client.send_code_request(phone)
except Exception as e:
logger.error(f"Ошибка отправки кода: {e}")
return
# Ввод кода
code = input("📲 Введите код из Telegram: ").strip()
try:
await client.sign_in(phone, code)
except Exception as e:
if "PASSWORD" in str(e):
# Запрос 2FA пароля
password = input("🔒 Введите 2FA пароль: ").strip()
await client.sign_in(password=password)
else:
logger.error(f"Ошибка входа: {e}")
return
# Получаем информацию о пользователе
me = await client.get_me()
logger.info(f"✅ Успешная авторизация: {me.first_name} @{me.username or 'no_username'}")
# Сохраняем сессию
session_string = client.session.save()
session_filename = f"user_{me.id}.session"
session_path = SESSIONS_DIR / session_filename
with open(session_path, 'w', encoding='utf-8') as f:
f.write(session_string)
logger.info(f"💾 Сессия сохранена: {session_path}")
logger.info("")
logger.info("Следующие шаги:")
logger.info("1. Скопируйте файл сессии в папку sessions/")
logger.info("2. Запустите бота: python bot/controller.py")
logger.info("3. Запустите воркера: python bot/worker.py")
await client.disconnect()
except KeyboardInterrupt:
logger.info("\n❌ Отменено пользователем")
except Exception as e:
logger.error(f"❌ Ошибка: {e}")
raise
if __name__ == "__main__":
asyncio.run(main())

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

50
docker-compose.yml Normal file
View File

@@ -0,0 +1,50 @@
version: '3.8'
services:
# Controller - бот для модерации (Telegram Bot API)
controller:
build: .
container_name: batch-bot-controller
volumes:
- ./sessions:/app/sessions
- ./data/logs:/app/data/logs
- ./data/comments.db:/app/data/comments.db
environment:
- BOT_TOKEN=${BOT_TOKEN}
- TELEGRAM_API_ID=${TELEGRAM_API_ID}
- TELEGRAM_API_HASH=${TELEGRAM_API_HASH}
- TARGET_GROUP_ID=${TARGET_GROUP_ID}
- LOG_GROUP_ID=${LOG_GROUP_ID}
- ADMIN_IDS=${ADMIN_IDS}
- OLLAMA_URL=${OLLAMA_URL:-http://172.17.0.1:11434}
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:30b-a3b}
- INITIAL_SCAN_LIMIT=${INITIAL_SCAN_LIMIT:-20}
- PYTHONPATH=/app
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
command: ["python", "bot/controller.py"]
# Worker - отправка комментариев от имени пользователей
worker:
build: .
container_name: batch-bot-worker
volumes:
- ./sessions:/app/sessions
- ./data/logs:/app/data/logs
- ./data/comments.db:/app/data/comments.db
environment:
- TELEGRAM_API_ID=${TELEGRAM_API_ID}
- TELEGRAM_API_HASH=${TELEGRAM_API_HASH}
- TARGET_GROUP_ID=${TARGET_GROUP_ID}
- LOG_GROUP_ID=${LOG_GROUP_ID}
- OLLAMA_URL=${OLLAMA_URL:-http://172.17.0.1:11434}
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:30b-a3b}
- INITIAL_SCAN_LIMIT=${INITIAL_SCAN_LIMIT:-20}
- COMMENT_DELAY_MIN=${COMMENT_DELAY_MIN:-1}
- COMMENT_DELAY_MAX=${COMMENT_DELAY_MAX:-5}
- PYTHONPATH=/app
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
command: ["python", "bot/worker.py"]

12
prompt.txt Normal file
View File

@@ -0,0 +1,12 @@
Ты - помощник для генерации комментариев к постам в социальных сетях.
Твоя задача - написать интересный и релевантный комментарий к следующему посту.
Комментарий должен быть:
- Информативным
- Дружелюбным
- Соответствующим теме поста
- Не слишком длинным (до 4-5 предложений)
- Добавить смайлики в тему
- Если в посте есть приветствие Ассаламу алейкум, то нужно начать с ответа на это приветствие
Текст поста:
{text}

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
telethon==1.34.0
python-dotenv==1.0.0
loguru==0.7.2
aiohttp==3.9.1
aiogram==3.4.1
apscheduler==3.10.4

0
sessions/.gitkeep Normal file
View File