commit e6aad48e720bae96749bea48c0e22b366a4c1cd8 Author: xds Date: Mon Feb 2 16:15:17 2026 +0300 init diff --git a/.env b/.env new file mode 100644 index 0000000..33f109c --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +BOT_TOKEN=8495170789:AAHyjjhHwwVtd9_ROnjHqPHRdnmyVr1aeaY +GEMINI_API_KEY=AIzaSyAHzDYhgjOqZZnvOnOFRGaSkKu4OAN3kZE +MONGO_HOST=mongodb://admin:super_secure_password@31.59.58.220:27017/ +ADMIN_ID=567047 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ceecf93 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.13-slim + +# Рабочая директория внутри контейнера +WORKDIR /app + +# Копируем зависимости (если есть requirements.txt) +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Копируем код +COPY . . + +# Запуск приложения (замени app.py на свой файл) +CMD ["python", "main.py"] diff --git a/adapters/__init__.py b/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapters/google_adapter.py b/adapters/google_adapter.py new file mode 100644 index 0000000..ddd6434 --- /dev/null +++ b/adapters/google_adapter.py @@ -0,0 +1,99 @@ +import os +import io +import logging +from datetime import datetime +from typing import List, Union, Dict, Any +from PIL import Image + +# Импортируем из нового SDK +from google import genai +from google.genai import types + +# Для настройки логгера +logger = logging.getLogger(__name__) + + +class GoogleAdapter: + def __init__(self, api_key: str): + if not api_key: + raise ValueError("API Key for Gemini is missing") + self.client = genai.Client(api_key=api_key) + # Укажите актуальную модель. + # Если gemini-3-pro-image-preview недоступна, используйте gemini-2.0-flash-exp + self.model_name = "gemini-3-pro-preview" + + def generate( + self, + prompt: str, + image_bytes: bytes = None, + generate_image: bool = False + ) -> Dict[str, Any]: + """ + Универсальный метод: + - Если generate_image=True: просим модель вернуть картинку (Image Generation). + - Если image_bytes переданы + generate_image=False: это Vision (описание фото). + - Если image_bytes + generate_image=True: это Image-to-Image (редактирование). + """ + if generate_image: + self.model_name = "gemini-3-pro-image-preview" + else : + self.model_name = "gemini-3-pro-preview" + contents = [prompt] + + # Если есть входное изображение (для Vision или для редактирования) + if image_bytes: + try: + image = Image.open(io.BytesIO(image_bytes)) + contents.append(image) + except Exception as e: + logger.error(f"Error processing input image: {e}") + return {"error": "Не удалось обработать входящее изображение."} + + # Настраиваем конфигурацию + # Для генерации картинок добавляем 'IMAGE' в response_modalities + modalities = ['TEXT'] + if generate_image: + modalities.append('IMAGE') + + try: + # Вызов API (синхронный метод в обертке, но aiogram вызывает его в треде, + # либо используйте client.aio для асинхронности если поддерживается версией SDK) + # В google-genai v0.3+ есть асинхронный клиент, но для простоты здесь стандартный вызов. + # Чтобы не блокировать event loop, в main.py мы обернем это в to_thread при необходимости, + # но пока используем стандартный вызов. + + response = self.client.models.generate_content( + model=self.model_name, + contents=contents, + config=types.GenerateContentConfig( + response_modalities=modalities, + temperature=0.7 if not generate_image else 1.0, + ) + ) + + result = {"text": "", "images": []} + + # Парсим ответ (Text или Inline Data) + if response.parts: + for part in response.parts: + if part.text: + result["text"] += part.text + + # Проверяем наличие сгенерированного изображения + if part.inline_data: + # ИСПРАВЛЕНИЕ: Берем "сырые" байты напрямую из ответа + # Это работает быстрее и не вызывает ошибку с PIL + + # part.inline_data.data — это уже bytes + byte_arr = io.BytesIO(part.inline_data.data) + now = datetime.now() + # Имя файла для телеграма (формально) + byte_arr.name = f'{now.timestamp()}.png' + + result["images"].append(byte_arr) + + return result + + except Exception as e: + logger.error(f"Gemini API Error: {e}") + return {"error": f"Ошибка API: {str(e)}"} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8ca0893 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + tot-bot: + image: tot-bot:latest + container_name: tot-bot + build: + context: . + network: host + network_mode: host + restart: unless-stopped diff --git a/keyboards.py b/keyboards.py new file mode 100644 index 0000000..1179cf7 --- /dev/null +++ b/keyboards.py @@ -0,0 +1,15 @@ +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + +def get_request_kb(): + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔐 Запросить доступ", callback_data="req_access")] + ]) + +def get_admin_decision_kb(user_id: int): + """Кнопки для админа с ID пользователя в callback_data""" + return InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="✅ Разрешить", callback_data=f"access_allow_{user_id}"), + InlineKeyboardButton(text="🚫 Запретить", callback_data=f"access_deny_{user_id}") + ] + ]) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..4a976a9 --- /dev/null +++ b/main.py @@ -0,0 +1,102 @@ +import asyncio +import logging +import os + +from aiogram import Bot, Dispatcher, Router, F +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from aiogram.filters import CommandStart, Command, CommandObject +from aiogram.types import Message, BufferedInputFile +from aiogram.fsm.storage.mongo import MongoStorage +from dotenv import load_dotenv +from motor.motor_asyncio import AsyncIOMotorClient + +# Импорты +from adapters.google_adapter import GoogleAdapter +from middlewares.auth import AuthMiddleware +from middlewares.dao import DaoMiddleware +from repos.char_repo import CharacterRepo +from repos.dao import DAO +from repos.user_repo import UsersRepo +from routers import char_router +# ВАЖНО: Импортируем роутер с логикой кнопок, а не создаем пустой +from routers.auth_router import router as auth_router +from routers.gen_router import router as gen_router +from routers.char_router import router as char_router + + +load_dotenv() + +# Настройки +BOT_TOKEN = os.getenv("BOT_TOKEN") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") +MONGO_HOST = os.getenv("MONGO_HOST") +ADMIN_ID = int(os.getenv("ADMIN_ID")) # Сразу преобразуем в int + +# Инициализация +bot = Bot(token=BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + +# БД +mongo_client = AsyncIOMotorClient(MONGO_HOST) +users_repo = UsersRepo(mongo_client) +char_repo = CharacterRepo(mongo_client) + +# Dispatcher +# Если MongoStorage пока не настроен на authSource=admin, можно временно убрать storage=... +dp = Dispatcher(storage=MongoStorage(mongo_client)) + +# ВНЕДРЕНИЕ ЗАВИСИМОСТЕЙ (чтобы они были доступны в хендлерах) +dp["repo"] = users_repo +dp["admin_id"] = ADMIN_ID +dp["gemini"] = GoogleAdapter(api_key=GEMINI_API_KEY) # Инициализируем тут + +# РОУТИНГ + +# 1. Роутер авторизации (кнопки) - ПОДКЛЮЧАЕМ ПЕРВЫМ И БЕЗ МИДЛВАРИ +dp.include_router(auth_router) +main_router = Router() +dp.include_router(main_router) +dp.include_router(char_router) +dp.include_router(gen_router) + +# 2. Основной роутер (чат с ботом) + +# Вешаем защиту ТОЛЬКО на основной роутер +main_router.message.middleware(AuthMiddleware(repo=users_repo, admin_id=ADMIN_ID)) +gen_router.message.middleware(AuthMiddleware(repo=users_repo, admin_id=ADMIN_ID)) +char_router.message.middleware(DaoMiddleware(dao=DAO(client=mongo_client))) + + + +def setup_logging() -> None: + logging.basicConfig(level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") + + +# --- ХЕНДЛЕРЫ ОСНОВНОГО РОУТЕРА --- +# Переносим их прямо сюда или в отдельный файл routers/chat_router.py +@main_router.message(Command("help")) +async def show_help(message: Message) -> None: + await message.answer("Для того, чтобы обратиться для текстовой генерации - просто отправь промпт.\n\n" + "Для генерации фото - /image {prompt}\n\n" + "Можно отправить фото и команду /image {prompt}\n\n" + "Диалоги не поддерживаются!!!! Каждое новое сообщение - новый диалог") + + +@main_router.message(CommandStart()) +async def cmd_start(message: Message): + await message.answer("👋 Привет! Я готов к работе.\n\n" + "Для того, чтобы обратиться для текстовой генерации - просто отправь промпт.\n\n" + "Для генерации фото - /image {prompt}\n\n" + "Можно отправить фото и команду /image {prompt}\n\n" + "Диалоги не поддерживаются!!!! Каждое новое сообщение - новый диалог" + ) + + +# --- ЗАПУСК --- +if __name__ == "__main__": + setup_logging() + try: + asyncio.run(dp.start_polling(bot)) + except KeyboardInterrupt: + print("Bot stopped") diff --git a/middlewares/__init__.py b/middlewares/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/middlewares/auth.py b/middlewares/auth.py new file mode 100644 index 0000000..adc85f3 --- /dev/null +++ b/middlewares/auth.py @@ -0,0 +1,50 @@ +from typing import Callable, Dict, Any, Awaitable +from aiogram import BaseMiddleware +from aiogram.types import Message +from repos.user_repo import UsersRepo, UserStatus +from keyboards import get_request_kb + + +class AuthMiddleware(BaseMiddleware): + def __init__(self, repo: UsersRepo, admin_id: int): + self.repo = repo + self.admin_id = admin_id + + async def __call__( + self, + handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]], + event: Message, + data: Dict[str, Any] + ) -> Any: + user = event.from_user + + # Админа пускаем всегда + # if user.id == self.admin_id: + # return await handler(event, data) + + # Получаем данные из БД + db_user = await self.repo.get_user(user.id) + status = db_user.get("status") if db_user else UserStatus.NONE + + # 1. Если доступ уже разрешен — пропускаем к боту + if status == UserStatus.ALLOWED: + return await handler(event, data) + + # 2. Если статус PENDING (ждет решения) + if status == UserStatus.PENDING: + await event.answer("⏳ Ваша заявка находится на рассмотрении администратора.") + return + + # 3. Если нет в базе или ЗАПРЕЩЕН — проверяем тайминг 24 часа + can_request = await self.repo.can_request_access(user.id) + + if can_request: + await event.answer( + "⛔️ У вас нет доступа к этому боту.\nВы можете отправить запрос администратору.", + reply_markup=get_request_kb() + ) + else: + # Если 24 часа еще не прошло + await event.answer("⛔️ Доступ запрещен.\nПовторный запрос можно отправить через 24 часа.") + + return # Прерываем обработку, хендлеры бота не сработают \ No newline at end of file diff --git a/middlewares/dao.py b/middlewares/dao.py new file mode 100644 index 0000000..976b64f --- /dev/null +++ b/middlewares/dao.py @@ -0,0 +1,13 @@ +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject + +from repos.dao import DAO + + +class DaoMiddleware(BaseMiddleware): + def __init__(self, dao: DAO): + self._dao = dao + + async def __call__(self, handler, event: TelegramObject, data: dict): + data["dao"] = self._dao + return await handler(event, data) diff --git a/models/Character.py b/models/Character.py new file mode 100644 index 0000000..2b5a055 --- /dev/null +++ b/models/Character.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + + +class Character(BaseModel): + id: int | None + name: str + character_image: bytes + character_bio: str \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repos/__init__.py b/repos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/repos/char_repo.py b/repos/char_repo.py new file mode 100644 index 0000000..5eef2ea --- /dev/null +++ b/repos/char_repo.py @@ -0,0 +1,16 @@ +from motor.motor_asyncio import AsyncIOMotorClient + +from models.Character import Character + + +class CharacterRepo: + def __init__(self, client: AsyncIOMotorClient, db_name="bot_db"): + self.collection = client[db_name]["characters"] + + async def add_character(self, character: Character) -> Character: + op = await self.collection.insert_one(character.model_dump()) + character.id = op.inserted_id + return character + + async def get_character(self, character_id: int) -> Character: + return await self.collection.find_one({"id": character_id}) \ No newline at end of file diff --git a/repos/dao.py b/repos/dao.py new file mode 100644 index 0000000..65fc769 --- /dev/null +++ b/repos/dao.py @@ -0,0 +1,9 @@ +from motor.motor_asyncio import AsyncIOMotorClient + +from repos.char_repo import CharacterRepo +from repos.user_repo import UsersRepo + + +class DAO: + def __init__(self, client: AsyncIOMotorClient, db_name="bot_db"): + self.chars = CharacterRepo(client, db_name) diff --git a/repos/user_repo.py b/repos/user_repo.py new file mode 100644 index 0000000..4f6e405 --- /dev/null +++ b/repos/user_repo.py @@ -0,0 +1,65 @@ +from datetime import datetime, timedelta +from enum import Enum + +from aiogram.types import User +from motor.motor_asyncio import AsyncIOMotorClient + + +class UserStatus: + ALLOWED = "allowed" + DENIED = "denied" + PENDING = "pending" + NONE = "none" # Пользователя нет в базе + + +class UsersRepo: + def __init__(self, client: AsyncIOMotorClient, db_name="bot_db"): + self.collection = client[db_name]["users"] + + async def get_user(self, user_id: int): + return await self.collection.find_one({"user_id": user_id}) + + async def create_or_update_request(self, user: User): + """ + Обновляет дату последнего запроса и ставит статус PENDING. + Сохраняет всю инфу о юзере. + """ + now = datetime.now() + data = { + "user_id": user.id, + "username": user.username, + "full_name": user.full_name, + "status": UserStatus.PENDING, + "last_request_date": now + } + await self.collection.update_one( + {"user_id": user.id}, + {"$set": data}, + upsert=True + ) + + async def set_status(self, user_id: int, status: str): + """Меняет статус (разрешен/запрещен)""" + await self.collection.update_one( + {"user_id": user_id}, + {"$set": {"status": status}} + ) + + async def can_request_access(self, user_id: int) -> bool: + """ + Проверяет, можно ли отправить запрос (прошло ли 24 часа). + Возвращает True, если пользователя нет или прошло > 24ч. + """ + user = await self.get_user(user_id) + if not user: + return True + + last_date = user.get("last_request_date") + if not last_date: + return True + + # Проверка на 24 часа + if datetime.now() - last_date > timedelta(hours=24): + return True + + return False \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c0c6420 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,41 @@ +aiofiles==24.1.0 +aiogram==3.24.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.11.18 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.12.1 +attrs==25.4.0 +certifi==2026.1.4 +cffi==2.0.0 +charset-normalizer==3.4.4 +cryptography==46.0.4 +distro==1.9.0 +dnspython==2.8.0 +frozenlist==1.8.0 +google-auth==2.48.0 +google-genai==1.61.0 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.11 +magic-filter==1.0.12 +motor==3.7.1 +multidict==6.7.1 +pillow==12.1.0 +propcache==0.4.1 +pyasn1==0.6.2 +pyasn1_modules==0.4.2 +pycparser==3.0 +pydantic==2.10.6 +pydantic_core==2.27.2 +pymongo==4.16.0 +python-dotenv==1.2.1 +requests==2.32.5 +rsa==4.9.1 +sniffio==1.3.1 +tenacity==9.1.2 +typing_extensions==4.15.0 +urllib3==2.6.3 +websockets==15.0.1 +yarl==1.22.0 diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routers/auth_router.py b/routers/auth_router.py new file mode 100644 index 0000000..52d3464 --- /dev/null +++ b/routers/auth_router.py @@ -0,0 +1,83 @@ +from aiogram import Router, F, Bot +from aiogram.types import CallbackQuery, Message +from repos.user_repo import UsersRepo, UserStatus +from keyboards import get_admin_decision_kb + +router = Router() + + +# Чтобы IDE не ругалась, переменные инициализируются в main, +# но здесь мы ожидаем, что они будут переданы или доступны через DI (workflow_data) +# В этом примере я буду доставать их из data, переданной диспетчером, или глобально (для простоты примера - глобально в рамках архитектуры aiogram лучше через middleware DI). +# Для простоты доступа предположим, что repo и admin_id прокинуты. + + + +@router.callback_query(F.data == "req_access") +async def user_request_access(callback: CallbackQuery, repo: UsersRepo, bot: Bot, admin_id: int): + """Пользователь нажал 'Запросить доступ'""" + user = callback.from_user + + # Двойная проверка на 24 часа (на случай если пользователь не обновлял сообщение) + if not await repo.can_request_access(user.id): + await callback.answer("⏳ Вы уже отправляли запрос недавно. Повторите через сутки.", show_alert=True) + return + + # 1. Записываем в БД статус PENDING + await repo.create_or_update_request(user) + + # 2. Уведомляем пользователя + await callback.message.edit_text("✅ Заявка отправлена администратору. Ожидайте решения.") + + # 3. Отправляем уведомление Админу + # Формируем красивый текст + info = ( + f"🔔 Новый запрос доступа!\n" + f"👤 Имя: {user.full_name}\n" + f"🆔 ID: {user.id}\n" + f"🔗 Username: @{user.username if user.username else 'нет'}" + ) + + await bot.send_message( + chat_id=admin_id, + text=info, + reply_markup=get_admin_decision_kb(user.id) + ) + await callback.answer() + + +@router.callback_query(F.data.startswith("access_")) +async def admin_decision(callback: CallbackQuery, repo: UsersRepo, bot: Bot): + """Админ нажал Разрешить или Запретить""" + action, user_id_str = callback.data.split("_")[1], callback.data.split("_")[2] + target_user_id = int(user_id_str) + + if action == "allow": + # Обновляем БД + await repo.set_status(target_user_id, UserStatus.ALLOWED) + + # Ответ админу + await callback.message.edit_text(f"✅ Доступ для {target_user_id} РАЗРЕШЕН.") + + # Уведомление пользователю + try: + await bot.send_message(target_user_id, "🎉 Вам предоставлен доступ к боту!\nНажмите /start") + except: + await callback.message.answer( + f"⚠️ Не удалось уведомить пользователя {target_user_id} (он заблокировал бота?)") + + elif action == "deny": + # Обновляем БД (статус DENIED, дата запроса остается старой - то есть через сутки он сможет снова попросить) + # Если хотите, чтобы при отказе таймер 24ч сбрасывался на "сейчас", нужно обновить last_request_date. + # В текущей реализации repo, мы просто меняем статус. Таймер тикает от момента подачи заявки пользователем. + + await repo.set_status(target_user_id, UserStatus.DENIED) + + await callback.message.edit_text(f"🚫 Доступ для {target_user_id} ЗАПРЕЩЕН.") + + try: + await bot.send_message(target_user_id, "🚫 Администратор отклонил ваш запрос доступа.") + except: + pass + + await callback.answer() \ No newline at end of file diff --git a/routers/char_router.py b/routers/char_router.py new file mode 100644 index 0000000..9d43604 --- /dev/null +++ b/routers/char_router.py @@ -0,0 +1,48 @@ +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import * +from aiogram import Router, F + +from models.Character import Character +from repos.dao import DAO + +router = Router() + + +class States(StatesGroup): + char_wait_name = State() + char_wait_bio = State() + + +@router.message(F.document, Command("add_char")) +async def add_char(message: Message, state: FSMContext, dao: DAO): + await state.set_data({"photo": bot.download(file=message.document.file_id)}) + await state.set_state(States.char_wait_name) + await message.answer("Кайф, теперь напиши ее имя") + + +@router.callback_query(States.char_wait_name) +async def new_char_name(message: Message, state: FSMContext, dao: DAO): + await state.set_data({"name": message.text}) + await state.set_state(States.char_wait_bio) + await message.answer("А теперь напиши био. Хоть чуть чуть.") + + +@router.callback_query(States.char_wait_bio) +async def new_char_bio(message: Message, state: FSMContext, dao: DAO): + data = await state.get_data() + photo = data["photo"] + name = data["name"] + char = Character(id=None, name=name, character_image=photo, character_bio=message.text) + await dao.chars.add_character(char) + await message.answer_photo(photo=BufferedInputFile(char.character_image, "img.png"), caption="Персонаж создан!\n" + f"Имя:{char.name}\n" + f"Био: {char.character_bio}\n" + ) + + +@router.message(Command("add_char")) +async def add_char_cmd(message: Message): + await message.answer( + "Добавление персонажа производится через отправку документа-фото исходного изображения персонажа.") diff --git a/routers/gen_router.py b/routers/gen_router.py new file mode 100644 index 0000000..27a1ffd --- /dev/null +++ b/routers/gen_router.py @@ -0,0 +1,55 @@ +import asyncio + +from aiogram import Router, Bot, F +from aiogram.enums import ParseMode +from aiogram.filters import * +from aiogram.types import * + +from adapters.google_adapter import GoogleAdapter + +router = Router() + +@router.message(Command("image")) +async def cmd_image_gen(message: Message, command: CommandObject, gemini: GoogleAdapter, bot: Bot): + # ... ваш код ... + # Обратите внимание: gemini теперь прилетает аргументом, так как мы сделали dp["gemini"] + prompt = command.args + if not prompt: + await message.answer("⚠️ Напиши промпт.") + return + + wait_msg = await message.answer("🎨 Генерирую...") + + # Получение байтов фото (логика та же) + image_bytes = None + if message.photo: + file_io = await bot.download(message.photo[-1].file_id) + image_bytes = file_io.getvalue() + elif message.reply_to_message and message.reply_to_message.photo: + file_io = await bot.download(message.reply_to_message.photo[-1].file_id) + image_bytes = file_io.getvalue() + + result = await asyncio.to_thread( + gemini.generate, prompt=prompt, image_bytes=image_bytes, generate_image=True + ) + + await wait_msg.delete() + + if result.get("images"): + for img in result["images"]: + await message.answer_document(BufferedInputFile(img.read(), "img.png")) + elif result.get("text"): + await message.answer(result["text"]) + else: + await message.answer(f"Ошибка: {result.get('error', 'Unknown')}") + + +@router.message(F.text) +async def handle_text(message: Message, gemini: GoogleAdapter, bot: Bot): + await bot.send_chat_action(message.chat.id, "typing") + result = await asyncio.to_thread(gemini.generate, prompt=message.text) + if result.get("text"): + await message.answer(result["text"], parse_mode=ParseMode.MARKDOWN) + else: + await message.answer("Ошибка генерации") +