import os import threading import hashlib import asyncio import io import aiohttp import random import uuid from flask import Flask, request from vkbottle import Keyboard, KeyboardButtonColor, Text, OpenLink, PhotoMessageUploader, API from vkbottle.bot import Bot, Message from supabase import create_client, Client from loguru import logger from dotenv import load_dotenv load_dotenv() # Импортируем вспомогательные файлы import prompts import generator # --- КОНФИГУРАЦИЯ --- TOKEN = os.environ.get("VK_TOKEN") SECOND_GROUP_TOKEN = os.environ.get("SECOND_GROUP_TOKEN") # Ключ из второй группы YOOMONEY_SECRET = os.environ.get("YOOMONEY_SECRET") SUPABASE_URL = os.environ.get("SUPABASE_URL") SUPABASE_KEY = os.environ.get("SUPABASE_KEY") YOOMONEY_WALLET = "4100119452661463" GROUP_ID = 226495679 ADDITIONAL_GROUP_ID = 218575968 # Вторая группа SUPPORT_URL = "https://vk.me/olga_luchnaya" BUCKET_NAME = "photos" # Название бакета в Supabase Storage # Инициализация бота bot = Bot(token=TOKEN) # Отдельное API для проверки подписки на вторую группу second_bot_api = API(token=SECOND_GROUP_TOKEN) uploader = PhotoMessageUploader(bot.api) supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) app = Flask(__name__) loop = None logger.add("bot_log.log", rotation="10 MB", level="INFO") # --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- async def upload_to_bucket(url, user_id, prefix="temp"): """Скачивает фото и временно сохраняет в Supabase Storage. Возвращает путь и публичную ссылку.""" try: async with aiohttp.ClientSession() as session: async with session.get(url) as resp: if resp.status == 200: img_data = await resp.read() file_name = f"{user_id}/{prefix}_{uuid.uuid4()}.png" supabase.storage.from_(BUCKET_NAME).upload( path=file_name, file=img_data, file_options={"content-type": "image/png"} ) # Генерируем публичную ссылку res = supabase.storage.from_(BUCKET_NAME).get_public_url(file_name) return file_name, res except Exception as e: logger.error(f"Ошибка загрузки в бакет ({prefix}): {e}") return None, None async def download_file(url): """Скачивает файл по URL и возвращает BytesIO для загрузки в ВК""" try: async with aiohttp.ClientSession() as session: async with session.get(url) as resp: if resp.status == 200: data = await resp.read() return io.BytesIO(data) except Exception as e: logger.error(f"Ошибка скачивания файла для ВК: {e}") return None def get_user(user_id): res = supabase.table("users").select("*").eq("id", user_id).execute() if not res.data: new_user = { "id": user_id, "balance": 0, "free_used": False, "status": "main", "gender": None, "primary_photo": None, "last_style": None, "temp_promo": None } supabase.table("users").insert(new_user).execute() return new_user return res.data[0] async def is_subscribed(user_id): try: res = await bot.api.groups.is_member(group_id=GROUP_ID, user_id=user_id) return bool(res) except Exception as e: logger.error(f"Ошибка проверки подписки: {e}") return True async def is_subscribed_both(user_id): """Проверка подписки на оба сообщества для промокода""" try: res1 = await bot.api.groups.is_member(group_id=GROUP_ID, user_id=user_id) res2 = await second_bot_api.groups.is_member(group_id=ADDITIONAL_GROUP_ID, user_id=user_id) return bool(res1) and bool(res2) except Exception as e: logger.error(f"Ошибка проверки двойной подписки: {e}") return False # --- КЛАВИАТУРЫ --- def get_main_menu(free_used=False): kb = Keyboard(one_time=False) if not free_used: kb.add(Text("🎁 Попробовать бесплатно"), color=KeyboardButtonColor.POSITIVE).row() kb.add(Text("📸 Выбрать стиль фото"), color=KeyboardButtonColor.PRIMARY).row() kb.add(Text("🎨 Режим творца"), color=KeyboardButtonColor.PRIMARY).row() kb.add(Text("🔄 Сменить пол"), color=KeyboardButtonColor.SECONDARY) kb.add(Text("🔄 Сменить фото"), color=KeyboardButtonColor.SECONDARY).row() kb.add(Text("💰 Баланс"), color=KeyboardButtonColor.SECONDARY) kb.add(Text("❓ Как это работает?"), color=KeyboardButtonColor.SECONDARY).row() kb.add(Text("🎟 Промокод"), color=KeyboardButtonColor.PRIMARY) kb.add(OpenLink(SUPPORT_URL, "🆘 Поддержка")) return kb.get_json() def get_promo_check_kb(): """Клавиатура с кнопками подписки и проверки""" kb = Keyboard(inline=True) kb.add(OpenLink(f"https://vk.com/club{GROUP_ID}", "📢 Подписаться 1")) kb.add(OpenLink(f"https://vk.com/club{ADDITIONAL_GROUP_ID}", "📢 Подписаться 2")) kb.row() kb.add(Text("✅ Я подписан(а)"), color=KeyboardButtonColor.POSITIVE) return kb.get_json() def get_fast_packs_menu(): kb = Keyboard(inline=True) packs = supabase.table("settings").select("*").eq("is_premium", False).order("price").execute().data for i, pack in enumerate(packs): kb.add(Text(f"📸 {pack['name']} - {int(pack['price'])} р")) if (i + 1) % 2 == 0: kb.row() if len(packs) % 2 != 0: kb.row() kb.add(Text("⬅️ В главное меню"), color=KeyboardButtonColor.SECONDARY) return kb.get_json() def get_gender_menu(): return (Keyboard(inline=True) .add(Text("🙋‍♂️ Мужской"), color=KeyboardButtonColor.PRIMARY) .add(Text("🙋‍♀️ Женский"), color=KeyboardButtonColor.PRIMARY) ).get_json() def get_styles_menu(gender): kb = Keyboard(one_time=False, inline=False) g = gender if gender else "female" if g in prompts.FACE_SWAP_STYLES: styles_list = [s for s in prompts.FACE_SWAP_STYLES[g].keys() if s != "🎁 Попробовать бесплатно"] for i, style in enumerate(styles_list): kb.add(Text(style)) if (i + 1) % 2 == 0: kb.row() if len(styles_list) % 2 != 0: kb.row() kb.add(Text("⬅️ В главное меню"), color=KeyboardButtonColor.SECONDARY) return kb.get_json() # --- ОБРАБОТЧИКИ --- @bot.on.message(text=["Начать", "начать", "Меню", "start", "⬅️ В главное меню"]) async def start_handler(message: Message): user = get_user(message.from_id) supabase.table("users").update({"status": "main"}).eq("id", message.from_id).execute() if not user['free_used']: msg = ("Привет 👋\n" "Добро пожаловать в твоего персонального нейрофотографа!\n\n" "🎁Для тебя есть подарок — 1 бесплатная генерация.\n" "Нажми кнопку «Попробовать бесплатно»👇\n\n") else: msg = ("Выбирай режим в меню ниже, чтобы создать новые шедевры ✨") await message.answer(msg, keyboard=get_main_menu(user['free_used'])) @bot.on.message(text="❓ Как это работает?") async def help_handler(message: Message): user = get_user(message.from_id) help_text = ( "⚙️ ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ\n\n" "🎁 1. Бесплатный старт\n" "Для знакомства с сервисом тебе доступна 1 тестовая генерация.\n" "Она поможет оценить качество и стиль фотографий.\n\n" "⚡ 2. Выбор стиля фото\n" "Нейросеть переносит твоё лицо с одного селфи на готовый шаблон.\n" "Идеально, если нужен быстрый и эффектный результат.\n\n" "🎨 3. Режим творца\n" "Опиши любой образ или идею — нейросеть создаст фото по твоему сценарию.\n" "Полная свобода для фантазии и экспериментов.\n\n" "⚠️ ВАЖНО\n" "Присылай чёткие фото лица: без очков, головных уборов и посторонних предметов." ) await message.answer(help_text, keyboard=get_main_menu(user['free_used'])) @bot.on.message(text="🎟 Промокод") async def promo_handler(message: Message): supabase.table("users").update({"status": "wait_promo"}).eq("id", message.from_id).execute() await message.answer("Введите промокод:") @bot.on.message(text="✅ Я подписан(а)") async def promo_check_after_sub(message: Message): user = get_user(message.from_id) if user['status'] != "check_subs_for_promo" or not user.get('temp_promo'): return await start_handler(message) if await is_subscribed_both(message.from_id): return await apply_promo_logic(message, user['temp_promo'], user) else: await message.answer("Вы всё еще не подписаны на оба сообщества! 📢", keyboard=get_promo_check_kb()) async def apply_promo_logic(message: Message, code: str, user: dict): """Вспомогательная функция для начисления промокода""" res = supabase.table("promocodes").select("*").eq("code", code).execute() if not res.data: return promo = res.data[0] # Начисляем баланс supabase.rpc("increment_balance", {"user_id": message.from_id, "amount": promo['amount']}).execute() # Уменьшаем лимит промокода supabase.table("promocodes").update({"uses_left": promo['uses_left'] - 1}).eq("code", code).execute() # Записываем историю supabase.table("promocode_history").insert({"user_id": message.from_id, "code": code}).execute() # Сбрасываем статус supabase.table("users").update({"status": "main", "temp_promo": None}).eq("id", message.from_id).execute() await message.answer(f"✅ Промокод активирован! Начислено генераций: {promo['amount']}", keyboard=get_main_menu(user['free_used'])) @bot.on.message(text=["🎁 Попробовать бесплатно", "✅ Я подписался"]) async def free_trial_handler(message: Message): user = get_user(message.from_id) if user['free_used']: return await message.answer("Вы уже использовали бесплатную попытку!", keyboard=get_main_menu(True)) if not user['gender']: supabase.table("users").update({"status": "wait_gender_free"}).eq("id", message.from_id).execute() return await message.answer("Сначала выберите ваш пол:", keyboard=get_gender_menu()) if not user['primary_photo']: supabase.table("users").update({"status": "wait_primary_photo"}).eq("id", message.from_id).execute() return await message.answer("📸 Загрузи 1 фото для бесплатной генерации\n\n" "Пожалуйста, обрати внимание на требования:\n" "• в кадре только ты\n" "• лицо крупное и чёткое\n" "• без очков, головных уборов и фильтров\n" "• без других людей в кадре\n\n" "Я создам для тебя стильное нейро-фото ✨\n\n" "Загрузи фотографию прямо сейчас ⬇️") if not await is_subscribed(message.from_id): subscribe_kb = ( Keyboard(inline=True) .add(OpenLink(f"https://vk.com/club{GROUP_ID}", "📢 Подписаться")) .row() .add(Text("✅ Я подписался"), color=KeyboardButtonColor.POSITIVE) ).get_json() return await message.answer( "🎁 Для бесплатной генерации подпишись на наше сообщество!\n\n" "После подписки нажмите кнопку «✅ Я подписался» ниже 👇", keyboard=subscribe_kb ) await message.answer("⏳ Создаю фотографию... Это займет около 30 секунд.") gender = user['gender'] prompt = prompts.FACE_SWAP_STYLES[gender]["🎁 Попробовать бесплатно"] # --- Логика Бакет --- paths_to_clean = [] bucket_path, temporary_url = await upload_to_bucket(user['primary_photo'], message.from_id, "src") if bucket_path: paths_to_clean.append(bucket_path) res_url = await generator.generate_image( prompt=prompt, gender=gender, photo_url=temporary_url if temporary_url else user['primary_photo'], lora_model=None ) if res_url: # Сохраняем результат в бакет для ВК res_path, res_url_bucket = await upload_to_bucket(res_url, message.from_id, "res") if res_path: paths_to_clean.append(res_path) if res_url_bucket: try: # Внедрение фикса: скачиваем файл как BytesIO file_stream = await download_file(res_url_bucket) photo_vk = await uploader.upload(file_stream, file_name="result.png") await message.answer("Ваше фото готово! ✨", attachment=photo_vk) promo_text = ( "Теперь ты можешь выбрать режим:\n\n" "📸Выбор стиля фото\n" "Создание профессионального нейро-фото по одной твоей фотографии лица. " "Идеально, если нужен быстрый и эффектный результат.\n\n" "🎨Режим творца\n" "Создавай любые сюжеты для фото. Полная свобода для твоей фантазии 🎭\n\n" "Попробуем?\n" "Выбирай режим в меню ниже 📸💫" ) await message.answer(promo_text, keyboard=get_main_menu(True)) # ВАЖНО: primary_photo НЕ удаляется, чтобы сохранить ссылку в БД supabase.table("users").update({"free_used": True}).eq("id", message.from_id).execute() except Exception as e: logger.error(f"Ошибка в free_trial VK upload: {e}") await message.answer("Техническая ошибка при отправке фото.") else: await message.answer("Ошибка при обработке результата.") else: await message.answer("Произошла ошибка при генерации. Попробуйте другое фото.") # Чистка бакета if paths_to_clean: supabase.storage.from_(BUCKET_NAME).remove(paths_to_clean) @bot.on.message(text=["💰 Баланс", "💰 Баланс / Профиль"]) async def balance_handler(message: Message): user = get_user(message.from_id) photo_st = "Есть" if user['primary_photo'] else "Нет" gender_ru = "Не выбран" if user['gender'] == "male": gender_ru = "Мужской" elif user['gender'] == "female": gender_ru = "Женский" kb = Keyboard() kb.add(Text("🛒 Купить генерации")).row() kb.add(Text("⬅️ В главное меню")) await message.answer( f"🪙 Баланс: {user['balance']} генераций\n" f"👤 Пол: {gender_ru}\n" f"🖼 Закрепленная фотография: {photo_st}", keyboard=kb.get_json() ) @bot.on.message(text="🛒 Купить генерации") async def shop_handler(message: Message): await message.answer("Выберите количество генераций:", keyboard=get_fast_packs_menu()) @bot.on.message(text="📸 Выбрать стиль фото") async def styles_handler(message: Message): user = get_user(message.from_id) if not user['gender']: return await message.answer("Сначала выберите пол:", keyboard=get_gender_menu()) await message.answer("Здесь ты можешь создать стильное фото за 30 секунд.\n\n" "В каждом стиле доступно 10 разных вариантов фотографий.\n" "При каждой генерации варианты выбираются случайно.\n\n" "✨ Важно знать\n" "Даже если выбрать один и тот же стиль несколько раз,\n" "ты каждый раз будешь получать разные фотографии.\n\n" "🎭 Выбери стиль\n", keyboard=get_styles_menu(user['gender'])) @bot.on.message(text="🎨 Режим творца") async def creator_mode(message: Message): user = get_user(message.from_id) if user['balance'] <= 0: return await message.answer("🪙 Недостаточно генераций для режима творца. Пополните баланс:", keyboard=get_fast_packs_menu()) supabase.table("users").update({"status": "wait_custom_prompt"}).eq("id", message.from_id).execute() kb = Keyboard(inline=True).add(Text("⬅️ В главное меню"), color=KeyboardButtonColor.SECONDARY).get_json() await message.answer( "Здесь вы можете создавать образы по своему запросу: стиль, одежда, фон, настроение. Напишите запрос ниже👇", keyboard=kb) # --- ГЛАВНЫЕ ОБРАБОТЧИКИ --- @bot.on.message() async def main_handler(message: Message): user = get_user(message.from_id) text = message.text if text == "⬅️ В главное меню": supabase.table("users").update({"status": "main"}).eq("id", message.from_id).execute() return await start_handler(message) # ЛОГИКА ПРОМОКОДОВ if user['status'] == "wait_promo": code = text.strip().upper() # Проверяем существование и лимит res = supabase.table("promocodes").select("*").eq("code", code).eq("active", True).execute() if not res.data or res.data[0]['uses_left'] <= 0: supabase.table("users").update({"status": "main"}).eq("id", message.from_id).execute() return await message.answer("❌ Промокод не существует или устарел", keyboard=get_main_menu(user['free_used'])) # Проверяем, не использовал ли уже пользователь этот код hist = supabase.table("promocode_history").select("*").eq("user_id", message.from_id).eq("code", code).execute() if hist.data: supabase.table("users").update({"status": "main"}).eq("id", message.from_id).execute() return await message.answer("⛔ Вы уже использовали этот промокод!", keyboard=get_main_menu(user['free_used'])) # Проверка подписки на две группы if await is_subscribed_both(message.from_id): return await apply_promo_logic(message, code, user) else: # Сохраняем код в temp_promo, чтобы начислить после подписки supabase.table("users").update({"status": "check_subs_for_promo", "temp_promo": code}).eq("id", message.from_id).execute() return await message.answer("Для активации промокода нужно подписаться на два наших сообщества! 👇", keyboard=get_promo_check_kb()) # 1. Оплата packs = supabase.table("settings").select("*").execute().data for pack in packs: if pack['name'] in text and str(int(pack['price'])) in text: pay_url = ( f"https://yoomoney.ru/quickpay/confirm.xml?" f"receiver={YOOMONEY_WALLET}&" f"quickpay-form=button&" f"sum={pack['price']}&" f"label={message.from_id}&" f"targets=Оплата+пакета+{pack['name'].replace(' ', '+')}" ) kb = Keyboard(inline=True).add(OpenLink(pay_url, "💳 Перейти к оплате")).get_json() return await message.answer(f"Вы выбрали: {pack['name']}\nК оплате: {int(pack['price'])} руб.", keyboard=kb) # 2. Пол if text in ["🙋‍♂️ Мужской", "🙋‍♀️ Женский"]: g = "male" if "Мужской" in text else "female" supabase.table("users").update({"gender": g}).eq("id", message.from_id).execute() if user['status'] == "wait_gender_change": supabase.table("users").update({"status": "main"}).eq("id", message.from_id).execute() return await message.answer("✅ Изменения сохранены!", keyboard=get_main_menu(user['free_used'])) if not user['primary_photo']: supabase.table("users").update({"status": "wait_primary_photo"}).eq("id", message.from_id).execute() return await message.answer( "📸 Загрузи 1 фото для бесплатной генерации\n\n" "Загрузи фотографию прямо сейчас ⬇️" ) else: supabase.table("users").update({"status": "main"}).eq("id", message.from_id).execute() return await message.answer(f"✅ Пол сохранен!", keyboard=get_main_menu(user['free_used'])) if text == "🔄 Сменить пол": supabase.table("users").update({"status": "wait_gender_change"}).eq("id", message.from_id).execute() return await message.answer("Выберите ваш пол:", keyboard=get_gender_menu()) if text == "🔄 Сменить фото": supabase.table("users").update({"status": "wait_primary_photo"}).eq("id", message.from_id).execute() return await message.answer("Пришлите новое четкое фото лица:") # 5. Прием фото if user['status'] == "wait_primary_photo" or (message.attachments and not user['primary_photo']): if message.attachments: photo_sizes = message.attachments[0].photo.sizes photo_url = sorted(photo_sizes, key=lambda x: x.width)[-1].url supabase.table("users").update({"primary_photo": photo_url, "status": "main"}).eq("id", message.from_id).execute() if not user['free_used']: await message.answer("⏳ Создаю фотографию... Это займет около 30 секунд.") gender = user['gender'] or "female" prompt = prompts.FACE_SWAP_STYLES[gender]["🎁 Попробовать бесплатно"] # --- Бакет --- paths_to_clean = [] bucket_path, temporary_url = await upload_to_bucket(photo_url, message.from_id, "src") if bucket_path: paths_to_clean.append(bucket_path) res_url = await generator.generate_image(prompt=prompt, gender=gender, photo_url=temporary_url if temporary_url else photo_url) if res_url: res_path, res_url_bucket = await upload_to_bucket(res_url, message.from_id, "res") if res_path: paths_to_clean.append(res_path) if res_url_bucket: try: # Внедрение фикса: скачиваем файл как BytesIO file_stream = await download_file(res_url_bucket) photo_vk = await uploader.upload(file_stream, file_name="result.png") await message.answer("Ваше фото готово! ✨", attachment=photo_vk) # ВАЖНО: primary_photo НЕ удаляется здесь supabase.table("users").update({"free_used": True}).eq("id", message.from_id).execute() if paths_to_clean: supabase.storage.from_(BUCKET_NAME).remove(paths_to_clean) return await message.answer("Выбирай режим в меню ниже 📸💫", keyboard=get_main_menu(True)) except Exception as e: logger.error(f"VK Error: {e}") if paths_to_clean: supabase.storage.from_(BUCKET_NAME).remove(paths_to_clean) return await message.answer("Произошла ошибка при генерации.") return await message.answer("✅ Фото лица принято!", keyboard=get_main_menu(user['free_used'])) # 6. ГЕНЕРАЦИЯ (СТИЛИ + РЕЖИМ ТВОРЦА) gender = user['gender'] or "female" styles_dict = prompts.FACE_SWAP_STYLES.get(gender, {}) if text in styles_dict or user['status'] == "wait_custom_prompt": if user['balance'] <= 0: return await message.answer("🪙 Недостаточно генераций. Пополните баланс:", keyboard=get_fast_packs_menu()) if not user['primary_photo']: supabase.table("users").update({"status": "wait_primary_photo"}).eq("id", message.from_id).execute() return await message.answer("Сначала отправьте фото вашего лица") if text in styles_dict: options = styles_dict[text] last_val = user.get('last_style') or "" last_idx = -1 if "|" in last_val and last_val.split("|")[0] == text: try: last_idx = int(last_val.split("|")[1]) except: last_idx = -1 available_indices = [i for i in range(len(options)) if i != last_idx] new_idx = random.choice(available_indices) prompt = options[new_idx] new_last_style = f"{text}|{new_idx}" else: prompt = text new_last_style = "custom_prompt|0" await message.answer("🎨 Генерирую образ... Пожалуйста, подождите.") # --- Бакет --- paths_to_clean = [] bucket_path, temporary_url = await upload_to_bucket(user['primary_photo'], message.from_id, "src") if bucket_path: paths_to_clean.append(bucket_path) res_url = await generator.generate_image( prompt=prompt, gender=gender, photo_url=temporary_url if temporary_url else user['primary_photo'] ) if res_url: res_path, res_url_bucket = await upload_to_bucket(res_url, message.from_id, "res") if res_path: paths_to_clean.append(res_path) if res_url_bucket: try: # Внедрение фикса: скачиваем файл как BytesIO file_stream = await download_file(res_url_bucket) photo_vk = await uploader.upload(file_stream, file_name="style.png") await message.answer("Готово! ✨", attachment=photo_vk) supabase.rpc("increment_balance", {"user_id": message.from_id, "amount": -1}).execute() # ВАЖНО: primary_photo: None УДАЛЕНО, чтобы фото осталось в профиле supabase.table("users").update( {"last_style": new_last_style, "status": "main"}).eq("id", message.from_id).execute() except Exception as e: logger.error(f"VK Style Error: {e}") await message.answer("ВК не принял фото. Баланс сохранен.") else: await message.answer("Ошибка нейросети. Попробуйте другой запрос.") if paths_to_clean: supabase.storage.from_(BUCKET_NAME).remove(paths_to_clean) supabase.table("users").update({"status": "main"}).eq("id", message.from_id).execute() return await message.answer("Воспользуйтесь меню 👇", keyboard=get_main_menu(user['free_used'])) @app.route('/yoomoney', methods=['POST']) def yoomoney_webhook(): d = request.form check = f"{d.get('notification_type')}&{d.get('operation_id')}&{d.get('amount')}&{d.get('currency')}&{d.get('datetime')}&{d.get('sender')}&{d.get('codepro')}&{YOOMONEY_SECRET}&{d.get('label')}" my_hash = hashlib.sha1(check.encode('utf-8')).hexdigest() if my_hash == d.get('sha1_hash'): try: u_id = int(d.get('label')) received_amount = float(d.get('withdraw_amount') or d.get('amount')) packs = supabase.table("settings").select("*").execute().data for pack in packs: if abs(received_amount - float(pack['price'])) / float(pack['price']) < 0.05: supabase.rpc("increment_balance", {"user_id": u_id, "amount": pack['amount']}).execute() if loop: asyncio.run_coroutine_threadsafe(bot.api.messages.send(peer_id=u_id, random_id=0, message=f"✅ Оплата прошла успешно! Пакет «{pack['name']}» активирован."), loop) break except Exception as e: logger.error(f"Webhook error: {e}") return "OK", 200 @app.route('/') def index(): return "

VK Bot is Running

", 200 if __name__ == "__main__": loop = asyncio.get_event_loop() threading.Thread(target=lambda: app.run(host="0.0.0.0", port=7860, use_reloader=False), daemon=True).start() bot.run_forever()