From 9af84db429ae5861c062be3ce2f72aa3374c4def Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Tue, 12 Aug 2025 21:55:56 +0900 Subject: [PATCH] MVP ready. Fully functional (registration? moderation, profiles vew) --- docker-compose.yml | 3 + services/bot/alembic/env.py | 34 +- services/bot/alembic/versions/0001_init.py | 8 + services/bot/app/core/config.py | 2 + services/bot/app/core/db.py | 22 ++ services/bot/app/handlers/admin.py | 141 +++++++ services/bot/app/handlers/conversation.py | 348 ++++++++++++++++++ services/bot/app/handlers/profile.py | 41 +++ services/bot/app/main.py | 34 +- services/bot/app/models/__init__.py | 3 + services/bot/app/models/admin.py | 14 + services/bot/app/models/base.py | 6 + services/bot/app/models/candidate.py | 53 +++ services/bot/app/repositories/admin_repo.py | 24 ++ .../bot/app/repositories/candidate_repo.py | 40 ++ services/bot/app/services/admin_bootstrap.py | 12 + services/bot/app/utils/common.py | 15 + 17 files changed, 782 insertions(+), 18 deletions(-) create mode 100644 services/bot/app/core/db.py create mode 100644 services/bot/app/handlers/admin.py create mode 100644 services/bot/app/handlers/conversation.py create mode 100644 services/bot/app/handlers/profile.py create mode 100644 services/bot/app/models/__init__.py create mode 100644 services/bot/app/models/admin.py create mode 100644 services/bot/app/models/base.py create mode 100644 services/bot/app/models/candidate.py create mode 100644 services/bot/app/repositories/admin_repo.py create mode 100644 services/bot/app/repositories/candidate_repo.py create mode 100644 services/bot/app/services/admin_bootstrap.py create mode 100644 services/bot/app/utils/common.py diff --git a/docker-compose.yml b/docker-compose.yml index 7271dc8..51f9fe6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,9 @@ services: dockerfile: Dockerfile args: - PY_VERSION=3.12 + environment: + TZ: ${TZ} + PYTHONPATH: /app depends_on: db: condition: service_healthy diff --git a/services/bot/alembic/env.py b/services/bot/alembic/env.py index a24cd53..6bb08d3 100644 --- a/services/bot/alembic/env.py +++ b/services/bot/alembic/env.py @@ -1,24 +1,42 @@ from __future__ import annotations -import os +import os, sys from logging.config import fileConfig + from sqlalchemy import engine_from_config, pool from alembic import context config = context.config -# Подменяем URL из переменных окружения контейнера -if os.getenv("DATABASE_URL"): - config.set_main_option("sqlalchemy.url", os.getenv("DATABASE_URL")) -fileConfig(config.config_file_name) +# Возьми URL из переменных контейнера +db_url = os.getenv("DATABASE_URL") +if db_url: + config.set_main_option("sqlalchemy.url", db_url) + +# Лог-конфиг (без падения, если секций нет) +cfg_name = config.config_file_name +if cfg_name: + try: + fileConfig(cfg_name) + except Exception: + pass + +# Обеспечим PYTHONPATH=/app для импорта пакета app +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) # Метаданные моделей -from app.models.base import Base # noqa: E402 +from app.models.base import Base # noqa target_metadata = Base.metadata def run_migrations_offline(): url = config.get_main_option("sqlalchemy.url") - context.configure(url=url, target_metadata=target_metadata, literal_binds=True, - dialect_opts={"paramstyle": "named"}) + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) with context.begin_transaction(): context.run_migrations() diff --git a/services/bot/alembic/versions/0001_init.py b/services/bot/alembic/versions/0001_init.py index 6749f49..fadbe1b 100644 --- a/services/bot/alembic/versions/0001_init.py +++ b/services/bot/alembic/versions/0001_init.py @@ -20,6 +20,7 @@ def upgrade(): sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True), sa.Column('telegram_id', sa.Integer(), unique=True, index=True), sa.Column('username', sa.String(length=100), nullable=True), + sa.Column('full_name', sa.String(length=120), nullable=True), sa.Column('gender', sa.String(length=20), nullable=True), sa.Column('birth_date', sa.Date(), nullable=True), @@ -30,6 +31,7 @@ def upgrade(): sa.Column('citizenship', sa.String(length=80), nullable=True), sa.Column('visa_status', sa.String(length=60), nullable=True), sa.Column('languages', sa.String(length=200), nullable=True), + sa.Column('education', sa.String(length=120), nullable=True), sa.Column('occupation', sa.String(length=120), nullable=True), sa.Column('income_range', sa.String(length=60), nullable=True), @@ -37,19 +39,25 @@ def upgrade(): sa.Column('marital_status', sa.String(length=60), nullable=True), sa.Column('has_children', sa.Boolean(), nullable=True), sa.Column('children_notes', sa.String(length=200), nullable=True), + sa.Column('smoking', sa.String(length=20), nullable=True), sa.Column('alcohol', sa.String(length=20), nullable=True), sa.Column('health_notes', sa.String(length=300), nullable=True), + sa.Column('hobbies_tags', sa.String(length=300), nullable=True), sa.Column('hobbies_free', sa.Text(), nullable=True), + sa.Column('goal', sa.String(length=120), nullable=True), sa.Column('partner_prefs', sa.Text(), nullable=True), + sa.Column('avatar_file_id', sa.String(length=200), nullable=True), sa.Column('gallery_file_ids', sa.Text(), nullable=True), + sa.Column('consent_personal', sa.Boolean(), nullable=False, server_default=sa.text('0')), sa.Column('consent_policy', sa.Boolean(), nullable=False, server_default=sa.text('0')), sa.Column('is_verified', sa.Boolean(), nullable=False, server_default=sa.text('0')), sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP')), ) diff --git a/services/bot/app/core/config.py b/services/bot/app/core/config.py index eb69437..82faf83 100644 --- a/services/bot/app/core/config.py +++ b/services/bot/app/core/config.py @@ -2,4 +2,6 @@ import os class Settings: BOT_TOKEN = os.getenv("BOT_TOKEN", "") DATABASE_URL = os.getenv("DATABASE_URL", "") + PRIMARY_ADMIN_TELEGRAM_ID = os.getenv("PRIMARY_ADMIN_TELEGRAM_ID", "") + settings = Settings() diff --git a/services/bot/app/core/db.py b/services/bot/app/core/db.py new file mode 100644 index 0000000..fd1b8ac --- /dev/null +++ b/services/bot/app/core/db.py @@ -0,0 +1,22 @@ +from __future__ import annotations +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from app.core.config import settings + +# Создаём движок SQLAlchemy +engine = create_engine( + settings.DATABASE_URL, + future=True, + pool_pre_ping=True, +) + +# Фабрика сессий +SessionLocal = sessionmaker( + bind=engine, + expire_on_commit=False, + future=True, +) + +def db_session() -> Session: + """Получить новую сессию БД.""" + return SessionLocal() diff --git a/services/bot/app/handlers/admin.py b/services/bot/app/handlers/admin.py new file mode 100644 index 0000000..bfd9fca --- /dev/null +++ b/services/bot/app/handlers/admin.py @@ -0,0 +1,141 @@ +from __future__ import annotations +from telegram import Update +from telegram.ext import ContextTypes +from sqlalchemy import desc + +from app.core.db import db_session +from app.repositories.admin_repo import AdminRepository +from app.repositories.candidate_repo import CandidateRepository +from app.models.candidate import Candidate + +from app.utils.common import csv_to_list + +async def add_admin_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Команда /addadmin — добавить нового администратора (только для админов).""" + with db_session() as s: + repo = AdminRepository(s) + if not repo.is_admin(update.effective_user.id): + await update.message.reply_text("Недостаточно прав.") + return + + args = context.args or [] + if not args: + await update.message.reply_text("Формат: /addadmin 123456789") + return + + try: + tid = int(args[0]) + except ValueError: + await update.message.reply_text("Неверный telegram_id.") + return + + from app.models.admin import Admin + if s.query(Admin).filter(Admin.telegram_id == tid).first(): + await update.message.reply_text("Этот администратор уже добавлен.") + return + + s.add(Admin(telegram_id=tid)) + s.commit() + await update.message.reply_text(f"Администратор {tid} добавлен.") + + +async def list_candidates_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Команда /candidates — показать последние анкеты (только для админов).""" + with db_session() as s: + admin_repo = AdminRepository(s) + if not admin_repo.is_admin(update.effective_user.id): + await update.message.reply_text("Недостаточно прав.") + return + + rows = s.query(Candidate).order_by(desc(Candidate.created_at)).limit(30).all() + if not rows: + await update.message.reply_text("Кандидатов пока нет.") + return + + out = [] + for c in rows: + out.append( + f"#{c.id} {c.full_name or '—'} | {c.city or '—'} | цель: {c.goal or '—'} | @{c.username or ''}" + ) + await update.message.reply_text("Последние анкеты:\n" + "\n".join(out)) + + +async def verify_candidate_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Команда /verify — пометить анкету как проверенную (только для админов).""" + with db_session() as s: + admin_repo = AdminRepository(s) + if not admin_repo.is_admin(update.effective_user.id): + await update.message.reply_text("Недостаточно прав.") + return + + args = context.args or [] + if not args: + await update.message.reply_text("Формат: /verify 12") + return + + try: + cid = int(args[0]) + except ValueError: + await update.message.reply_text("Укажи числовой id анкеты.") + return + + c = s.get(Candidate, cid) + if not c: + await update.message.reply_text("Анкета не найдена.") + return + + c.is_verified = True + s.commit() + await update.message.reply_text(f"Анкета #{cid} помечена как проверенная.") + +async def view_candidate_handler(update, context): + """Команда /view — показать карточку анкеты с фото (для админов).""" + with db_session() as s: + admin_repo = AdminRepository(s) + if not admin_repo.is_admin(update.effective_user.id): + await update.message.reply_text("Недостаточно прав.") + return + + args = context.args or [] + if not args: + await update.message.reply_text("Формат: /view 12") + return + + try: + cid = int(args[0]) + except ValueError: + await update.message.reply_text("Укажи числовой id анкеты.") + return + + c = s.get(Candidate, cid) + if not c: + await update.message.reply_text("Анкета не найдена.") + return + + # Текстовая карточка + caption = ( + f"#{c.id} {c.full_name or '—'} @{c.username or ''}\n" + f"Город: {c.city or '—'} | Цель: {c.goal or '—'}\n" + f"Статус: {'✅ проверена' if c.is_verified else '⏳ на проверке'} | Активна: {'да' if c.is_active else 'нет'}" + ) + + chat_id = update.effective_chat.id + + # Аватар (если есть) + if c.avatar_file_id: + await context.bot.send_photo(chat_id=chat_id, photo=c.avatar_file_id, caption=caption) + else: + await context.bot.send_message(chat_id=chat_id, text=caption + "\n(аватар отсутствует)") + + # Галерея (если есть) + gallery_ids = csv_to_list(c.gallery_file_ids) + if gallery_ids: + # Telegram принимает до 10 media за раз + chunk = [] + for fid in gallery_ids: + chunk.append(InputMediaPhoto(media=fid)) + if len(chunk) == 10: + await context.bot.send_media_group(chat_id=chat_id, media=chunk) + chunk = [] + if chunk: + await context.bot.send_media_group(chat_id=chat_id, media=chunk) \ No newline at end of file diff --git a/services/bot/app/handlers/conversation.py b/services/bot/app/handlers/conversation.py new file mode 100644 index 0000000..5eacb3f --- /dev/null +++ b/services/bot/app/handlers/conversation.py @@ -0,0 +1,348 @@ +from __future__ import annotations +from datetime import datetime, date +from typing import List + +from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, InputMediaPhoto +from telegram.ext import ConversationHandler, CommandHandler, MessageHandler, filters + +from app.core.db import db_session +from app.models.candidate import Candidate +from app.repositories.admin_repo import AdminRepository +from app.utils.common import csv_to_list, list_to_csv, calc_age +from app.services.admin_bootstrap import ensure_primary_admin + +( + S_NAME, S_GENDER, S_BIRTH, S_HEIGHT, S_WEIGHT, + S_COUNTRY, S_CITY, S_CITIZEN, S_VISA, S_LANGS, + S_EDU, S_OCCUP, S_INCOME, S_RELIGION, S_MARITAL, S_CHILDREN, S_CHILDREN_NOTES, + S_SMOKING, S_ALCOHOL, S_HEALTH, + S_HOBBIES_TAGS, S_HOBBIES_FREE, + S_GOAL, S_PARTNER, + S_AVATAR, S_GALLERY, S_CONSENTS, S_CONFIRM +) = range(28) + +GENDER_KB = [["жен", "муж"], ["другое"]] +YESNO_KB = [["да", "нет"]] +SMOKE_ALC_KB = [["нет", "иногда", "да"]] +MARITAL_KB = [["никогда не был(а) в браке"], ["в разводе"], ["вдова/вдовец"]] +INCOME_KB = [["до 2 млн KRW", "2–4 млн"], ["4–6 млн", "6+ млн"], ["не указывать"]] + +def build_conversation() -> ConversationHandler: + return ConversationHandler( + entry_points=[CommandHandler("start", start), CommandHandler("new", start)], + states={ + S_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_name)], + S_GENDER: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_gender)], + S_BIRTH: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_birth)], + S_HEIGHT: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_height)], + S_WEIGHT: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_weight)], + S_COUNTRY: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_country)], + S_CITY: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_city)], + S_CITIZEN: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_citizen)], + S_VISA: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_visa)], + S_LANGS: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_langs)], + S_EDU: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_edu)], + S_OCCUP: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_occup)], + S_INCOME: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_income)], + S_RELIGION: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_religion)], + S_MARITAL: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_marital)], + S_CHILDREN: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_children)], + S_CHILDREN_NOTES: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_children_notes)], + S_SMOKING: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_smoking)], + S_ALCOHOL: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_alcohol)], + S_HEALTH: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_health)], + S_HOBBIES_TAGS: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_hobbies_tags)], + S_HOBBIES_FREE: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_hobbies_free)], + S_GOAL: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_goal)], + S_PARTNER: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_partner)], + S_AVATAR: [ + MessageHandler(filters.PHOTO, s_avatar), + MessageHandler(filters.TEXT & ~filters.COMMAND, s_avatar), + ], + S_GALLERY: [ + MessageHandler(filters.PHOTO, s_gallery), + MessageHandler(filters.TEXT & ~filters.COMMAND, s_gallery), + ], + S_CONSENTS: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_consents)], + S_CONFIRM: [MessageHandler(filters.TEXT & ~filters.COMMAND, s_confirm)], + }, + fallbacks=[CommandHandler("cancel", cancel)], + allow_reentry=True, + ) + +async def start(update, context): + with db_session() as s: + ensure_primary_admin(s) + exists = s.query(Candidate).filter(Candidate.telegram_id == update.effective_user.id).first() + if exists: + await update.message.reply_text( + "Анкета уже существует. Посмотреть — /my, редактирование — /edit (скоро), начать заново — /new", + reply_markup=ReplyKeyboardRemove(), + ) + return ConversationHandler.END + + await update.message.reply_text( + "Привет! Это анкета для брачного сервиса с сопровождением до визы F‑6 🇰🇷\nКак тебя зовут?", + reply_markup=ReplyKeyboardRemove(), + ) + return S_NAME + +async def s_name(update, context): + context.user_data["full_name"] = update.message.text.strip() + await update.message.reply_text("Твой пол?", reply_markup=ReplyKeyboardMarkup(GENDER_KB, one_time_keyboard=True, resize_keyboard=True)) + return S_GENDER + +async def s_gender(update, context): + context.user_data["gender"] = update.message.text.strip().lower() + await update.message.reply_text("Дата рождения (ДД.ММ.ГГГГ):", reply_markup=ReplyKeyboardRemove()) + return S_BIRTH + +async def s_birth(update, context): + try: + d = datetime.strptime(update.message.text.strip(), "%d.%m.%Y").date() + except Exception: + await update.message.reply_text("Неверный формат. Пример: 31.12.1995") + return S_BIRTH + context.user_data["birth_date"] = d + await update.message.reply_text("Рост (см):") + return S_HEIGHT + +async def s_height(update, context): + try: + context.user_data["height_cm"] = float(update.message.text.replace(",", ".")) + except Exception: + await update.message.reply_text("Введи число, например 168") + return S_HEIGHT + await update.message.reply_text("Вес (кг):") + return S_WEIGHT + +async def s_weight(update, context): + try: + context.user_data["weight_kg"] = float(update.message.text.replace(",", ".")) + except Exception: + await update.message.reply_text("Введи число, например 55") + return S_WEIGHT + await update.message.reply_text("Страна проживания:") + return S_COUNTRY + +async def s_country(update, context): + context.user_data["country"] = update.message.text.strip() + await update.message.reply_text("Город:") + return S_CITY + +async def s_city(update, context): + context.user_data["city"] = update.message.text.strip() + await update.message.reply_text("Гражданство (РФ/Казахстан/Узбекистан и т.п.):") + return S_CITIZEN + +async def s_citizen(update, context): + context.user_data["citizenship"] = update.message.text.strip() + await update.message.reply_text("Текущий визовый статус в Корее (нет/F‑4/H‑1 и т.п.):") + return S_VISA + +async def s_visa(update, context): + context.user_data["visa_status"] = update.message.text.strip() + await update.message.reply_text("Языки (через запятую, напр.: ru, ko, en):") + return S_LANGS + +async def s_langs(update, context): + context.user_data["languages"] = list_to_csv(csv_to_list(update.message.text)) + await update.message.reply_text("Образование (высшее/колледж/иное):") + return S_EDU + +async def s_edu(update, context): + context.user_data["education"] = update.message.text.strip() + await update.message.reply_text("Профессия:") + return S_OCCUP + +async def s_occup(update, context): + context.user_data["occupation"] = update.message.text.strip() + await update.message.reply_text( + "Доход (диапазон):", + reply_markup=ReplyKeyboardMarkup(INCOME_KB, one_time_keyboard=True, resize_keyboard=True), + ) + return S_INCOME + +async def s_income(update, context): + context.user_data["income_range"] = update.message.text.strip() + await update.message.reply_text("Религия (если нет, напиши «нет»):", reply_markup=ReplyKeyboardRemove()) + return S_RELIGION + +async def s_religion(update, context): + context.user_data["religion"] = update.message.text.strip() + await update.message.reply_text( + "Семейное положение:", + reply_markup=ReplyKeyboardMarkup(MARITAL_KB, one_time_keyboard=True, resize_keyboard=True), + ) + return S_MARITAL + +async def s_marital(update, context): + context.user_data["marital_status"] = update.message.text.strip() + await update.message.reply_text("Есть дети?", reply_markup=ReplyKeyboardMarkup(YESNO_KB, one_time_keyboard=True, resize_keyboard=True)) + return S_CHILDREN + +async def s_children(update, context): + ans = update.message.text.strip().lower() + context.user_data["has_children"] = (ans == "да") + if ans == "да": + await update.message.reply_text("Коротко про детей (кол-во/возраст/с кем проживают):", reply_markup=ReplyKeyboardRemove()) + return S_CHILDREN_NOTES + context.user_data["children_notes"] = None + await update.message.reply_text("Курение:", reply_markup=ReplyKeyboardMarkup(SMOKE_ALC_KB, one_time_keyboard=True, resize_keyboard=True)) + return S_SMOKING + +async def s_children_notes(update, context): + context.user_data["children_notes"] = update.message.text.strip() + await update.message.reply_text("Курение:", reply_markup=ReplyKeyboardMarkup(SMOKE_ALC_KB, one_time_keyboard=True, resize_keyboard=True)) + return S_SMOKING + +async def s_smoking(update, context): + context.user_data["smoking"] = update.message.text.strip().lower() + await update.message.reply_text("Алкоголь:", reply_markup=ReplyKeyboardMarkup(SMOKE_ALC_KB, one_time_keyboard=True, resize_keyboard=True)) + return S_ALCOHOL + +async def s_alcohol(update, context): + context.user_data["alcohol"] = update.message.text.strip().lower() + await update.message.reply_text("Здоровье/особенности (если есть; иначе «нет»):", reply_markup=ReplyKeyboardRemove()) + return S_HEALTH + +async def s_health(update, context): + context.user_data["health_notes"] = update.message.text.strip() + await update.message.reply_text("Хобби/интересы (теги через запятую, напр.: пение, путешествия, k‑drama):") + return S_HOBBIES_TAGS + +async def s_hobbies_tags(update, context): + context.user_data["hobbies_tags"] = list_to_csv(csv_to_list(update.message.text)) + await update.message.reply_text("Расскажи подробнее про интересы (свободный текст):") + return S_HOBBIES_FREE + +async def s_hobbies_free(update, context): + context.user_data["hobbies_free"] = update.message.text.strip() + await update.message.reply_text("Твоя основная цель (например: «брак с гражданином Кореи (F‑6)»):") + return S_GOAL + +async def s_goal(update, context): + context.user_data["goal"] = update.message.text.strip() + await update.message.reply_text("Пожелания к партнёру (возраст, ценности, город, религия и т.п.):") + return S_PARTNER + +async def s_partner(update, context): + context.user_data["partner_prefs"] = update.message.text.strip() + await update.message.reply_text("Загрузи своё фото (аватар):") + return S_AVATAR + +async def s_avatar(update, context): + if not update.message.photo: + await update.message.reply_text("Пожалуйста, пришли фотографию.") + return S_AVATAR + context.user_data["avatar_file_id"] = update.message.photo[-1].file_id + context.user_data["gallery_file_ids"] = [] + await update.message.reply_text("Можно добавить до 9 дополнительных фото. Пришли их. Когда закончишь — напиши «готово».") + return S_GALLERY + +async def s_gallery(update, context): + if update.message.photo: + fid = update.message.photo[-1].file_id + gallery: List[str] = context.user_data.get("gallery_file_ids", []) + if len(gallery) < 9: + gallery.append(fid) + context.user_data["gallery_file_ids"] = gallery + await update.message.reply_text(f"Добавлено фото ({len(gallery)}/9). Ещё? Или напиши «готово».") + else: + await update.message.reply_text("Лимит 9 фото достигнут. Напиши «готово».") + return S_GALLERY + + if (update.message.text or "").strip().lower() != "готово": + await update.message.reply_text("Если с фото закончили — напиши «готово».") + return S_GALLERY + + await update.message.reply_text("Подтверди согласие на обработку персональных данных. Напиши: «согласен» или «согласна».") + return S_CONSENTS + +async def s_consents(update, context): + ans = (update.message.text or "").strip().lower() + if ans not in ("согласен", "согласна"): + await update.message.reply_text("Нужно написать «согласен» или «согласна».") + return S_CONSENTS + context.user_data["consent_personal"] = True + context.user_data["consent_policy"] = True + + d = context.user_data + age = calc_age(d.get("birth_date")) + summary = ( + f"Проверь данные:\n\n" + f"Имя: {d.get('full_name')}\n" + f"Пол: {d.get('gender')}\n" + f"Дата рождения: {d.get('birth_date')} (возраст: {age})\n" + f"Рост/вес: {d.get('height_cm')} см / {d.get('weight_kg')} кг\n" + f"Страна/город: {d.get('country')}, {d.get('city')}\n" + f"Гражданство: {d.get('citizenship')}\n" + f"Виза/статус: {d.get('visa_status')}\n" + f"Языки: {d.get('languages')}\n" + f"Образование/Профессия: {d.get('education')} / {d.get('occupation')}\n" + f"Доход: {d.get('income_range')}\n" + f"Религия: {d.get('religion')}\n" + f"Семейное положение: {d.get('marital_status')}\n" + f"Дети: {'да' if d.get('has_children') else 'нет'} {'(' + d.get('children_notes') + ')' if d.get('children_notes') else ''}\n" + f"Курение/Алкоголь: {d.get('smoking')} / {d.get('alcohol')}\n" + f"Здоровье: {d.get('health_notes')}\n" + f"Хобби (теги): {d.get('hobbies_tags')}\n" + f"Интересы (текст): {d.get('hobbies_free')}\n" + f"Цель: {d.get('goal')}\n" + f"Пожелания к партнёру: {d.get('partner_prefs')}\n" + f"\nЕсли всё ок — напиши «подтверждаю». Иначе /cancel и /new." + ) + await update.message.reply_text(summary) + return S_CONFIRM + +async def s_confirm(update, context): + if (update.message.text or "").strip().lower() != "подтверждаю": + await update.message.reply_text('Напиши «подтверждаю» для сохранения, либо /cancel.') + return S_CONFIRM + + u = update.effective_user + d = context.user_data + with db_session() as s: + cand = s.query(Candidate).filter(Candidate.telegram_id == u.id).first() + if not cand: + cand = Candidate(telegram_id=u.id, username=u.username or None) + s.add(cand) + + cand.full_name = d.get("full_name") + cand.gender = d.get("gender") + cand.birth_date = d.get("birth_date") + cand.height_cm = d.get("height_cm") + cand.weight_kg = d.get("weight_kg") + cand.country = d.get("country") + cand.city = d.get("city") + cand.citizenship = d.get("citizenship") + cand.visa_status = d.get("visa_status") + cand.languages = d.get("languages") + cand.education = d.get("education") + cand.occupation = d.get("occupation") + cand.income_range = d.get("income_range") + cand.religion = d.get("religion") + cand.marital_status = d.get("marital_status") + cand.has_children = d.get("has_children") + cand.children_notes = d.get("children_notes") + cand.smoking = d.get("smoking") + cand.alcohol = d.get("alcohol") + cand.health_notes = d.get("health_notes") + cand.hobbies_tags = d.get("hobbies_tags") + cand.hobbies_free = d.get("hobbies_free") + cand.goal = d.get("goal") + cand.partner_prefs = d.get("partner_prefs") + cand.avatar_file_id = d.get("avatar_file_id") + cand.gallery_file_ids = list_to_csv(d.get("gallery_file_ids", [])) + cand.consent_personal = d.get("consent_personal", False) + cand.consent_policy = d.get("consent_policy", False) + cand.is_verified = False + cand.is_active = True + s.commit() + + await update.message.reply_text("Готово! Анкета сохранена. Свяжемся после модерации. Спасибо!") + return ConversationHandler.END + +async def cancel(update, context): + await update.message.reply_text("Ок, отменил. Для нового старта — /new") + return ConversationHandler.END diff --git a/services/bot/app/handlers/profile.py b/services/bot/app/handlers/profile.py new file mode 100644 index 0000000..d88b95d --- /dev/null +++ b/services/bot/app/handlers/profile.py @@ -0,0 +1,41 @@ +from __future__ import annotations +from telegram import Update, InputMediaPhoto +from telegram.ext import ContextTypes + +from app.core.db import db_session +from app.repositories.candidate_repo import CandidateRepository +from app.utils.common import csv_to_list + + +async def my_profile_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Команда /my — показать свою анкету.""" + with db_session() as s: + repo = CandidateRepository(s) + c = repo.by_tg(update.effective_user.id) + if not c: + await update.message.reply_text("Анкета не найдена. Начни с /new") + return + + text = ( + f"Твоя анкета:\n" + f"{c.full_name or '—'} (@{update.effective_user.username or ''})\n" + f"Город: {c.city or '—'}\n" + f"Цель: {c.goal or '—'}" + ) + + if c.avatar_file_id: + await update.message.reply_photo(photo=c.avatar_file_id, caption=text) + else: + await update.message.reply_text(text) + + gallery = csv_to_list(c.gallery_file_ids) + if gallery: + media = [InputMediaPhoto(g) for g in gallery[:10]] + await update.message.reply_media_group(media) + + +async def edit_hint_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Команда /edit — пока только подсказка о редактировании.""" + await update.message.reply_text( + "Точечное редактирование будет добавлено командами /edit_*. Пока что для правок — /new (перезаполнить анкету)." + ) diff --git a/services/bot/app/main.py b/services/bot/app/main.py index 7817e20..c53c5f3 100644 --- a/services/bot/app/main.py +++ b/services/bot/app/main.py @@ -1,14 +1,28 @@ -import asyncio +from __future__ import annotations from telegram.ext import Application, CommandHandler from app.core.config import settings -async def start(update, context): - await update.message.reply_text("Привет! Бот запущен.") -async def main(): +from app.handlers.conversation import build_conversation +from app.handlers.admin import add_admin_handler, list_candidates_handler, verify_candidate_handler, view_candidate_handler + +from app.handlers.profile import my_profile_handler, edit_hint_handler + +async def start_cmd(update, context): + await update.message.reply_text("Бот запущен. Набери /new, чтобы заполнить анкету.") + +def build_app() -> Application: app = Application.builder().token(settings.BOT_TOKEN).build() - app.add_handler(CommandHandler("start", start)) - await app.initialize() - await app.start() - await app.updater.start_polling() - await app.updater.wait_until_closed() + # Хендлеры + app.add_handler(build_conversation()) + app.add_handler(CommandHandler("start", start_cmd)) + app.add_handler(CommandHandler("my", my_profile_handler)) + app.add_handler(CommandHandler("edit", edit_hint_handler)) + app.add_handler(CommandHandler("addadmin", add_admin_handler)) + app.add_handler(CommandHandler("candidates", list_candidates_handler)) + app.add_handler(CommandHandler("verify", verify_candidate_handler)) + app.add_handler(CommandHandler("view", view_candidate_handler)) + return app + if __name__ == "__main__": - asyncio.run(main()) + application = build_app() + # В v21 это блокирующий вызов; updater больше не используется. + application.run_polling(close_loop=False) diff --git a/services/bot/app/models/__init__.py b/services/bot/app/models/__init__.py new file mode 100644 index 0000000..0848586 --- /dev/null +++ b/services/bot/app/models/__init__.py @@ -0,0 +1,3 @@ +from .base import Base # noqa +from .admin import Admin # noqa +from .candidate import Candidate # noqa diff --git a/services/bot/app/models/admin.py b/services/bot/app/models/admin.py new file mode 100644 index 0000000..ce287bd --- /dev/null +++ b/services/bot/app/models/admin.py @@ -0,0 +1,14 @@ +from __future__ import annotations +from datetime import datetime +from sqlalchemy import Integer, String, DateTime +from sqlalchemy.orm import Mapped, mapped_column +from app.models.base import Base + +class Admin(Base): + """Модель администратора бота.""" + __tablename__ = "admins" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + telegram_id: Mapped[int] = mapped_column(Integer, unique=True, index=True) + full_name: Mapped[str | None] = mapped_column(String(120), default=None) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/services/bot/app/models/base.py b/services/bot/app/models/base.py new file mode 100644 index 0000000..1f60ccc --- /dev/null +++ b/services/bot/app/models/base.py @@ -0,0 +1,6 @@ +from __future__ import annotations +from sqlalchemy.orm import DeclarativeBase + +class Base(DeclarativeBase): + """Базовый класс для всех моделей.""" + pass diff --git a/services/bot/app/models/candidate.py b/services/bot/app/models/candidate.py new file mode 100644 index 0000000..c038652 --- /dev/null +++ b/services/bot/app/models/candidate.py @@ -0,0 +1,53 @@ +from __future__ import annotations +from datetime import date, datetime +from sqlalchemy import Integer, String, Date, DateTime, Boolean, Float, Text +from sqlalchemy.orm import Mapped, mapped_column +from app.models.base import Base + +class Candidate(Base): + """Модель анкеты кандидата.""" + __tablename__ = "candidates" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + telegram_id: Mapped[int] = mapped_column(Integer, unique=True, index=True) + username: Mapped[str | None] = mapped_column(String(100), default=None) + + full_name: Mapped[str | None] = mapped_column(String(120)) + gender: Mapped[str | None] = mapped_column(String(20)) + birth_date: Mapped[date | None] = mapped_column(Date) + height_cm: Mapped[float | None] = mapped_column(Float) + weight_kg: Mapped[float | None] = mapped_column(Float) + country: Mapped[str | None] = mapped_column(String(80)) + city: Mapped[str | None] = mapped_column(String(120)) + citizenship: Mapped[str | None] = mapped_column(String(80)) + visa_status: Mapped[str | None] = mapped_column(String(60)) + languages: Mapped[str | None] = mapped_column(String(200)) + + education: Mapped[str | None] = mapped_column(String(120)) + occupation: Mapped[str | None] = mapped_column(String(120)) + income_range: Mapped[str | None] = mapped_column(String(60)) + religion: Mapped[str | None] = mapped_column(String(80)) + marital_status: Mapped[str | None] = mapped_column(String(60)) + has_children: Mapped[bool | None] = mapped_column(Boolean, default=None) + children_notes: Mapped[str | None] = mapped_column(String(200)) + + smoking: Mapped[str | None] = mapped_column(String(20)) + alcohol: Mapped[str | None] = mapped_column(String(20)) + health_notes: Mapped[str | None] = mapped_column(String(300)) + + hobbies_tags: Mapped[str | None] = mapped_column(String(300)) + hobbies_free: Mapped[str | None] = mapped_column(Text) + + goal: Mapped[str | None] = mapped_column(String(120)) + partner_prefs: Mapped[str | None] = mapped_column(Text) + + avatar_file_id: Mapped[str | None] = mapped_column(String(200)) + gallery_file_ids: Mapped[str | None] = mapped_column(Text) + + consent_personal: Mapped[bool] = mapped_column(Boolean, default=False) + consent_policy: Mapped[bool] = mapped_column(Boolean, default=False) + is_verified: Mapped[bool] = mapped_column(Boolean, default=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/services/bot/app/repositories/admin_repo.py b/services/bot/app/repositories/admin_repo.py new file mode 100644 index 0000000..0d947b3 --- /dev/null +++ b/services/bot/app/repositories/admin_repo.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from sqlalchemy.orm import Session +from app.models.admin import Admin + +class AdminRepository: + """Работа с таблицей администраторов.""" + + def __init__(self, session: Session): + self.s = session + + def is_admin(self, telegram_id: int) -> bool: + """Проверка, является ли пользователь администратором.""" + return self.s.query(Admin).filter(Admin.telegram_id == telegram_id).first() is not None + + def add_admin(self, telegram_id: int, full_name: str | None = None) -> Admin: + """Добавить нового администратора.""" + admin = Admin(telegram_id=telegram_id, full_name=full_name) + self.s.add(admin) + self.s.commit() + return admin + + def get_all_admins(self) -> list[Admin]: + """Получить всех администраторов.""" + return self.s.query(Admin).all() diff --git a/services/bot/app/repositories/candidate_repo.py b/services/bot/app/repositories/candidate_repo.py new file mode 100644 index 0000000..9e8d80f --- /dev/null +++ b/services/bot/app/repositories/candidate_repo.py @@ -0,0 +1,40 @@ +from __future__ import annotations +from sqlalchemy.orm import Session +from app.models.candidate import Candidate + + +class CandidateRepository: + """Работа с таблицей кандидатов.""" + + def __init__(self, session: Session): + self.s = session + + def by_tg(self, telegram_id: int) -> Candidate | None: + """Найти анкету по Telegram ID.""" + return self.s.query(Candidate).filter(Candidate.telegram_id == telegram_id).first() + + def by_id(self, candidate_id: int) -> Candidate | None: + """Найти анкету по ID.""" + return self.s.get(Candidate, candidate_id) + + def all(self, limit: int = 100) -> list[Candidate]: + """Получить список всех анкет (с лимитом).""" + return self.s.query(Candidate).order_by(Candidate.created_at.desc()).limit(limit).all() + + def add(self, candidate: Candidate) -> Candidate: + """Добавить новую анкету.""" + self.s.add(candidate) + self.s.commit() + self.s.refresh(candidate) + return candidate + + def update(self, candidate: Candidate) -> Candidate: + """Обновить существующую анкету.""" + self.s.commit() + self.s.refresh(candidate) + return candidate + + def delete(self, candidate: Candidate) -> None: + """Удалить анкету.""" + self.s.delete(candidate) + self.s.commit() diff --git a/services/bot/app/services/admin_bootstrap.py b/services/bot/app/services/admin_bootstrap.py new file mode 100644 index 0000000..d3d0497 --- /dev/null +++ b/services/bot/app/services/admin_bootstrap.py @@ -0,0 +1,12 @@ +from __future__ import annotations +from datetime import datetime +from sqlalchemy.orm import Session +from app.core.config import settings +from app.models.admin import Admin + +def ensure_primary_admin(session: Session) -> None: + if settings.PRIMARY_ADMIN_TELEGRAM_ID and not session.query(Admin).filter( + Admin.telegram_id == settings.PRIMARY_ADMIN_TELEGRAM_ID + ).first(): + session.add(Admin(telegram_id=settings.PRIMARY_ADMIN_TELEGRAM_ID, full_name="Primary Admin", created_at=datetime.utcnow())) + session.commit() diff --git a/services/bot/app/utils/common.py b/services/bot/app/utils/common.py new file mode 100644 index 0000000..15f6ff2 --- /dev/null +++ b/services/bot/app/utils/common.py @@ -0,0 +1,15 @@ +from __future__ import annotations +from datetime import date +from typing import List, Optional + +def csv_to_list(s: Optional[str]) -> List[str]: + return [x.strip() for x in (s or "").split(",") if x.strip()] + +def list_to_csv(items: List[str]) -> str: + return ", ".join([i.strip() for i in items if i.strip()]) + +def calc_age(born: Optional[date]) -> Optional[int]: + if not born: + return None + today = date.today() + return today.year - born.year - ((today.month, today.day) < (born.month, born.day))