From a18ad30961363e524054822dbddb953bcab838e4 Mon Sep 17 00:00:00 2001 From: bilal Date: Tue, 24 Feb 2026 04:40:07 +0300 Subject: [PATCH] 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) --- .env.example | 29 ++ .gitignore | 71 ++++ DOCKER.md | 168 ++++++++ Dockerfile | 16 + GIT_INSTRUCTIONS.md | 167 ++++++++ QUICKSTART.md | 112 +++++ README.md | 267 ++++++++++++ auth.py | 130 ++++++ bot/__init__.py | 1 + bot/config.py | 57 +++ bot/controller.py | 909 +++++++++++++++++++++++++++++++++++++++++ bot/db.py | 591 +++++++++++++++++++++++++++ bot/keyboard.py | 120 ++++++ bot/ollama.py | 58 +++ bot/session_manager.py | 262 ++++++++++++ bot/worker.py | 405 ++++++++++++++++++ docker-compose.yml | 50 +++ prompt.txt | 12 + requirements.txt | 6 + sessions/.gitkeep | 0 20 files changed, 3431 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DOCKER.md create mode 100644 Dockerfile create mode 100644 GIT_INSTRUCTIONS.md create mode 100644 QUICKSTART.md create mode 100644 README.md create mode 100644 auth.py create mode 100644 bot/__init__.py create mode 100644 bot/config.py create mode 100644 bot/controller.py create mode 100644 bot/db.py create mode 100644 bot/keyboard.py create mode 100644 bot/ollama.py create mode 100644 bot/session_manager.py create mode 100644 bot/worker.py create mode 100644 docker-compose.yml create mode 100644 prompt.txt create mode 100644 requirements.txt create mode 100644 sessions/.gitkeep diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6a0bb1f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d275eb6 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..da6dcb3 --- /dev/null +++ b/DOCKER.md @@ -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 +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3594d21 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/GIT_INSTRUCTIONS.md b/GIT_INSTRUCTIONS.md new file mode 100644 index 0000000..a9a6594 --- /dev/null +++ b/GIT_INSTRUCTIONS.md @@ -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 +``` diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..40280eb --- /dev/null +++ b/QUICKSTART.md @@ -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`) diff --git a/README.md b/README.md new file mode 100644 index 0000000..e0bf3a4 --- /dev/null +++ b/README.md @@ -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 diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..fedfb44 --- /dev/null +++ b/auth.py @@ -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="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}", + 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()) diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..a9ceafb --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1 @@ +# Bot package diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..fca20bf --- /dev/null +++ b/bot/config.py @@ -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 diff --git a/bot/controller.py b/bot/controller.py new file mode 100644 index 0000000..61a6790 --- /dev/null +++ b/bot/controller.py @@ -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()) diff --git a/bot/db.py b/bot/db.py new file mode 100644 index 0000000..8e919a9 --- /dev/null +++ b/bot/db.py @@ -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() diff --git a/bot/keyboard.py b/bot/keyboard.py new file mode 100644 index 0000000..1a6c7a6 --- /dev/null +++ b/bot/keyboard.py @@ -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() diff --git a/bot/ollama.py b/bot/ollama.py new file mode 100644 index 0000000..d68dac7 --- /dev/null +++ b/bot/ollama.py @@ -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('', '').replace('', '').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 diff --git a/bot/session_manager.py b/bot/session_manager.py new file mode 100644 index 0000000..d839e7d --- /dev/null +++ b/bot/session_manager.py @@ -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() diff --git a/bot/worker.py b/bot/worker.py new file mode 100644 index 0000000..af2dcd0 --- /dev/null +++ b/bot/worker.py @@ -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()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ee1b06d --- /dev/null +++ b/docker-compose.yml @@ -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"] diff --git a/prompt.txt b/prompt.txt new file mode 100644 index 0000000..9477c98 --- /dev/null +++ b/prompt.txt @@ -0,0 +1,12 @@ +Ты - помощник для генерации комментариев к постам в социальных сетях. +Твоя задача - написать интересный и релевантный комментарий к следующему посту. +Комментарий должен быть: +- Информативным +- Дружелюбным +- Соответствующим теме поста +- Не слишком длинным (до 4-5 предложений) +- Добавить смайлики в тему +- Если в посте есть приветствие Ассаламу алейкум, то нужно начать с ответа на это приветствие + +Текст поста: +{text} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2e01e2c --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/sessions/.gitkeep b/sessions/.gitkeep new file mode 100644 index 0000000..e69de29