-
+ {% for error in errors %}
+
- {{ error }} + {% endfor %} +
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..767e7bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +__pycache__/ +*.pyc +users.db +venv/ +cabinet_service/media/ +.idea/ diff --git a/__pycache__/config.cpython-314.pyc b/__pycache__/config.cpython-314.pyc deleted file mode 100644 index ee8b1fa..0000000 Binary files a/__pycache__/config.cpython-314.pyc and /dev/null differ diff --git a/__pycache__/database.cpython-314.pyc b/__pycache__/database.cpython-314.pyc deleted file mode 100644 index 04b736c..0000000 Binary files a/__pycache__/database.cpython-314.pyc and /dev/null differ diff --git a/ai_core/__pycache__/neural_network.cpython-314.pyc b/ai_core/__pycache__/neural_network.cpython-314.pyc deleted file mode 100644 index 7df867f..0000000 Binary files a/ai_core/__pycache__/neural_network.cpython-314.pyc and /dev/null differ diff --git a/ai_core/image_generator.py b/ai_core/image_generator.py new file mode 100644 index 0000000..77ee336 --- /dev/null +++ b/ai_core/image_generator.py @@ -0,0 +1,89 @@ +import base64 +import struct + +import requests + +from config import PIAPI_API_KEY, PIAPI_IMAGE_MODEL, get_proxies + +PIAPI_IMAGE_URL = "https://api.piapi.ai/v1/images/generations" + +# Доступные размеры (gpt-image-1) +IMAGE_SIZES = { + "square": ("1024x1024", "Квадрат 1024x1024"), + "wide": ("1536x1024", "Горизонтальный 1536x1024"), + "tall": ("1024x1536", "Вертикальный 1024x1536"), +} + +# Пресеты стилей - дописываются к промпту +STYLE_PRESETS = { + "none": ("Без стиля", ""), + "photo": ("Фотореализм", ", ultra realistic photo, professional photography, natural lighting"), + "illustration": ("Иллюстрация", ", digital illustration, vibrant colors, detailed"), + "minimal": ("Минимализм", ", minimalist style, clean background, simple composition"), + "3d": ("3D-рендер", ", 3D render, soft shadows, modern design"), + "flat": ("Плоская графика", ", flat vector illustration, bold shapes, social media style"), +} + + +def detect_ext(data: bytes) -> str: + """Определяет расширение файла по magic-байтам.""" + if data[:8] == b"\x89PNG\r\n\x1a\n": + return ".png" + if data[:2] == b"\xff\xd8": + return ".jpg" + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return ".webp" + if data[:4] == b"GIF8": + return ".gif" + return ".png" + + +class ImageGenerator: + """Генерация изображений через PiAPI (OpenAI-совместимый /images/generations).""" + + def __init__(self, model: str | None = None): + self.model = model or PIAPI_IMAGE_MODEL + self.proxies = get_proxies() + + def generate(self, prompt: str, size: str = "square") -> bytes: + """Генерирует картинку и возвращает её байты. Поднимает исключение при ошибке.""" + if size not in IMAGE_SIZES: + size = "square" + size_value = IMAGE_SIZES[size][0] + + response = requests.post( + PIAPI_IMAGE_URL, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {PIAPI_API_KEY}", + }, + json={ + "model": self.model, + "prompt": prompt, + "n": 1, + "size": size_value, + }, + proxies=self.proxies, + timeout=240, + ) + response.raise_for_status() + + data = response.json().get("data") or [] + if not data: + raise RuntimeError("Пустой ответ от API картинок") + + item = data[0] + if item.get("b64_json"): + return base64.b64decode(item["b64_json"]) + if item.get("url"): + img_response = requests.get(item["url"], proxies=self.proxies, timeout=120) + img_response.raise_for_status() + return img_response.content + + raise RuntimeError("Неожиданный формат ответа от API картинок") + + def build_prompt(self, prompt: str, style: str = "none") -> str: + """Дополняет промпт пресетом стиля.""" + if style in STYLE_PRESETS: + return prompt.strip() + STYLE_PRESETS[style][1] + return prompt.strip() diff --git a/ai_core/neural_network.py b/ai_core/neural_network.py index b4e8b96..ef437e2 100644 --- a/ai_core/neural_network.py +++ b/ai_core/neural_network.py @@ -1,28 +1,26 @@ import random import os -from groq import Groq -from config import GROQ_API_KEY +import json +import requests +from config import PIAPI_API_KEY, PIAPI_MODEL, get_proxies + +PIAPI_URL = "https://api.piapi.ai/v1/chat/completions" class ContentGenerator: def __init__(self): """Инициализация генератора контента""" print("🔄 Инициализация ContentGenerator...") - - # Проверяем, есть ли ключ Groq - if GROQ_API_KEY: - try: - self.client = Groq(api_key=GROQ_API_KEY) - self.model = "llama-3.3-70b-versatile" - self.use_groq = True - print("✅ Groq API подключен!") - except Exception as e: - print(f"⚠️ Ошибка подключения Groq: {e}") - self.use_groq = False + + # Проверяем, есть ли ключ PiAPI + if PIAPI_API_KEY: + self.model = PIAPI_MODEL + self.use_piapi = True + print("✅ PiAPI подключен!") else: - print("⚠️ Groq API ключ не найден, использую шаблоны") - self.use_groq = False + print("⚠️ PiAPI ключ не найден, использую шаблоны") + self.use_piapi = False - # Шаблоны для запасного варианта (если Groq не работает) + # Шаблоны для запасного варианта (если PiAPI не работает) self.templates = { 'instagram': [ "🔥 {business_name} — это не просто бизнес, это миссия!\n\n" @@ -116,22 +114,22 @@ class ContentGenerator: """ print(f"🔄 Генерация поста для {platform}...") - # Пробуем сгенерировать через Groq - if self.use_groq: + # Пробуем сгенерировать через PiAPI + if self.use_piapi: try: - post = self._generate_with_groq(business_type, business_description, platform) + post = self._generate_with_piapi(business_type, business_description, platform) if post and len(post) > 20: - print("✅ Пост сгенерирован через Groq!") + print("✅ Пост сгенерирован через PiAPI!") return post except Exception as e: - print(f"⚠️ Ошибка Groq: {e}, использую шаблоны") - - # Если Groq не работает - используем шаблоны + print(f"⚠️ Ошибка PiAPI: {e}, использую шаблоны") + + # Если PiAPI не работает - используем шаблоны print("🔄 Использую шаблоны...") return self._generate_with_templates(business_type, business_description, platform) - - def _generate_with_groq(self, business_type, business_description, platform): - """Генерация через Groq API""" + + def _generate_with_piapi(self, business_type, business_description, platform): + """Генерация через PiAPI (OpenAI-совместимый chat/completions)""" platform_names = { 'instagram': 'Instagram', @@ -160,18 +158,163 @@ class ContentGenerator: Пост должен быть уникальным и интересным. """ - response = self.client.chat.completions.create( - model=self.model, - messages=[ - {"role": "system", "content": "Ты профессиональный копирайтер. Пиши ярко, полезно и вовлекающе."}, - {"role": "user", "content": prompt} - ], - temperature=0.9, - max_tokens=500 + response = requests.post( + PIAPI_URL, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {PIAPI_API_KEY}" + }, + json={ + "model": self.model, + "messages": [ + {"role": "system", "content": "Ты профессиональный копирайтер. Пиши ярко, полезно и вовлекающе."}, + {"role": "user", "content": prompt} + ], + "temperature": 0.9, + "max_tokens": 500 + }, + proxies=get_proxies(), + timeout=30 ) - - return response.choices[0].message.content.strip() - + response.raise_for_status() + data = response.json() + + return data["choices"][0]["message"]["content"].strip() + + def generate_post_with_style(self, business_type, business_description, platform, style, wishes): + """Генерирует пост с учётом стиля и пожеланий пользователя""" + print(f"🔄 Генерация поста в своём стиле для {platform}...") + + if self.use_piapi: + try: + platform_names = { + 'instagram': 'Instagram', + 'linkedin': 'LinkedIn', + 'telegram': 'Telegram', + 'twitter': 'Twitter/X', + 'ad': 'рекламный пост' + } + + prompt = f""" +Ты — профессиональный SMM-специалист и копирайтер. + +Напиши пост для {platform_names.get(platform, 'соцсетей')} о бизнесе. + +Тип бизнеса: {business_type} +Описание бизнеса: {business_description} +Стиль поста: {style} +Пожелания к посту: {wishes} + +Требования к посту: +- Длина: 100-200 слов +- Строго придерживайся указанного стиля и пожеланий +- Призыв к действию в конце +- Используй 2-3 эмодзи + +Напиши только текст поста, без лишних пояснений. +""" + + response = requests.post( + PIAPI_URL, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {PIAPI_API_KEY}" + }, + json={ + "model": self.model, + "messages": [ + {"role": "system", "content": "Ты профессиональный копирайтер. Точно следуешь стилю и пожеланиям клиента."}, + {"role": "user", "content": prompt} + ], + "temperature": 0.9, + "max_tokens": 500 + }, + proxies=get_proxies(), + timeout=30 + ) + response.raise_for_status() + post = response.json()["choices"][0]["message"]["content"].strip() + if post and len(post) > 20: + print("✅ Пост в стиле сгенерирован через PiAPI!") + return post + except Exception as e: + print(f"⚠️ Ошибка PiAPI: {e}, использую шаблоны") + + print("🔄 Использую шаблоны...") + post = self._generate_with_templates(business_type, business_description, platform) + return f"{post}\n\n💭 Пожелания учтены частично (без ИИ): {wishes}" + + def generate_content_plan(self, business_type, business_description, days): + """Генерирует контент-план на несколько дней вперёд""" + print(f"🔄 Генерация контент-плана на {days} дн...") + + if self.use_piapi: + try: + prompt = f""" +Ты — SMM-стратег. Составь контент-план на {days} дней для бизнеса. + +Тип бизнеса: {business_type} +Описание бизнеса: {business_description} + +Для каждого дня укажи платформу (instagram, linkedin, telegram, twitter или ad) и короткую идею поста (1 предложение). + +Верни ТОЛЬКО валидный JSON без пояснений в формате: +{{"plan": [{{"day": 1, "platform": "instagram", "idea": "..."}}, ...]}} +""" + + response = requests.post( + PIAPI_URL, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {PIAPI_API_KEY}" + }, + json={ + "model": self.model, + "messages": [ + {"role": "system", "content": "Ты отвечаешь только валидным JSON, без markdown и пояснений."}, + {"role": "user", "content": prompt} + ], + "temperature": 0.7, + "max_tokens": 1000, + "response_format": {"type": "json_object"} + }, + proxies=get_proxies(), + timeout=30 + ) + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"].strip() + parsed = json.loads(content) + plan = parsed.get("plan") + if plan: + print("✅ Контент-план сгенерирован через PiAPI!") + return plan + except Exception as e: + print(f"⚠️ Ошибка PiAPI: {e}, использую шаблонный план") + + print("🔄 Использую шаблонный план...") + return self._generate_template_plan(business_type, days) + + def _generate_template_plan(self, business_type, days): + """Простой контент-план без обращения к ИИ (запасной вариант)""" + platforms = ['instagram', 'linkedin', 'telegram', 'twitter', 'ad'] + ideas = [ + f"Расскажи историю успеха {business_type}", + f"Покажи закулисье работы {business_type}", + "Поделись отзывом клиента", + "Развенчай миф из твоей ниши", + "Дай практический совет подписчикам", + "Проведи опрос среди аудитории", + "Расскажи о новинке или акции" + ] + plan = [] + for day in range(1, days + 1): + plan.append({ + 'day': day, + 'platform': platforms[(day - 1) % len(platforms)], + 'idea': ideas[(day - 1) % len(ideas)] + }) + return plan + def _generate_with_templates(self, business_type, business_description, platform): """Генерация через шаблоны (запасной вариант)""" diff --git a/cabinet_service/app.py b/cabinet_service/app.py new file mode 100644 index 0000000..4dd1691 --- /dev/null +++ b/cabinet_service/app.py @@ -0,0 +1,435 @@ +from pathlib import Path +import os +import uuid + +import bcrypt +from fastapi import Depends, FastAPI, Form, HTTPException, Request +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware + +from ai_core.image_generator import IMAGE_SIZES, STYLE_PRESETS, ImageGenerator, detect_ext +from ai_core.neural_network import ContentGenerator +from config import SECRET_KEY +from database import ( + create_web_user, + delete_media_by_id, + delete_plan_item, + delete_post_by_id, + delete_user_by_id, + get_media_by_id_for_user, + get_media_for_user, + get_plan_for_user, + get_plan_stats_for_user, + get_post_by_id_for_user, + get_posts_for_user, + get_stats_for_user, + get_user_by_email, + get_user_by_id, + replace_plan_for_user, + save_media, + save_post_for_user, + update_plan_status, + update_post_content, + update_user_field_by_id, +) +from keyboards.reply_keyboards import BUSINESS_TYPES + +BASE_DIR = Path(__file__).resolve().parent +MEDIA_DIR = BASE_DIR / "media" +MEDIA_DIR.mkdir(exist_ok=True) + +app = FastAPI(title="Личный кабинет") +app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY) +app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") +app.mount("/media", StaticFiles(directory=str(MEDIA_DIR)), name="media") +templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) + +content_gen = ContentGenerator() +image_gen = ImageGenerator() + +PLATFORMS = [ + ("instagram", "Instagram"), + ("linkedin", "LinkedIn"), + ("telegram", "Telegram"), + ("twitter", "Twitter/X"), + ("ad", "Рекламный пост"), +] + +PLATFORM_LABELS = dict(PLATFORMS) + +def platform_label(platform: str) -> str: + return PLATFORM_LABELS.get(platform, platform) + + +class NotAuthenticated(Exception): + pass + + +@app.exception_handler(NotAuthenticated) +async def not_authenticated_handler(request: Request, exc: NotAuthenticated): + return RedirectResponse(url="/login", status_code=303) + + +def current_user(request: Request) -> dict: + user_id = request.session.get("user_id") + if not user_id: + raise NotAuthenticated() + user = get_user_by_id(user_id) + if not user: + request.session.clear() + raise NotAuthenticated() + return user + + +def flash(request: Request, message: str): + request.session["flash"] = message + + +def render(request: Request, template: str, status_code: int = 200, **context): + context.setdefault("user", None) + context["flash_message"] = request.session.pop("flash", None) + return templates.TemplateResponse(request, template, context, status_code=status_code) + + +def business_name_of(user: dict) -> str: + return BUSINESS_TYPES.get(str(user["business_type"]), {}).get("name", "Не указан") + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +@app.get("/") +def root(request: Request): + if request.session.get("user_id"): + return RedirectResponse("/dashboard", status_code=303) + return RedirectResponse("/login", status_code=303) + + +# === РЕГИСТРАЦИЯ === + +@app.get("/register") +def register_form(request: Request): + if request.session.get("user_id"): + return RedirectResponse("/dashboard", status_code=303) + return render(request, "register.html", business_types=BUSINESS_TYPES) + + +@app.post("/register") +def register_submit( + request: Request, + name: str = Form(...), + phone: str = Form(...), + business_type: str = Form(...), + description: str = Form(...), + email: str = Form(...), + password: str = Form(...), + password_confirm: str = Form(...), +): + email = email.strip().lower() + errors = [] + if business_type not in BUSINESS_TYPES: + errors.append("Выбери вид бизнеса из списка.") + if len(password) < 8: + errors.append("Пароль должен быть не короче 8 символов.") + if password != password_confirm: + errors.append("Пароли не совпадают.") + if "@" not in email or "." not in email.split("@")[-1]: + errors.append("Введи корректный email.") + if not errors and get_user_by_email(email): + errors.append("Пользователь с таким email уже зарегистрирован.") + + if errors: + return render( + request, "register.html", status_code=400, + business_types=BUSINESS_TYPES, errors=errors, + form={ + "name": name, "phone": phone, "business_type": business_type, + "description": description, "email": email, + }, + ) + + password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + user_id = create_web_user(email, password_hash, name, phone, int(business_type), description) + request.session["user_id"] = user_id + flash(request, "Регистрация завершена! Добро пожаловать.") + return RedirectResponse("/dashboard", status_code=303) + + +# === ВХОД / ВЫХОД === + +@app.get("/login") +def login_form(request: Request): + if request.session.get("user_id"): + return RedirectResponse("/dashboard", status_code=303) + return render(request, "login.html") + + +@app.post("/login") +def login_submit(request: Request, email: str = Form(...), password: str = Form(...)): + email = email.strip().lower() + user = get_user_by_email(email) + valid = ( + user is not None + and user["password_hash"] + and bcrypt.checkpw(password.encode("utf-8"), user["password_hash"].encode("utf-8")) + ) + if not valid: + return render( + request, "login.html", status_code=400, + errors=["Неверный email или пароль."], form={"email": email}, + ) + + request.session["user_id"] = user["id"] + return RedirectResponse("/dashboard", status_code=303) + + +@app.post("/logout") +def logout(request: Request): + request.session.clear() + return RedirectResponse("/login", status_code=303) + + +# === КАБИНЕТ === + +@app.get("/dashboard") +def dashboard(request: Request, user: dict = Depends(current_user)): + return render(request, "dashboard.html", user=user, business_name=business_name_of(user)) + + +@app.get("/profile/edit") +def profile_edit_form(request: Request, user: dict = Depends(current_user)): + return render(request, "profile_edit.html", user=user, business_types=BUSINESS_TYPES) + + +@app.post("/profile/edit") +def profile_edit_submit( + request: Request, + name: str = Form(...), + phone: str = Form(...), + business_type: str = Form(...), + description: str = Form(...), + user: dict = Depends(current_user), +): + if business_type not in BUSINESS_TYPES: + return render( + request, "profile_edit.html", status_code=400, + user=user, business_types=BUSINESS_TYPES, + errors=["Выбери вид бизнеса из списка."], + ) + + update_user_field_by_id(user["id"], "name", name) + update_user_field_by_id(user["id"], "phone", phone) + update_user_field_by_id(user["id"], "business_type", int(business_type)) + update_user_field_by_id(user["id"], "description", description) + flash(request, "Профиль обновлён.") + return RedirectResponse("/dashboard", status_code=303) + + +@app.get("/generate") +def generate_form(request: Request, user: dict = Depends(current_user)): + return render(request, "generate.html", user=user, platforms=PLATFORMS) + + +@app.post("/generate") +def generate_submit( + request: Request, + platform: str = Form(...), + style: str = Form(...), + wishes: str = Form(""), + user: dict = Depends(current_user), +): + description = user["description"] or "не указано" + content = content_gen.generate_post_with_style( + business_name_of(user), description, platform, style, wishes + ) + save_post_for_user(user["id"], platform, content, style, wishes) + return render( + request, "generate.html", user=user, platforms=PLATFORMS, result=content, + form={"platform": platform, "style": style, "wishes": wishes}, + ) + + +@app.get("/history") +def history(request: Request, user: dict = Depends(current_user)): + posts = get_posts_for_user(user["id"], limit=50) + return render(request, "history.html", user=user, posts=posts, platform_label=platform_label) + + +@app.get("/posts/{post_id}") +def post_detail(request: Request, post_id: int, user: dict = Depends(current_user)): + post = get_post_by_id_for_user(post_id, user["id"]) + if not post: + raise HTTPException(status_code=404, detail="Пост не найден") + return render(request, "post_detail.html", user=user, post=post, platform_label=platform_label) + + +@app.get("/posts/{post_id}/edit") +def post_edit_form(request: Request, post_id: int, user: dict = Depends(current_user)): + post = get_post_by_id_for_user(post_id, user["id"]) + if not post: + raise HTTPException(status_code=404, detail="Пост не найден") + return render(request, "post_edit.html", user=user, post=post, platform_label=platform_label) + + +@app.post("/posts/{post_id}/edit") +def post_edit_submit( + request: Request, + post_id: int, + content: str = Form(...), + user: dict = Depends(current_user), +): + post = get_post_by_id_for_user(post_id, user["id"]) + if not post: + raise HTTPException(status_code=404, detail="Пост не найден") + content = content.strip() + if not content: + return render( + request, "post_edit.html", status_code=400, + user=user, post=post, platform_label=platform_label, + errors=["Текст поста не может быть пустым."], + ) + update_post_content(user["id"], post_id, content) + flash(request, "Пост обновлён.") + return RedirectResponse(f"/posts/{post_id}", status_code=303) + + +@app.post("/posts/{post_id}/delete") +def post_delete(request: Request, post_id: int, user: dict = Depends(current_user)): + post = get_post_by_id_for_user(post_id, user["id"]) + if not post: + raise HTTPException(status_code=404, detail="Пост не найден") + delete_post_by_id(user["id"], post_id) + flash(request, "Пост удалён.") + return RedirectResponse("/history", status_code=303) + + +@app.get("/plan") +def plan_form(request: Request, user: dict = Depends(current_user)): + items = get_plan_for_user(user["id"]) + status_counts = get_plan_stats_for_user(user["id"]) + return render( + request, "plan.html", + user=user, plan=items, status_counts=status_counts, platform_label=platform_label, + ) + + +@app.post("/plan") +def plan_submit(request: Request, days: int = Form(7), user: dict = Depends(current_user)): + days = max(1, min(days, 14)) + description = user["description"] or "не указано" + plan = content_gen.generate_content_plan(business_name_of(user), description, days) + replace_plan_for_user(user["id"], plan) + flash(request, f"Контент-план на {days} дн. сохранён.") + return RedirectResponse("/plan", status_code=303) + + +@app.post("/plan/{item_id}/status") +def plan_status_submit( + request: Request, + item_id: int, + status: str = Form(...), + user: dict = Depends(current_user), +): + try: + update_plan_status(user["id"], item_id, status) + flash(request, "Статус пункта обновлён.") + except ValueError: + flash(request, "Недопустимый статус.") + return RedirectResponse("/plan", status_code=303) + + +@app.post("/plan/{item_id}/delete") +def plan_item_delete(request: Request, item_id: int, user: dict = Depends(current_user)): + delete_plan_item(user["id"], item_id) + flash(request, "Пункт плана удалён.") + return RedirectResponse("/plan", status_code=303) + + +@app.get("/stats") +def stats(request: Request, user: dict = Depends(current_user)): + return render(request, "stats.html", user=user, stats=get_stats_for_user(user["id"])) + + +@app.get("/delete-account") +def delete_confirm_form(request: Request, user: dict = Depends(current_user)): + return render(request, "delete_confirm.html", user=user) + + +@app.post("/delete-account") +def delete_account_submit( + request: Request, confirm: str = Form(""), user: dict = Depends(current_user) +): + if confirm != "yes": + return RedirectResponse("/dashboard", status_code=303) + delete_user_by_id(user["id"]) + request.session.clear() + return RedirectResponse("/login", status_code=303) + + +# === ГЕНЕРАЦИЯ КАРТИНОК === + +@app.get("/images") +def images_gallery(request: Request, user: dict = Depends(current_user)): + items = get_media_for_user(user["id"]) + return render(request, "images.html", user=user, images=items) + + +@app.get("/images/generate") +def image_generate_form(request: Request, user: dict = Depends(current_user)): + return render( + request, "image_generate.html", + user=user, sizes=IMAGE_SIZES, styles=STYLE_PRESETS, + ) + + +@app.post("/images/generate") +def image_generate_submit( + request: Request, + prompt: str = Form(...), + size: str = Form("square"), + style: str = Form("none"), + user: dict = Depends(current_user), +): + prompt = prompt.strip() + if not prompt: + return render( + request, "image_generate.html", status_code=400, + user=user, sizes=IMAGE_SIZES, styles=STYLE_PRESETS, + form={"prompt": prompt, "size": size, "style": style}, + errors=["Опиши, что нужно нарисовать."], + ) + + full_prompt = image_gen.build_prompt(prompt, style) + try: + image_bytes = image_gen.generate(full_prompt, size) + except Exception as e: + return render( + request, "image_generate.html", status_code=502, + user=user, sizes=IMAGE_SIZES, styles=STYLE_PRESETS, + form={"prompt": prompt, "size": size, "style": style}, + errors=[f"Ошибка генерации картинки: {e}"], + ) + + filename = f"{uuid.uuid4().hex}{detect_ext(image_bytes)}" + (MEDIA_DIR / filename).write_bytes(image_bytes) + save_media(user["id"], prompt, style, filename) + flash(request, "Картинка сгенерирована и сохранена в библиотеку.") + return RedirectResponse("/images", status_code=303) + + +@app.post("/images/{media_id}/delete") +def image_delete(request: Request, media_id: int, user: dict = Depends(current_user)): + item = get_media_by_id_for_user(media_id, user["id"]) + if not item: + raise HTTPException(status_code=404, detail="Картинка не найдена") + filename = delete_media_by_id(user["id"], media_id) + if filename: + file_path = MEDIA_DIR / filename + if file_path.exists(): + file_path.unlink() + flash(request, "Картинка удалена.") + return RedirectResponse("/images", status_code=303) diff --git a/cabinet_service/runner.py b/cabinet_service/runner.py new file mode 100644 index 0000000..0550bab --- /dev/null +++ b/cabinet_service/runner.py @@ -0,0 +1,13 @@ +import uvicorn + +from config import CABINET_SERVICE_HOST, CABINET_SERVICE_PORT +from cabinet_service.app import app + + +def run(): + uvicorn.run( + app, + host=CABINET_SERVICE_HOST, + port=CABINET_SERVICE_PORT, + log_level="warning" + ) diff --git a/cabinet_service/static/style.css b/cabinet_service/static/style.css new file mode 100644 index 0000000..b704d74 --- /dev/null +++ b/cabinet_service/static/style.css @@ -0,0 +1,555 @@ +:root { + --bg: #f4f5f7; + --surface: #ffffff; + --border: #e2e5ea; + --border-strong: #d1d5db; + --text: #1f2328; + --text-muted: #4b5563; + --text-faint: #6b7280; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --accent-soft: #eef2ff; + --accent-soft-border: #c7d2fe; + --accent-soft-hover: #e0e7ff; + --danger: #dc2626; + --danger-hover: #b91c1c; + --danger-text: #b91c1c; + --success-bg: #ecfdf3; + --success-border: #a7e3bb; + --success-text: #14532d; + --warn-bg: #fffbeb; + --warn-border: #fde68a; + --warn-text: #78350f; + --error-bg: #fef2f2; + --error-border: #f3b4b4; + --error-text: #7f1d1d; + --table-head: #f9fafb; + --badge-bg: #f3f4f6; + --badge-text: #374151; + --input-bg: #ffffff; + --published-surface: #fafefb; + color-scheme: light; +} + +:root[data-theme="dark"] { + --bg: #0f172a; + --surface: #1e293b; + --border: #334155; + --border-strong: #475569; + --text: #e2e8f0; + --text-muted: #94a3b8; + --text-faint: #94a3b8; + --accent: #60a5fa; + --accent-hover: #93c5fd; + --accent-soft: #172554; + --accent-soft-border: #1e40af; + --accent-soft-hover: #1e3a8a; + --danger: #ef4444; + --danger-hover: #f87171; + --danger-text: #f87171; + --success-bg: #064e3b; + --success-border: #059669; + --success-text: #a7f3d0; + --warn-bg: #451a03; + --warn-border: #b45309; + --warn-text: #fde68a; + --error-bg: #450a0a; + --error-border: #991b1b; + --error-text: #fecaca; + --table-head: #243244; + --badge-bg: #334155; + --badge-text: #e2e8f0; + --input-bg: #0f172a; + --published-surface: #0b2e22; + color-scheme: dark; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --bg: #0f172a; + --surface: #1e293b; + --border: #334155; + --border-strong: #475569; + --text: #e2e8f0; + --text-muted: #94a3b8; + --text-faint: #94a3b8; + --accent: #60a5fa; + --accent-hover: #93c5fd; + --accent-soft: #172554; + --accent-soft-border: #1e40af; + --accent-soft-hover: #1e3a8a; + --danger: #ef4444; + --danger-hover: #f87171; + --danger-text: #f87171; + --success-bg: #064e3b; + --success-border: #059669; + --success-text: #a7f3d0; + --warn-bg: #451a03; + --warn-border: #b45309; + --warn-text: #fde68a; + --error-bg: #450a0a; + --error-border: #991b1b; + --error-text: #fecaca; + --table-head: #243244; + --badge-bg: #334155; + --badge-text: #e2e8f0; + --input-bg: #0f172a; + --published-surface: #0b2e22; + color-scheme: dark; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); + transition: background-color 0.2s ease, color 0.2s ease; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.topbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 20px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.topbar .brand { + font-weight: 600; + font-size: 1.1rem; + color: var(--text); +} + +.topbar nav { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px; +} + +.theme-toggle { + background: none; + border: 1px solid var(--border); + border-radius: 8px; + padding: 4px 10px; + font-size: 1rem; + line-height: 1.4; + cursor: pointer; +} + +.theme-toggle:hover { + background: var(--accent-soft); + border-color: var(--accent-soft-border); +} + +.inline-form { + display: inline; + margin: 0; +} + +.link-button { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--accent); + cursor: pointer; +} + +.link-button:hover { + text-decoration: underline; +} + +.container { + max-width: 720px; + margin: 0 auto; + padding: 24px 20px 60px; +} + +h1 { + font-size: 1.5rem; + margin-bottom: 16px; +} + +h2 { + font-size: 1.15rem; + margin-top: 28px; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px 18px; + margin-bottom: 16px; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.card p { + margin: 6px 0; +} + +.result { + white-space: pre-wrap; +} + +.flash { + background: var(--success-bg); + border: 1px solid var(--success-border); + color: var(--success-text); + padding: 10px 14px; + border-radius: 8px; + margin-bottom: 16px; +} + +.errors { + background: var(--error-bg); + border: 1px solid var(--error-border); + color: var(--error-text); + padding: 10px 14px; + border-radius: 8px; + margin-bottom: 16px; +} + +.errors ul { + margin: 0; + padding-left: 18px; +} + +.form { + display: flex; + flex-direction: column; + gap: 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 20px; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.form label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.9rem; + color: var(--text-muted); +} + +.form input, +.form select, +.form textarea { + font: inherit; + padding: 9px 11px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--input-bg); + color: var(--text); + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.form input:focus, +.form select:focus, +.form textarea:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.form button, +.button-danger { + align-self: flex-start; + background: var(--accent); + color: #fff; + border: none; + padding: 10px 18px; + border-radius: 8px; + font: inherit; + cursor: pointer; +} + +.form button:hover { + background: var(--accent-hover); +} + +.button-danger { + background: var(--danger); +} + +.button-danger:hover { + background: var(--danger-hover); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; +} + +.tile { + display: block; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 18px; + font-weight: 500; + color: var(--text); + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.tile:hover { + border-color: var(--accent); + text-decoration: none; +} + +.tile-danger { + color: var(--danger-text); +} + +.post-list { + list-style: none; + margin: 0; + padding: 0; +} + +.post-meta { + font-size: 0.85rem; + color: var(--text-faint); + margin-bottom: 6px; +} + +.post-content { + white-space: pre-wrap; +} + +.table { + width: 100%; + border-collapse: collapse; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.table th, +.table td { + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid var(--border); +} + +.table th { + background: var(--table-head); +} + +/* === Действия с постами / планом === */ + +.post-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 12px; +} + +.copy-btn { + background: var(--accent-soft); + color: var(--accent); + border: 1px solid var(--accent-soft-border); + border-radius: 8px; + padding: 7px 12px; + font: inherit; + font-size: 0.9rem; + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.copy-btn:hover { + background: var(--accent-soft-hover); +} + +.button-link { + background: none; + border: none; + padding: 0; + font: inherit; + font-size: 0.9rem; + color: var(--accent); + cursor: pointer; + text-decoration: none; +} + +.button-link:hover { + text-decoration: underline; +} + +.button-link-danger { + color: var(--danger-text); +} + +.post-notes { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed var(--border); + font-size: 0.9rem; + color: var(--text-muted); +} + +.form-actions { + display: flex; + align-items: center; + gap: 16px; +} + +/* === Контент-план === */ + +.chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.chip { + padding: 6px 12px; + border-radius: 999px; + font-size: 0.85rem; + background: var(--surface); + border: 1px solid var(--border); + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.chip-published { + background: var(--success-bg); + border-color: var(--success-border); + color: var(--success-text); +} + +.chip-postponed { + background: var(--warn-bg); + border-color: var(--warn-border); + color: var(--warn-text); +} + +.plan-item-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.plan-day { + font-weight: 600; +} + +.plan-platform { + font-size: 0.9rem; + color: var(--text-faint); +} + +.plan-idea { + white-space: pre-wrap; +} + +.status-badge { + font-size: 0.8rem; + padding: 3px 10px; + border-radius: 999px; + background: var(--badge-bg); + color: var(--badge-text); +} + +.status-badge.status-published { + background: var(--success-bg); + color: var(--success-text); +} + +.status-badge.status-postponed { + background: var(--warn-bg); + color: var(--warn-text); +} + +.plan-item.status-published { + background: var(--published-surface); +} + +.status-select { + font: inherit; + font-size: 0.9rem; + padding: 6px 8px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--input-bg); + color: var(--text); +} + +/* === Медиа-библиотека === */ + +.media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; + margin-bottom: 20px; +} + +.media-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + display: flex; + flex-direction: column; + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +.media-card a.media-link { + display: block; + line-height: 0; +} + +.media-thumb { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + background: var(--table-head); + display: block; +} + +.media-body { + padding: 10px 12px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.media-prompt { + font-size: 0.9rem; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.form-hint { + font-size: 0.85rem; + color: var(--text-faint); + margin: 0; +} diff --git a/cabinet_service/templates/base.html b/cabinet_service/templates/base.html new file mode 100644 index 0000000..09fb4da --- /dev/null +++ b/cabinet_service/templates/base.html @@ -0,0 +1,114 @@ + + +
+ + +Телефон: {{ user.phone }}
+Вид бизнеса: {{ business_name }}
+Описание: {{ user.description }}
+В кабинете с: {{ user.created_at }}
+Это безвозвратно удалит твой профиль и всю историю постов.
+ + +{% endblock %} diff --git a/cabinet_service/templates/generate.html b/cabinet_service/templates/generate.html new file mode 100644 index 0000000..025690f --- /dev/null +++ b/cabinet_service/templates/generate.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Сгенерировать пост — Личный кабинет{% endblock %} +{% block content %} +Постов пока нет.
+ +Картинок пока нет.
+ +Ещё нет аккаунта? Зарегистрироваться
+{% endblock %} diff --git a/cabinet_service/templates/plan.html b/cabinet_service/templates/plan.html new file mode 100644 index 0000000..8019c6a --- /dev/null +++ b/cabinet_service/templates/plan.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block title %}Контент-план — Личный кабинет{% endblock %} +{% block content %} +Контент-плана пока нет. Составь его на 1–14 дней — он будет сохранён, и ты сможешь отмечать опубликованные и отложенные посты.
+Уже есть аккаунт? Войти
+{% endblock %} diff --git a/cabinet_service/templates/stats.html b/cabinet_service/templates/stats.html new file mode 100644 index 0000000..9202506 --- /dev/null +++ b/cabinet_service/templates/stats.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}Статистика — Личный кабинет{% endblock %} +{% block content %} +В кабинете с: {{ stats.created_at }}
+Всего постов: {{ stats.total_posts }}
+Пока нет постов.
+{% endif %} + +{% endblock %} diff --git a/config.py b/config.py index 9e73f1a..999037a 100644 --- a/config.py +++ b/config.py @@ -1,19 +1,59 @@ import os +import secrets from dotenv import load_dotenv # Загружаем переменные из .env load_dotenv() # Токен бота -TOKEN = "8876908400:AAGb4_ayjfhWuUI_evP8vvc5YUUiG8jxepE" +TOKEN = os.getenv("BOT_TOKEN") -# Ключ Groq -GROQ_API_KEY = os.getenv("GROQ_API_KEY") +if not TOKEN: + print("❌ BOT_TOKEN не найден в .env файле!") + print("👉 Создай файл .env и добавь: BOT_TOKEN=твой_токен") + +# Ключ PiAPI +PIAPI_API_KEY = os.getenv("PIAPI_API_KEY") +PIAPI_MODEL = os.getenv("PIAPI_MODEL", "gpt-4o") + +# Модель генерации картинок через PiAPI (список доступных: /v1/models) +PIAPI_IMAGE_MODEL = os.getenv("PIAPI_IMAGE_MODEL", "gpt-image-1") + +# Прокси для доступа к внешним API (PiAPI и др.). +# Формат в .env: PROXY_HOST, PROXY_PORT, PROXY_USER, PROXY_PASS +PROXY_HOST = os.getenv("PROXY_HOST") +PROXY_PORT = os.getenv("PROXY_PORT") +PROXY_USER = os.getenv("PROXY_USER") +PROXY_PASS = os.getenv("PROXY_PASS") + +def get_proxies(): + """Возвращает dict прокси для requests, или None, если прокси не настроен.""" + if not (PROXY_HOST and PROXY_PORT): + return None + auth = f"{PROXY_USER}:{PROXY_PASS}@" if (PROXY_USER and PROXY_PASS) else "" + proxy = f"http://{auth}{PROXY_HOST}:{PROXY_PORT}" + return {"http": proxy, "https": proxy} + +# Сервис "Личный кабинет" (отдельный FastAPI-процесс, веб-интерфейс на браузере) +CABINET_SERVICE_HOST = os.getenv("CABINET_SERVICE_HOST", "127.0.0.1") +CABINET_SERVICE_PORT = int(os.getenv("CABINET_SERVICE_PORT", "8001")) + +# Публичный адрес кабинета - именно эту ссылку бот присылает пользователю. +# Для реального использования (не localhost) нужно задать в .env настоящий +# домен/IP, доступный из браузера пользователя. +CABINET_WEB_URL = os.getenv("CABINET_WEB_URL", f"http://{CABINET_SERVICE_HOST}:{CABINET_SERVICE_PORT}") + +# Ключ подписи cookie-сессий веб-кабинета +SECRET_KEY = os.getenv("SECRET_KEY") +if not SECRET_KEY: + SECRET_KEY = secrets.token_hex(32) + print("⚠️ SECRET_KEY не найден в .env - сгенерирован временный ключ.") + print("👉 Добавь SECRET_KEY в .env, иначе все сессии слетают при каждом перезапуске.") # Проверяем, что ключ загрузился -if not GROQ_API_KEY: - print("❌ GROQ_API_KEY не найден в .env файле!") - print("👉 Создай файл .env и добавь: GROQ_API_KEY=твой_ключ") +if not PIAPI_API_KEY: + print("❌ PIAPI_API_KEY не найден в .env файле!") + print("👉 Создай файл .env и добавь: PIAPI_API_KEY=твой_ключ") else: - print("✅ GROQ_API_KEY загружен!") + print("✅ PIAPI_API_KEY загружен!") diff --git a/database.py b/database.py index 24d3611..b425bde 100644 --- a/database.py +++ b/database.py @@ -1,9 +1,12 @@ import sqlite3 +# Поля профиля, которые разрешено менять через "личный кабинет" +EDITABLE_FIELDS = {'name', 'phone', 'business_type', 'description'} + def create_db(): conn = sqlite3.connect('users.db') cursor = conn.cursor() - + cursor.execute(''' CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -15,7 +18,68 @@ def create_db(): registered BOOLEAN DEFAULT 0 ) ''') - + + cursor.execute("PRAGMA table_info(users)") + columns = [row[1] for row in cursor.fetchall()] + if 'registered' not in columns: + cursor.execute('ALTER TABLE users ADD COLUMN registered BOOLEAN DEFAULT 0') + print("✅ Колонка 'registered' добавлена в таблицу users") + if 'created_at' not in columns: + # SQLite не разрешает non-constant DEFAULT в ALTER TABLE ADD COLUMN + cursor.execute('ALTER TABLE users ADD COLUMN created_at TIMESTAMP') + cursor.execute('UPDATE users SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL') + print("✅ Колонка 'created_at' добавлена в таблицу users") + if 'email' not in columns: + # Без UNIQUE - SQLite не позволяет добавить UNIQUE через ALTER TABLE, + # уникальность email проверяется на уровне приложения (get_user_by_email) + cursor.execute('ALTER TABLE users ADD COLUMN email TEXT') + print("✅ Колонка 'email' добавлена в таблицу users") + if 'password_hash' not in columns: + cursor.execute('ALTER TABLE users ADD COLUMN password_hash TEXT') + print("✅ Колонка 'password_hash' добавлена в таблицу users") + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_id INTEGER, + platform TEXT, + content TEXT, + style TEXT, + wishes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + cursor.execute("PRAGMA table_info(posts)") + posts_columns = [row[1] for row in cursor.fetchall()] + if 'user_id' not in posts_columns: + # Посты веб-кабинета привязаны к users.id, посты из бота - к telegram_id + cursor.execute('ALTER TABLE posts ADD COLUMN user_id INTEGER') + print("✅ Колонка 'user_id' добавлена в таблицу posts") + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS content_plan ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + day INTEGER, + platform TEXT, + idea TEXT, + status TEXT DEFAULT 'planned', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + prompt TEXT, + style TEXT, + filename TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + conn.commit() conn.close() print("✅ База данных создана!") @@ -50,5 +114,329 @@ def update_user_description(telegram_id, description): conn.commit() conn.close() +def update_user_field(telegram_id, field, value): + """Обновляет одно поле профиля. field обязан быть из EDITABLE_FIELDS.""" + if field not in EDITABLE_FIELDS: + raise ValueError(f"Поле '{field}' нельзя редактировать") + + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute(f'UPDATE users SET {field} = ? WHERE telegram_id = ?', (value, telegram_id)) + conn.commit() + conn.close() + +def delete_user(telegram_id): + """Удаляет пользователя и всю его историю постов.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute('DELETE FROM users WHERE telegram_id = ?', (telegram_id,)) + cursor.execute('DELETE FROM posts WHERE telegram_id = ?', (telegram_id,)) + conn.commit() + conn.close() + +def save_post(telegram_id, platform, content, style=None, wishes=None): + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO posts (telegram_id, platform, content, style, wishes) + VALUES (?, ?, ?, ?, ?) + ''', (telegram_id, platform, content, style, wishes)) + conn.commit() + conn.close() + +def get_posts(telegram_id, limit=10): + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute(''' + SELECT platform, content, style, wishes, created_at + FROM posts + WHERE telegram_id = ? + ORDER BY created_at DESC + LIMIT ? + ''', (telegram_id, limit)) + posts = cursor.fetchall() + conn.close() + return posts + +def get_stats(telegram_id): + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + + cursor.execute('SELECT created_at FROM users WHERE telegram_id = ?', (telegram_id,)) + row = cursor.fetchone() + created_at = row[0] if row else None + + cursor.execute('SELECT COUNT(*) FROM posts WHERE telegram_id = ?', (telegram_id,)) + total_posts = cursor.fetchone()[0] + + cursor.execute(''' + SELECT platform, COUNT(*) FROM posts + WHERE telegram_id = ? + GROUP BY platform + ''', (telegram_id,)) + by_platform = dict(cursor.fetchall()) + + conn.close() + return { + 'created_at': created_at, + 'total_posts': total_posts, + 'by_platform': by_platform + } + +# === ФУНКЦИИ ДЛЯ ВЕБ-КАБИНЕТА (ключ - внутренний users.id, не telegram_id) === + +def _row_conn(): + conn = sqlite3.connect('users.db') + conn.row_factory = sqlite3.Row + return conn + + +def get_user_by_email(email): + conn = _row_conn() + cursor = conn.cursor() + cursor.execute('SELECT * FROM users WHERE email = ?', (email,)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + + +def get_user_by_id(user_id): + conn = _row_conn() + cursor = conn.cursor() + cursor.execute('SELECT * FROM users WHERE id = ?', (user_id,)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + + +def create_web_user(email, password_hash, name, phone, business_type, description): + """Создаёт пользователя веб-кабинета (без telegram_id). Возвращает id нового пользователя.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO users (name, phone, business_type, description, registered, created_at, email, password_hash) + VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP, ?, ?) + ''', (name, phone, business_type, description, email, password_hash)) + conn.commit() + user_id = cursor.lastrowid + conn.close() + return user_id + + +def update_user_field_by_id(user_id, field, value): + """Обновляет одно поле профиля по внутреннему id. field обязан быть из EDITABLE_FIELDS.""" + if field not in EDITABLE_FIELDS: + raise ValueError(f"Поле '{field}' нельзя редактировать") + + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute(f'UPDATE users SET {field} = ? WHERE id = ?', (value, user_id)) + conn.commit() + conn.close() + + +def delete_user_by_id(user_id): + """Удаляет пользователя веб-кабинета и всю его историю постов.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute('DELETE FROM users WHERE id = ?', (user_id,)) + cursor.execute('DELETE FROM posts WHERE user_id = ?', (user_id,)) + conn.commit() + conn.close() + + +def save_post_for_user(user_id, platform, content, style=None, wishes=None): + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO posts (user_id, platform, content, style, wishes) + VALUES (?, ?, ?, ?, ?) + ''', (user_id, platform, content, style, wishes)) + conn.commit() + conn.close() + + +def get_posts_for_user(user_id, limit=10): + conn = _row_conn() + cursor = conn.cursor() + cursor.execute(''' + SELECT id, platform, content, style, wishes, created_at + FROM posts + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ? + ''', (user_id, limit)) + posts = [dict(row) for row in cursor.fetchall()] + conn.close() + return posts + + +def get_stats_for_user(user_id): + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + + cursor.execute('SELECT created_at FROM users WHERE id = ?', (user_id,)) + row = cursor.fetchone() + created_at = row[0] if row else None + + cursor.execute('SELECT COUNT(*) FROM posts WHERE user_id = ?', (user_id,)) + total_posts = cursor.fetchone()[0] + + cursor.execute(''' + SELECT platform, COUNT(*) FROM posts + WHERE user_id = ? + GROUP BY platform + ''', (user_id,)) + by_platform = dict(cursor.fetchall()) + + conn.close() + return { + 'created_at': created_at, + 'total_posts': total_posts, + 'by_platform': by_platform + } + + +# === УПРАВЛЕНИЕ ПОСТАМИ (веб-кабинет) === + +def get_post_by_id_for_user(post_id, user_id): + """Возвращает один пост веб-пользователя (только если он принадлежит user_id).""" + conn = _row_conn() + cursor = conn.cursor() + cursor.execute('SELECT * FROM posts WHERE id = ? AND user_id = ?', (post_id, user_id)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + + +def update_post_content(user_id, post_id, content): + """Редактирует текст поста. Меняет только посты, принадлежащие user_id.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute('UPDATE posts SET content = ? WHERE id = ? AND user_id = ?', (content, post_id, user_id)) + conn.commit() + conn.close() + + +def delete_post_by_id(user_id, post_id): + """Удаляет пост. Удаляет только посты, принадлежащие user_id.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute('DELETE FROM posts WHERE id = ? AND user_id = ?', (post_id, user_id)) + conn.commit() + conn.close() + + +# === КОНТЕНТ-ПЛАН (веб-кабинет) === + +PLAN_STATUSES = ('planned', 'published', 'postponed') + +def replace_plan_for_user(user_id, plan_items): + """Полностью заменяет контент-план пользователя новыми пунктами. + plan_items: список словарей {'day': int, 'platform': str, 'idea': str}""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute('DELETE FROM content_plan WHERE user_id = ?', (user_id,)) + cursor.executemany( + 'INSERT INTO content_plan (user_id, day, platform, idea) VALUES (?, ?, ?, ?)', + [(user_id, item['day'], item['platform'], item['idea']) for item in plan_items] + ) + conn.commit() + conn.close() + + +def get_plan_for_user(user_id): + conn = _row_conn() + cursor = conn.cursor() + cursor.execute('SELECT * FROM content_plan WHERE user_id = ? ORDER BY day, id', (user_id,)) + rows = [dict(row) for row in cursor.fetchall()] + conn.close() + return rows + + +def update_plan_status(user_id, item_id, status): + if status not in PLAN_STATUSES: + raise ValueError(f"Недопустимый статус '{status}'") + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + 'UPDATE content_plan SET status = ? WHERE id = ? AND user_id = ?', + (status, item_id, user_id) + ) + conn.commit() + conn.close() + + +def delete_plan_item(user_id, item_id): + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute('DELETE FROM content_plan WHERE id = ? AND user_id = ?', (item_id, user_id)) + conn.commit() + conn.close() + + +def get_plan_stats_for_user(user_id): + """Считает пункты плана по статусам: {'planned': n, 'published': n, ...}""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute('SELECT status, COUNT(*) FROM content_plan WHERE user_id = ? GROUP BY status', (user_id,)) + result = dict(cursor.fetchall()) + conn.close() + return result + + +# === МЕДИА-БИБЛИОТЕКА (веб-кабинет) === + +def save_media(user_id, prompt, style, filename): + """Сохраняет запись о сгенерированной картинке. Возвращает id записи.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute( + 'INSERT INTO media (user_id, prompt, style, filename) VALUES (?, ?, ?, ?)', + (user_id, prompt, style, filename) + ) + conn.commit() + media_id = cursor.lastrowid + conn.close() + return media_id + + +def get_media_for_user(user_id, limit=50): + conn = _row_conn() + cursor = conn.cursor() + cursor.execute( + 'SELECT id, prompt, style, filename, created_at FROM media ' + 'WHERE user_id = ? ORDER BY created_at DESC, id DESC LIMIT ?', + (user_id, limit) + ) + rows = [dict(row) for row in cursor.fetchall()] + conn.close() + return rows + + +def get_media_by_id_for_user(media_id, user_id): + conn = _row_conn() + cursor = conn.cursor() + cursor.execute('SELECT * FROM media WHERE id = ? AND user_id = ?', (media_id, user_id)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + + +def delete_media_by_id(user_id, media_id): + """Удаляет запись о картинке. Возвращает filename, чтобы удалить и сам файл.""" + conn = sqlite3.connect('users.db') + cursor = conn.cursor() + cursor.execute('SELECT filename FROM media WHERE id = ? AND user_id = ?', (media_id, user_id)) + row = cursor.fetchone() + if row: + cursor.execute('DELETE FROM media WHERE id = ? AND user_id = ?', (media_id, user_id)) + conn.commit() + conn.close() + return row[0] + conn.close() + return None + + # Создаем базу при первом импорте create_db() \ No newline at end of file diff --git a/handlers/__pycache__/content.cpython-314.pyc b/handlers/__pycache__/content.cpython-314.pyc deleted file mode 100644 index 6e04c73..0000000 Binary files a/handlers/__pycache__/content.cpython-314.pyc and /dev/null differ diff --git a/handlers/__pycache__/menu.cpython-314.pyc b/handlers/__pycache__/menu.cpython-314.pyc deleted file mode 100644 index eca2503..0000000 Binary files a/handlers/__pycache__/menu.cpython-314.pyc and /dev/null differ diff --git a/handlers/__pycache__/registration.cpython-314.pyc b/handlers/__pycache__/registration.cpython-314.pyc deleted file mode 100644 index 9d0fd50..0000000 Binary files a/handlers/__pycache__/registration.cpython-314.pyc and /dev/null differ diff --git a/handlers/menu.py b/handlers/menu.py index 2a3183a..0f92036 100644 --- a/handlers/menu.py +++ b/handlers/menu.py @@ -1,6 +1,7 @@ from database import get_user, update_user_description -from keyboards.reply_keyboards import get_main_menu_keyboard, get_settings_keyboard, get_content_keyboard, BUSINESS_TYPES +from keyboards.reply_keyboards import get_main_menu_keyboard, get_settings_keyboard, get_content_keyboard, get_cabinet_link_keyboard, BUSINESS_TYPES from handlers.content import handle_content_generation +from config import CABINET_WEB_URL def show_main_menu(user_id, bot): """Показывает главное меню""" @@ -81,12 +82,21 @@ def handle_menu_choice(message, bot): return True # Обработка выбора платформы для контента - elif text in ["📝 Пост для Instagram", "💼 Пост для LinkedIn", - "📱 Пост для Telegram", "🧵 Пост для Twitter/X", + elif text in ["📝 Пост для Instagram", "💼 Пост для LinkedIn", + "📱 Пост для Telegram", "🧵 Пост для Twitter/X", "🎯 Рекламный пост"]: handle_content_generation(message, bot, text) return True - + + elif text == "🗂 Личный кабинет": + bot.send_message( + user_id, + "🗂 Полная версия личного кабинета теперь в браузере: профиль, " + "посты в своём стиле, контент-план, история и статистика.", + reply_markup=get_cabinet_link_keyboard(CABINET_WEB_URL) + ) + return True + return False def show_profile(message, bot): diff --git a/keyboards/__pycache__/reply_keyboards.cpython-314.pyc b/keyboards/__pycache__/reply_keyboards.cpython-314.pyc deleted file mode 100644 index d6631e4..0000000 Binary files a/keyboards/__pycache__/reply_keyboards.cpython-314.pyc and /dev/null differ diff --git a/keyboards/reply_keyboards.py b/keyboards/reply_keyboards.py index c5905a5..d1bd6e6 100644 --- a/keyboards/reply_keyboards.py +++ b/keyboards/reply_keyboards.py @@ -1,4 +1,4 @@ -from telebot.types import ReplyKeyboardMarkup, KeyboardButton +from telebot.types import ReplyKeyboardMarkup, KeyboardButton, InlineKeyboardMarkup, InlineKeyboardButton # === 9 ВИДОВ ПРЕДПРИНИМАТЕЛЕЙ === BUSINESS_TYPES = { @@ -21,10 +21,17 @@ def get_main_menu_keyboard(): KeyboardButton("✏️ Обновить описание"), KeyboardButton("📊 Статистика"), KeyboardButton("💡 Предложить контент"), - KeyboardButton("⚙️ Настройки") + KeyboardButton("⚙️ Настройки"), + KeyboardButton("🗂 Личный кабинет") ) return keyboard +def get_cabinet_link_keyboard(cabinet_web_url): + """Инлайн-кнопка со ссылкой на веб-версию личного кабинета""" + keyboard = InlineKeyboardMarkup() + keyboard.add(InlineKeyboardButton("🌐 Открыть личный кабинет", url=cabinet_web_url)) + return keyboard + def get_business_types_keyboard(): """Клавиатура с 9 видами предпринимателей""" keyboard = ReplyKeyboardMarkup(row_width=3, resize_keyboard=True) diff --git a/main.py b/main.py index ea05d8d..4e0d759 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ import telebot import requests import sys import os +import threading from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from telebot import apihelper @@ -87,13 +88,13 @@ def status_bot(message): import datetime now = datetime.datetime.now().strftime("%d.%m.%Y %H:%M:%S") - # Проверяем, есть ли GROQ_API_KEY + # Проверяем, есть ли PIAPI_API_KEY try: - from config import GROQ_API_KEY - groq_status = "подключен ✅" if GROQ_API_KEY else "не подключен ⚠️" + from config import PIAPI_API_KEY + piapi_status = "подключен ✅" if PIAPI_API_KEY else "не подключен ⚠️" except: - groq_status = "не подключен ⚠️" - + piapi_status = "не подключен ⚠️" + status_text = f""" 📊 **СТАТУС БОТА** @@ -103,7 +104,7 @@ def status_bot(message): 📋 **Информация:** • Бот запущен и работает • База данных: подключена ✅ -• Groq API: {groq_status} +• PiAPI: {piapi_status} 💡 **Команды управления:** /stop - остановить бота @@ -133,6 +134,7 @@ def help_command(message): 📊 Статистика - статистика 💡 Предложить контент - сгенерировать пост для соцсетей ⚙️ Настройки - настройки бота +🗂 Личный кабинет - расширенный профиль, пост в своём стиле, контент-план, история постов и статистика 💡 **Как использовать:** 1. Напиши /start @@ -170,7 +172,7 @@ def handle_messages(message): reply_markup=get_main_menu_keyboard() ) return - + # Обрабатываем выбор в меню if handle_menu_choice(message, bot): return @@ -194,6 +196,11 @@ def handle_messages(message): # === ЗАПУСК БОТА === if __name__ == '__main__': + from cabinet_service.runner import run as run_cabinet_service + cabinet_thread = threading.Thread(target=run_cabinet_service, daemon=True) + cabinet_thread.start() + print("🗂 Сервис личного кабинета запущен в фоне") + print("🤖 БОТ ЗАПУЩЕН!") print("📋 Команды:") print(" /start - начать работу") @@ -202,7 +209,7 @@ if __name__ == '__main__': print(" /stop - остановить бота") print(" /restart - перезапустить бота") print("✅ Используй кнопки для навигации") - + try: bot.infinity_polling(timeout=30) except KeyboardInterrupt: diff --git a/opencode.jsonc b/opencode.jsonc new file mode 100644 index 0000000..720ece5 --- /dev/null +++ b/opencode.jsonc @@ -0,0 +1,3 @@ +{ + "$schema": "https://opencode.ai/config.json" +} diff --git a/requirements.txt b/requirements.txt index 32af77c..e677e03 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,10 @@ pytelegrambotapi>=4.16.0 -sqlalchemy>=2.0.23 python-dotenv>=1.0.0 requests>=2.31.0 urllib3>=2.1.0 -groq>=0.5.0 -transformers>=4.36.0 # (опционально, для локальных моделей) -torch>=2.1.0 # (опционально, для локальных моделей) \ No newline at end of file +fastapi>=0.110.0 +uvicorn>=0.29.0 +bcrypt>=4.1.0 +itsdangerous>=2.1.0 +jinja2>=3.1.0 +python-multipart>=0.0.9 \ No newline at end of file diff --git a/users.db b/users.db deleted file mode 100644 index a8b665c..0000000 Binary files a/users.db and /dev/null differ