Добавлен веб-личный кабинет: посты, контент-план, медиа-библиотека и генерация картинок

This commit is contained in:
apuc committed 2026-08-07 09:35:44 +03:00
1 parent d8c086d3b4
commit 81625b2e6f
35 files changed
+2204 -48

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
.env
__pycache__/
*.pyc
users.db
venv/
cabinet_service/media/
.idea/
Binary file not shown.
Binary file not shown.
Binary file not shown.
+89
View File
@@ -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()
+172 -29
View File
@@ -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}, использую шаблоны")
print(f"⚠️ Ошибка PiAPI: {e}, использую шаблоны")
# Если Groq не работает - используем шаблоны
# Если 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,17 +158,162 @@ class ContentGenerator:
Пост должен быть уникальным и интересным.
"""
response = self.client.chat.completions.create(
model=self.model,
messages=[
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
"temperature": 0.9,
"max_tokens": 500
},
proxies=get_proxies(),
timeout=30
)
response.raise_for_status()
data = response.json()
return response.choices[0].message.content.strip()
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):
"""Генерация через шаблоны (запасной вариант)"""
+435
View File
@@ -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)
+13
View File
@@ -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"
)
+555
View File
@@ -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;
}
+114
View File
@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Личный кабинет{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css?v=2">
<script>
(function () {
function isDark() {
return document.documentElement.getAttribute('data-theme') === 'dark';
}
function setIcon(btn) {
if (btn) btn.textContent = isDark() ? '☀️' : '🌙';
}
try {
var saved = localStorage.getItem('theme');
// Тёмная тема по умолчанию: dark включается всегда, кроме явного выбора 'light'
if (saved !== 'light') {
document.documentElement.setAttribute('data-theme', 'dark');
}
} catch (e) {}
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-theme-toggle]');
if (!btn) return;
if (isDark()) {
document.documentElement.removeAttribute('data-theme');
try { localStorage.setItem('theme', 'light'); } catch (e) {}
} else {
document.documentElement.setAttribute('data-theme', 'dark');
try { localStorage.setItem('theme', 'dark'); } catch (e) {}
}
setIcon(btn);
});
document.addEventListener('DOMContentLoaded', function () {
setIcon(document.querySelector('[data-theme-toggle]'));
});
})();
</script>
</head>
<body>
<header class="topbar">
<a class="brand" href="{{ '/dashboard' if user else '/login' }}">🗂 Личный кабинет</a>
<nav>
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Переключить тему">🌙</button>
{% if user %}
<a href="/dashboard">Главная</a>
<a href="/generate">Пост</a>
<a href="/plan">Контент-план</a>
<a href="/history">История</a>
<a href="/images">Картинки</a>
<a href="/stats">Статистика</a>
<a href="/profile/edit">Профиль</a>
<form action="/logout" method="post" class="inline-form">
<button type="submit" class="link-button">Выйти</button>
</form>
</nav>
{% else %}
<a href="/login">Вход</a>
<a href="/register">Регистрация</a>
</nav>
{% endif %}
</header>
<main class="container">
{% if flash_message %}
<div class="flash">{{ flash_message }}</div>
{% endif %}
{% if errors %}
<div class="errors">
<ul>
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<script>
function copyText(text, btn) {
function done(ok) {
if (!btn) return;
var old = btn.textContent;
btn.textContent = ok ? '✅ Скопировано' : '❌ Ошибка';
setTimeout(function () { btn.textContent = old; }, 1500);
}
function fallback() {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
document.body.removeChild(ta);
done(ok);
}
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(function () { done(true); }, fallback);
} else {
fallback();
}
}
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-copy-target]');
if (!btn) return;
var el = document.getElementById(btn.getAttribute('data-copy-target'));
if (el) copyText(el.textContent, btn);
});
</script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Главная — Личный кабинет{% endblock %}
{% block content %}
<h1>Привет, {{ user.name }}!</h1>
<div class="card">
<p><strong>Телефон:</strong> {{ user.phone }}</p>
<p><strong>Вид бизнеса:</strong> {{ business_name }}</p>
<p><strong>Описание:</strong> {{ user.description }}</p>
<p><strong>В кабинете с:</strong> {{ user.created_at }}</p>
</div>
<div class="grid">
<a class="tile" href="/generate">💡 Сгенерировать пост</a>
<a class="tile" href="/plan">📅 Контент-план</a>
<a class="tile" href="/history">📜 История постов</a>
<a class="tile" href="/images">🖼 Медиа-библиотека</a>
<a class="tile" href="/stats">📈 Статистика</a>
<a class="tile" href="/profile/edit">✏️ Редактировать профиль</a>
<a class="tile tile-danger" href="/delete-account">🗑 Удалить аккаунт</a>
</div>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Удаление аккаунта — Личный кабинет{% endblock %}
{% block content %}
<h1>Удалить аккаунт?</h1>
<p>Это безвозвратно удалит твой профиль и всю историю постов.</p>
<form method="post" action="/delete-account" class="form">
<input type="hidden" name="confirm" value="yes">
<button type="submit" class="button-danger">Да, удалить аккаунт</button>
</form>
<p><a href="/dashboard">Отмена, вернуться в кабинет</a></p>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Сгенерировать пост — Личный кабинет{% endblock %}
{% block content %}
<h1>Пост в своём стиле</h1>
<form method="post" action="/generate" class="form">
<label>Платформа
<select name="platform" required>
{% for key, label in platforms %}
<option value="{{ key }}" {% if form and form.platform == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Стиль (например: дружелюбный, с юмором, экспертный)
<input type="text" name="style" value="{{ form.style if form else '' }}" required>
</label>
<label>Пожелания к посту
<textarea name="wishes" rows="3">{{ form.wishes if form else '' }}</textarea>
</label>
<button type="submit">Сгенерировать</button>
</form>
{% if result %}
<h2>Готовый пост</h2>
<div class="card result">{{ result }}</div>
{% endif %}
<p><a href="/dashboard">← Назад в кабинет</a></p>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}История постов — Личный кабинет{% endblock %}
{% block content %}
<h1>История постов</h1>
{% if posts %}
<ul class="post-list">
{% for post in posts %}
<li class="card">
<div class="post-meta">{{ platform_label(post.platform) }} · {{ post.created_at }}</div>
<div class="post-content" id="post-content-{{ post.id }}">{{ post.content }}</div>
<div class="post-actions">
<button type="button" class="copy-btn" data-copy-target="post-content-{{ post.id }}">📋 Копировать</button>
<a class="button-link" href="/posts/{{ post.id }}">👁 Открыть</a>
<a class="button-link" href="/posts/{{ post.id }}/edit">✏️ Изменить</a>
<form method="post" action="/posts/{{ post.id }}/delete" class="inline-form"
onsubmit="return confirm('Удалить этот пост? Действие необратимо.');">
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
</form>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="card">
<p>Постов пока нет.</p>
<p><a href="/generate">💡 Сгенерировать первый пост</a></p>
</div>
{% endif %}
<p><a href="/dashboard">← Назад в кабинет</a></p>
{% endblock %}
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Сгенерировать картинку — Личный кабинет{% endblock %}
{% block content %}
<h1>Генерация картинки</h1>
<form method="post" action="/images/generate" class="form">
<label>Что нарисовать?
<textarea name="prompt" rows="4" required placeholder="Например: современный кофейный стакан на белом фоне, логотип для кофейни">{{ form.prompt if form else '' }}</textarea>
</label>
<label>Размер
<select name="size" required>
{% for key, (value, label) in sizes.items() %}
<option value="{{ key }}" {% if form and form.size == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Стиль
<select name="style">
{% for key, (label, suffix) in styles.items() %}
<option value="{{ key }}" {% if form and form.style == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">🎨 Сгенерировать</button>
<p class="form-hint">Генерация занимает 10–60 секунд. Картинка сохранится в медиа-библиотеку.</p>
</form>
<p><a href="/images">← Назад в библиотеку</a></p>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Картинки — Личный кабинет{% endblock %}
{% block content %}
<h1>Медиа-библиотека</h1>
<p><a class="copy-btn" href="/images/generate">🎨 Сгенерировать картинку</a></p>
{% if images %}
<div class="media-grid">
{% for img in images %}
<div class="media-card">
<a href="/media/{{ img.filename }}" target="_blank" title="Открыть в полном размере">
<img class="media-thumb" src="/media/{{ img.filename }}" alt="{{ img.prompt }}" loading="lazy">
</a>
<div class="media-body">
<div class="media-prompt" title="{{ img.prompt }}">{{ img.prompt }}</div>
<div class="post-meta">{{ img.created_at }}</div>
<div class="post-actions">
<a class="copy-btn" href="/media/{{ img.filename }}" download>⬇ Скачать</a>
<form method="post" action="/images/{{ img.id }}/delete" class="inline-form"
onsubmit="return confirm('Удалить эту картинку?');">
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card">
<p>Картинок пока нет.</p>
<p><a href="/images/generate">🎨 Сгенерировать первую картинку</a></p>
</div>
{% endif %}
<p><a href="/dashboard">← Назад в кабинет</a></p>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Вход — Личный кабинет{% endblock %}
{% block content %}
<h1>Вход</h1>
<form method="post" action="/login" class="form">
<label>Email
<input type="email" name="email" value="{{ form.email if form else '' }}" required>
</label>
<label>Пароль
<input type="password" name="password" required>
</label>
<button type="submit">Войти</button>
</form>
<p>Ещё нет аккаунта? <a href="/register">Зарегистрироваться</a></p>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Контент-план — Личный кабинет{% endblock %}
{% block content %}
<h1>Контент-план</h1>
{% if plan %}
<div class="chips">
<span class="chip chip-planned">📝 В плане: {{ status_counts.get('planned', 0) }}</span>
<span class="chip chip-published">✅ Опубликовано: {{ status_counts.get('published', 0) }}</span>
<span class="chip chip-postponed">🕓 Отложено: {{ status_counts.get('postponed', 0) }}</span>
</div>
<div class="plan-list">
{% for item in plan %}
<div class="card plan-item status-{{ item.status }}">
<div class="plan-item-head">
<span class="plan-day">День {{ item.day }}</span>
<span class="plan-platform">{{ platform_label(item.platform) }}</span>
<span class="status-badge status-{{ item.status }}">
{% if item.status == 'published' %}✅ Опубликовано
{% elif item.status == 'postponed' %}🕓 Отложено
{% else %}📝 В плане{% endif %}
</span>
</div>
<div class="plan-idea">{{ item.idea }}</div>
<div class="post-actions">
<form method="post" action="/plan/{{ item.id }}/status" class="inline-form">
<select name="status" class="status-select">
<option value="planned" {% if item.status == 'planned' %}selected{% endif %}>📝 В плане</option>
<option value="published" {% if item.status == 'published' %}selected{% endif %}>✅ Опубликовано</option>
<option value="postponed" {% if item.status == 'postponed' %}selected{% endif %}>🕓 Отложено</option>
</select>
<button type="submit" class="copy-btn">Сохранить</button>
</form>
<form method="post" action="/plan/{{ item.id }}/delete" class="inline-form"
onsubmit="return confirm('Удалить этот пункт плана?');">
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card">
<p>Контент-плана пока нет. Составь его на 1–14 дней — он будет сохранён, и ты сможешь отмечать опубликованные и отложенные посты.</p>
</div>
{% endif %}
<h2>{% if plan %}Пересоставить план{% else %}Составить план{% endif %}</h2>
<form method="post" action="/plan" class="form"
onsubmit="{% if plan %}return confirm('Новый план заменит текущий (отметки статусов сбросятся). Продолжить?');{% endif %}">
<label>На сколько дней (1-14)
<input type="number" name="days" min="1" max="14" value="7" required>
</label>
<button type="submit">📅 Составить план</button>
</form>
<p><a href="/dashboard">← Назад в кабинет</a></p>
{% endblock %}
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Пост — Личный кабинет{% endblock %}
{% block content %}
<h1>Пост</h1>
<div class="card">
<div class="post-meta">
{{ platform_label(post.platform) }} · {{ post.created_at }}
{% if post.style %} · Стиль: {{ post.style }}{% endif %}
</div>
<div class="post-content" id="post-content-{{ post.id }}">{{ post.content }}</div>
{% if post.wishes %}
<div class="post-notes"><strong>Пожелания:</strong> {{ post.wishes }}</div>
{% endif %}
</div>
<div class="post-actions">
<button type="button" class="copy-btn" data-copy-target="post-content-{{ post.id }}">📋 Копировать</button>
<a class="button-link" href="/posts/{{ post.id }}/edit">✏️ Изменить</a>
<form method="post" action="/posts/{{ post.id }}/delete" class="inline-form"
onsubmit="return confirm('Удалить этот пост? Действие необратимо.');">
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
</form>
</div>
<p><a href="/history">← Назад к истории</a></p>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}Изменить пост — Личный кабинет{% endblock %}
{% block content %}
<h1>Изменить пост</h1>
<div class="card">
<p class="post-meta">{{ platform_label(post.platform) }} · {{ post.created_at }}</p>
<form method="post" action="/posts/{{ post.id }}/edit" class="form">
<label>Текст поста
<textarea name="content" rows="12" required>{{ post.content }}</textarea>
</label>
<div class="form-actions">
<button type="submit">💾 Сохранить</button>
<a class="button-link" href="/posts/{{ post.id }}">Отмена</a>
</div>
</form>
</div>
<p><a href="/history">← Назад к истории</a></p>
{% endblock %}
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Профиль — Личный кабинет{% endblock %}
{% block content %}
<h1>Редактировать профиль</h1>
<form method="post" action="/profile/edit" class="form">
<label>Имя
<input type="text" name="name" value="{{ user.name }}" required>
</label>
<label>Телефон
<input type="text" name="phone" value="{{ user.phone }}" required>
</label>
<label>Вид бизнеса
<select name="business_type" required>
{% for key, value in business_types.items() %}
<option value="{{ key }}" {% if key == user.business_type|string %}selected{% endif %}>{{ value.name }}</option>
{% endfor %}
</select>
</label>
<label>Описание бизнеса
<textarea name="description" rows="4" required>{{ user.description }}</textarea>
</label>
<button type="submit">Сохранить</button>
</form>
<p><a href="/dashboard">← Назад в кабинет</a></p>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Регистрация — Личный кабинет{% endblock %}
{% block content %}
<h1>Регистрация</h1>
<form method="post" action="/register" class="form">
<label>Имя
<input type="text" name="name" value="{{ form.name if form else '' }}" required>
</label>
<label>Телефон
<input type="text" name="phone" value="{{ form.phone if form else '' }}" required>
</label>
<label>Вид бизнеса
<select name="business_type" required>
<option value="" disabled {% if not form %}selected{% endif %}>Выбери вид бизнеса</option>
{% for key, value in business_types.items() %}
<option value="{{ key }}" {% if form and form.business_type == key %}selected{% endif %}>{{ value.name }}</option>
{% endfor %}
</select>
</label>
<label>Описание бизнеса
<textarea name="description" rows="4" required>{{ form.description if form else '' }}</textarea>
</label>
<label>Email
<input type="email" name="email" value="{{ form.email if form else '' }}" required>
</label>
<label>Пароль
<input type="password" name="password" minlength="8" required>
</label>
<label>Повтори пароль
<input type="password" name="password_confirm" minlength="8" required>
</label>
<button type="submit">Зарегистрироваться</button>
</form>
<p>Уже есть аккаунт? <a href="/login">Войти</a></p>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Статистика — Личный кабинет{% endblock %}
{% block content %}
<h1>Статистика использования</h1>
<div class="card">
<p><strong>В кабинете с:</strong> {{ stats.created_at }}</p>
<p><strong>Всего постов:</strong> {{ stats.total_posts }}</p>
</div>
<h2>По платформам</h2>
{% if stats.by_platform %}
<ul>
{% for platform, count in stats.by_platform.items() %}
<li>{{ platform }}: {{ count }}</li>
{% endfor %}
</ul>
{% else %}
<p>Пока нет постов.</p>
{% endif %}
<p><a href="/dashboard">← Назад в кабинет</a></p>
{% endblock %}
+47 -7
View File
@@ -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 загружен!")
+388
View File
@@ -1,5 +1,8 @@
import sqlite3
# Поля профиля, которые разрешено менять через "личный кабинет"
EDITABLE_FIELDS = {'name', 'phone', 'business_type', 'description'}
def create_db():
conn = sqlite3.connect('users.db')
cursor = conn.cursor()
@@ -16,6 +19,67 @@ def create_db():
)
''')
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()
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11 -1
View File
@@ -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):
"""Показывает главное меню"""
@@ -87,6 +88,15 @@ def handle_menu_choice(message, bot):
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):
+9 -2
View File
@@ -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)
+12 -5
View File
@@ -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,12 +88,12 @@ 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
@@ -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 - начать работу")
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://opencode.ai/config.json"
}
+6 -4
View File
@@ -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 # (опционально, для локальных моделей)
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
BIN
View File
Binary file not shown.