From 0729e9196b9b845f6b44ea8fa4390701ea2fa265 Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Wed, 13 Aug 2025 05:21:25 +0900 Subject: [PATCH] i18n, messages are in templates --- .env | 4 +- services/bot/app/core/i18n.py | 110 ++++++++ services/bot/app/handlers/admin.py | 388 +++++++++++++++++++++++------ services/bot/app/main.py | 52 +++- 4 files changed, 466 insertions(+), 88 deletions(-) create mode 100644 services/bot/app/core/i18n.py diff --git a/.env b/.env index 2191eb0..076bd0d 100644 --- a/.env +++ b/.env @@ -10,4 +10,6 @@ DATABASE_URL=mysql+pymysql://match_user:match_password_change_me@db:3306/match_a BOT_TOKEN=7839684534:AAGAUmO756P1T81q8ImndWgIBU9OsZkY06s PRIMARY_ADMIN_TELEGRAM_ID=556399210 -PYTHONUNBUFFERED=1 \ No newline at end of file +PYTHONUNBUFFERED=1 + +BOT_LOCALE=ru \ No newline at end of file diff --git a/services/bot/app/core/i18n.py b/services/bot/app/core/i18n.py new file mode 100644 index 0000000..6bbd9b1 --- /dev/null +++ b/services/bot/app/core/i18n.py @@ -0,0 +1,110 @@ +from __future__ import annotations +import os + +def _lang() -> str: + # Язык берём из переменной окружения, по умолчанию RU + return (os.getenv("BOT_LOCALE") or "ru").lower() + +def t(key: str, **kwargs) -> str: + lang = _lang() + base = LOCALES.get("ru", {}) + s = LOCALES.get(lang, {}).get(key, base.get(key, key)) + try: + return s.format(**kwargs) + except Exception: + return s # на случай, если забыли передать плейсхолдеры + +LOCALES = { + "ru": { + # общие + "err.not_allowed": "Недостаточно прав.", + "err.not_found": "Анкета не найдена.", + "ok.done": "Готово!", + + # /candidates и карточка + "cand.caption": "#{id} {name} @{username}\nГород: {city} | Цель: {goal}\nСтатус: {verified} | Активна: {active}", + "cand.verified_yes": "✅ проверена", + "cand.verified_no": "⏳ на проверке", + "cand.active_yes": "да", + "cand.active_no": "нет", + + "btn.open": "👁 Открыть", + "btn.verify": "✅ Проверить", + "btn.hide": "🚫 Скрыть", + "btn.restore": "♻️ Вернуть", + "btn.prev": "◀️ Назад", + "btn.next": "Вперёд ▶️", + + # /list фильтры + "list.start_gender": "Фильтр по полу:", + "list.gender_f": "жен", + "list.gender_m": "муж", + "list.skip": "Пропустить", + "list.ask_city": "Укажи город (или напиши «пропустить»):", + "list.ask_verified": "Показывать только проверенные?", + "list.ask_active": "Показывать только активные?", + "list.searching": "Подбираю анкеты по фильтрам…", + "list.empty": "По фильтрам ничего не нашлось.", + + # профайл/визард (пример — можешь расширить) + "wiz.hello": "Привет! Это анкета для брачного сервиса с сопровождением до визы F‑6 🇰🇷\nКак тебя зовут?", + "wiz.exists": "Анкета уже существует. Посмотреть — /my, редактирование — /edit (скоро), начать заново — /new", + }, + + # ЗАГОТОВКИ под en/ko — при желании дополни + "en": { + "err.not_allowed": "Not allowed.", + "err.not_found": "Profile not found.", + "ok.done": "Done!", + "cand.caption": "#{id} {name} @{username}\nCity: {city} | Goal: {goal}\nStatus: {verified} | Active: {active}", + "cand.verified_yes": "✅ verified", + "cand.verified_no": "⏳ pending", + "cand.active_yes": "yes", + "cand.active_no": "no", + "btn.open": "👁 Open", + "btn.verify": "✅ Verify", + "btn.hide": "🚫 Hide", + "btn.restore": "♻️ Restore", + "btn.prev": "◀️ Prev", + "btn.next": "Next ▶️", + "list.start_gender": "Filter by gender:", + "list.gender_f": "female", + "list.gender_m": "male", + "list.skip": "Skip", + "list.ask_city": "City (or type 'skip'):", + "list.ask_verified": "Only verified?", + "list.ask_active": "Only active?", + "list.searching": "Searching…", + "list.empty": "No results by filters.", + "wiz.hello": "Hi! This is the application for our marriage service (visa F‑6 support). What's your name?", + "wiz.exists": "Your profile already exists. View: /my, edit: /edit (soon), restart: /new", + }, + + "ko": { + "err.not_allowed": "권한이 없습니다.", + "err.not_found": "프로필을 찾을 수 없습니다.", + "ok.done": "완료!", + "cand.caption": "#{id} {name} @{username}\n도시: {city} | 목표: {goal}\n상태: {verified} | 활성: {active}", + "cand.verified_yes": "✅ 인증됨", + "cand.verified_no": "⏳ 검토중", + "cand.active_yes": "예", + "cand.active_no": "아니오", + "btn.open": "👁 열기", + "btn.verify": "✅ 인증", + "btn.hide": "🚫 숨기기", + "btn.restore": "♻️ 복구", + "btn.prev": "◀️ 이전", + "btn.next": "다음 ▶️", + "list.start_gender": "성별 필터:", + "list.gender_f": "여", + "list.gender_m": "남", + "list.skip": "건너뛰기", + "list.ask_city": "도시를 입력하거나 '건너뛰기'를 입력하세요:", + "list.ask_verified": "인증된 프로필만 표시할까요?", + "list.ask_active": "활성 프로필만 표시할까요?", + "list.searching": "검색 중…", + "list.empty": "필터에 해당하는 결과가 없습니다.", + "wiz.hello": "안녕하세요! 혼인 서비스(F‑6 비자 지원) 신청서입니다. 이름이 무엇인가요?", + "wiz.exists": "프로필이 이미 있습니다. 보기: /my, 편집: /edit(곧), 다시 시작: /new", + }, +} diff --git a/services/bot/app/handlers/admin.py b/services/bot/app/handlers/admin.py index bfd9fca..0a14f67 100644 --- a/services/bot/app/handlers/admin.py +++ b/services/bot/app/handlers/admin.py @@ -1,99 +1,197 @@ from __future__ import annotations -from telegram import Update -from telegram.ext import ContextTypes +from dataclasses import dataclass +from typing import Optional, List + +from telegram import ( + Update, + InlineKeyboardMarkup, + InlineKeyboardButton, + InputMediaPhoto, +) +from telegram.ext import ( + ContextTypes, + CommandHandler, + CallbackQueryHandler, + ConversationHandler, + MessageHandler, + filters, +) from sqlalchemy import desc from app.core.db import db_session +from app.core.i18n import t 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 +PAGE_SIZE = 5 # можно менять - 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 +def _caption(c: Candidate) -> str: + return t( + "cand.caption", + id=c.id, + name=(c.full_name or "—"), + username=(c.username or ""), + city=(c.city or "—"), + goal=(c.goal or "—"), + verified=(t("cand.verified_yes") if c.is_verified else t("cand.verified_no")), + active=(t("cand.active_yes") if c.is_active else t("cand.active_no")), + ) - from app.models.admin import Admin - if s.query(Admin).filter(Admin.telegram_id == tid).first(): - await update.message.reply_text("Этот администратор уже добавлен.") - return +def cand_kb(c: Candidate) -> InlineKeyboardMarkup: + buttons = [ + [ + InlineKeyboardButton(t("btn.open"), callback_data=f"cand:view:{c.id}"), + InlineKeyboardButton(t("btn.verify"), callback_data=f"cand:verify:{c.id}"), + ], + [ + InlineKeyboardButton( + t("btn.hide") if c.is_active else t("btn.restore"), + callback_data=f"cand:toggle:{c.id}", + ), + ], + ] + return InlineKeyboardMarkup(buttons) - s.add(Admin(telegram_id=tid)) - s.commit() - await update.message.reply_text(f"Администратор {tid} добавлен.") +def pager_kb(prefix: str, page: int, has_prev: bool, has_next: bool) -> InlineKeyboardMarkup | None: + # prefix: "candlist" или "listres" + btns = [] + row = [] + if has_prev: + row.append(InlineKeyboardButton(t("btn.prev"), callback_data=f"{prefix}:page:{page-1}")) + if has_next: + row.append(InlineKeyboardButton(t("btn.next"), callback_data=f"{prefix}:page:{page+1}")) + if row: + btns.append(row) + return InlineKeyboardMarkup(btns) + return None +# =================== /candidates с пагинацией =============================== 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("Недостаточно прав.") + if not AdminRepository(s).is_admin(update.effective_user.id): + await update.message.reply_text(t("err.not_allowed")) return - rows = s.query(Candidate).order_by(desc(Candidate.created_at)).limit(30).all() + page = 0 + context.user_data["candlist_page"] = page + total = s.query(Candidate).count() + rows = (s.query(Candidate) + .order_by(desc(Candidate.created_at)) + .offset(page * PAGE_SIZE).limit(PAGE_SIZE).all()) + if not rows: - await update.message.reply_text("Кандидатов пока нет.") + await update.message.reply_text(t("list.empty")) 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)) + if c.avatar_file_id: + await update.message.reply_photo(photo=c.avatar_file_id, caption=_caption(c), reply_markup=cand_kb(c)) + else: + await update.message.reply_text(text=_caption(c), reply_markup=cand_kb(c)) + kb = pager_kb("candlist", page, has_prev=False, has_next=(total > (page + 1) * PAGE_SIZE)) + if kb: + await update.message.reply_text(" ", reply_markup=kb) # отдельным сообщением панель пагинации + +async def candlist_pager(update: Update, context: ContextTypes.DEFAULT_TYPE): + q = update.callback_query + await q.answer() + _, _, page_str = q.data.split(":") # candlist:page: + page = max(0, int(page_str)) + context.user_data["candlist_page"] = page -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("Недостаточно прав.") + if not AdminRepository(s).is_admin(update.effective_user.id): + await q.edit_message_text(t("err.not_allowed")) return - args = context.args or [] - if not args: - await update.message.reply_text("Формат: /verify 12") - return + total = s.query(Candidate).count() + rows = (s.query(Candidate) + .order_by(desc(Candidate.created_at)) + .offset(page * PAGE_SIZE).limit(PAGE_SIZE).all()) + # Обновляем "панель" пагинации (это отдельное сообщение) + kb = pager_kb("candlist", page, has_prev=(page > 0), has_next=(total > (page + 1) * PAGE_SIZE)) try: - cid = int(args[0]) - except ValueError: - await update.message.reply_text("Укажи числовой id анкеты.") + await q.edit_message_reply_markup(kb) + except Exception: + # если не панель, а текст — просто обновим текст + await q.edit_message_text(" ", reply_markup=kb) + + # а список карточек отправляем следующими сообщениями + chat_id = q.message.chat.id + for c in rows: + if c.avatar_file_id: + await q.bot.send_photo(chat_id=chat_id, photo=c.avatar_file_id, caption=_caption(c), reply_markup=cand_kb(c)) + else: + await q.bot.send_message(chat_id=chat_id, text=_caption(c), reply_markup=cand_kb(c)) + +# ======================== Кнопки карточек =================================== + +async def callback_router(update: Update, context: ContextTypes.DEFAULT_TYPE): + q = update.callback_query + await q.answer() + data = q.data or "" + if not data.startswith("cand:"): + return + _, action, id_str = data.split(":") + cid = int(id_str) + + with db_session() as s: + if not AdminRepository(s).is_admin(update.effective_user.id): + if q.message.photo: + await q.edit_message_caption(caption=t("err.not_allowed")) + else: + await q.edit_message_text(t("err.not_allowed")) return c = s.get(Candidate, cid) if not c: - await update.message.reply_text("Анкета не найдена.") + if q.message.photo: + await q.edit_message_caption(caption=t("err.not_found")) + else: + await q.edit_message_text(t("err.not_found")) return - c.is_verified = True - s.commit() - await update.message.reply_text(f"Анкета #{cid} помечена как проверенная.") + if action == "view": + cap = _caption(c) + if q.message.photo: + await q.edit_message_caption(caption=cap, reply_markup=cand_kb(c)) + else: + await q.edit_message_text(text=cap, reply_markup=cand_kb(c)) + return -async def view_candidate_handler(update, context): - """Команда /view — показать карточку анкеты с фото (для админов).""" + if action == "verify": + c.is_verified = True + s.commit() + cap = _caption(c) + if q.message.photo: + await q.edit_message_caption(caption=cap, reply_markup=cand_kb(c)) + else: + await q.edit_message_text(text=cap, reply_markup=cand_kb(c)) + return + + if action == "toggle": + c.is_active = not c.is_active + s.commit() + cap = _caption(c) + if q.message.photo: + await q.edit_message_caption(caption=cap, reply_markup=cand_kb(c)) + else: + await q.edit_message_text(text=cap, reply_markup=cand_kb(c)) + return + +# ============================= /view =================================== + +async def view_candidate_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): with db_session() as s: - admin_repo = AdminRepository(s) - if not admin_repo.is_admin(update.effective_user.id): - await update.message.reply_text("Недостаточно прав.") + if not AdminRepository(s).is_admin(update.effective_user.id): + await update.message.reply_text(t("err.not_allowed")) return args = context.args or [] @@ -109,33 +207,165 @@ async def view_candidate_handler(update, context): c = s.get(Candidate, cid) if not c: - await update.message.reply_text("Анкета не найдена.") + await update.message.reply_text(t("err.not_found")) 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 'нет'}" - ) - + caption = _caption(c) 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) + await context.bot.send_photo(chat_id=chat_id, photo=c.avatar_file_id, caption=caption, reply_markup=cand_kb(c)) else: - await context.bot.send_message(chat_id=chat_id, text=caption + "\n(аватар отсутствует)") + await context.bot.send_message(chat_id=chat_id, text=caption, reply_markup=cand_kb(c)) - # Галерея (если есть) gallery_ids = csv_to_list(c.gallery_file_ids) if gallery_ids: - # Telegram принимает до 10 media за раз - chunk = [] + batch: List[InputMediaPhoto] = [] 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 + batch.append(InputMediaPhoto(media=fid)) + if len(batch) == 10: + await context.bot.send_media_group(chat_id=chat_id, media=batch) + batch = [] + if batch: + await context.bot.send_media_group(chat_id=chat_id, media=batch) + +# ===================== /list — фильтры + пагинация ========================== + +F_GENDER, F_CITY, F_VERIFIED, F_ACTIVE = range(4) + +@dataclass +class ListFilters: + gender: Optional[str] = None + city: Optional[str] = None + verified: Optional[bool] = None + active: Optional[bool] = True + +def _kb_gender() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup([ + [InlineKeyboardButton(t("list.gender_f"), callback_data="list:gender:f"), + InlineKeyboardButton(t("list.gender_m"), callback_data="list:gender:m")], + [InlineKeyboardButton(t("list.skip"), callback_data="list:gender:skip")], + ]) + +def _kb_yesno(prefix: str) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup([ + [InlineKeyboardButton("Да", callback_data=f"list:{prefix}:yes"), + InlineKeyboardButton("Нет", callback_data=f"list:{prefix}:no")], + [InlineKeyboardButton(t("list.skip"), callback_data=f"list:{prefix}:skip")], + ]) + +async def list_start(update: Update, context: ContextTypes.DEFAULT_TYPE): + with db_session() as s: + if not AdminRepository(s).is_admin(update.effective_user.id): + await update.message.reply_text(t("err.not_allowed")) + return ConversationHandler.END + context.user_data["list_filters"] = ListFilters() + context.user_data["list_page"] = 0 + await update.message.reply_text(t("list.start_gender"), reply_markup=_kb_gender()) + return F_GENDER + +async def list_gender(update: Update, context: ContextTypes.DEFAULT_TYPE): + q = update.callback_query + await q.answer() + _, _, val = q.data.split(":") # list:gender: + lf: ListFilters = context.user_data.get("list_filters") # type: ignore + if val == "f": + lf.gender = t("list.gender_f") + elif val == "m": + lf.gender = t("list.gender_m") + await q.edit_message_text(t("list.ask_city"), + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(t("list.skip"), callback_data="list:city:skip")]])) + return F_CITY + +async def list_city(update: Update, context: ContextTypes.DEFAULT_TYPE): + lf: ListFilters = context.user_data.get("list_filters") # type: ignore + if update.callback_query: + await update.callback_query.answer() + await update.callback_query.edit_message_text(t("list.ask_verified")) + await update.callback_query.message.reply_text(t("list.ask_verified"), reply_markup=_kb_yesno("verified")) + return F_VERIFIED + + city = (update.message.text or "").strip() + if city.lower() != "пропустить": + lf.city = city + await update.message.reply_text(t("list.ask_verified"), reply_markup=_kb_yesno("verified")) + return F_VERIFIED + +async def list_verified(update: Update, context: ContextTypes.DEFAULT_TYPE): + q = update.callback_query + await q.answer() + _, _, val = q.data.split(":") + lf: ListFilters = context.user_data.get("list_filters") # type: ignore + if val == "yes": + lf.verified = True + elif val == "no": + lf.verified = False + await q.edit_message_text(t("list.ask_active")) + await q.message.reply_text(t("list.ask_active"), reply_markup=_kb_yesno("active")) + return F_ACTIVE + +async def list_active(update: Update, context: ContextTypes.DEFAULT_TYPE): + q = update.callback_query + await q.answer() + _, _, val = q.data.split(":") + lf: ListFilters = context.user_data.get("list_filters") # type: ignore + if val == "yes": + lf.active = True + elif val == "no": + lf.active = False + + context.user_data["list_page"] = 0 + await q.edit_message_text(t("list.searching")) + await _list_show_results(update, context) + return ConversationHandler.END + +async def _list_show_results(update: Update, context: ContextTypes.DEFAULT_TYPE, page: Optional[int] = None): + lf: ListFilters = context.user_data.get("list_filters") # type: ignore + page = context.user_data.get("list_page", 0) if page is None else page + + with db_session() as s: + if not AdminRepository(s).is_admin(update.effective_user.id): + await context.bot.send_message(chat_id=update.effective_chat.id, text=t("err.not_allowed")) + return + + q = s.query(Candidate) + if lf.gender: + q = q.filter(Candidate.gender == lf.gender) + if lf.city: + q = q.filter(Candidate.city.ilike(lf.city)) + if lf.verified is not None: + q = q.filter(Candidate.is_verified.is_(lf.verified)) + if lf.active is not None: + q = q.filter(Candidate.is_active.is_(lf.active)) + + total = q.count() + rows = q.order_by(desc(Candidate.created_at)).offset(page * PAGE_SIZE).limit(PAGE_SIZE).all() + + if not rows: + await context.bot.send_message(chat_id=update.effective_chat.id, text=t("list.empty")) + return + + # Отправим карточки + for c in rows: + cap = _caption(c) + if c.avatar_file_id: + await context.bot.send_photo(chat_id=update.effective_chat.id, photo=c.avatar_file_id, caption=cap, reply_markup=cand_kb(c)) + else: + await context.bot.send_message(chat_id=update.effective_chat.id, text=cap, reply_markup=cand_kb(c)) + + # Панель пагинации результатов /list + kb = pager_kb("listres", page, has_prev=(page > 0), has_next=(total > (page + 1) * PAGE_SIZE)) + if kb: + await context.bot.send_message(chat_id=update.effective_chat.id, text=" ", reply_markup=kb) + +async def list_results_pager(update: Update, context: ContextTypes.DEFAULT_TYPE): + q = update.callback_query + await q.answer() + _, _, page_str = q.data.split(":") # listres:page: + page = max(0, int(page_str)) + context.user_data["list_page"] = page + # просто обновляем "панель" и заново выводим карточки + try: + await q.edit_message_reply_markup(reply_markup=None) # убираем старую панель (по возможности) + except Exception: + pass + await _list_show_results(update, context) diff --git a/services/bot/app/main.py b/services/bot/app/main.py index c53c5f3..352e086 100644 --- a/services/bot/app/main.py +++ b/services/bot/app/main.py @@ -1,28 +1,64 @@ from __future__ import annotations +import logging from telegram.ext import Application, CommandHandler from app.core.config import settings 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 +from app.handlers.admin import ( + list_candidates_handler, candlist_pager, callback_router, + view_candidate_handler, list_start, list_gender, list_city, list_verified, list_active, list_results_pager +) + +logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") +log = logging.getLogger("bot") async def start_cmd(update, context): await update.message.reply_text("Бот запущен. Набери /new, чтобы заполнить анкету.") def build_app() -> Application: + if not settings.BOT_TOKEN: + raise RuntimeError("BOT_TOKEN is empty. Set it in .env") app = Application.builder().token(settings.BOT_TOKEN).build() - # Хендлеры + + # визард пользователя 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)) + app.add_handler(CommandHandler("list", list_start)) + app.add_handler(CommandHandler("verify", lambda u, c: None)) # заглушка если где-то есть старая команда + + # callback-и + from telegram.ext import CallbackQueryHandler, ConversationHandler, MessageHandler, filters + app.add_handler(CallbackQueryHandler(candlist_pager, pattern=r"^candlist:page:")) + app.add_handler(CallbackQueryHandler(callback_router, pattern=r"^cand:")) + list_conv = ConversationHandler( + entry_points=[CommandHandler("list", list_start)], + states={ + 0: [CallbackQueryHandler(list_gender, pattern=r"^list:gender:")], + 1: [CallbackQueryHandler(list_city, pattern=r"^list:city:"), MessageHandler(filters.TEXT & ~filters.COMMAND, list_city)], + 2: [CallbackQueryHandler(list_verified, pattern=r"^list:verified:")], + 3: [CallbackQueryHandler(list_active, pattern=r"^list:active:")], + }, + fallbacks=[], + allow_reentry=True, + ) + app.add_handler(list_conv) + app.add_handler(CallbackQueryHandler(list_results_pager, pattern=r"^listres:page:")) return app if __name__ == "__main__": - application = build_app() - # В v21 это блокирующий вызов; updater больше не используется. - application.run_polling(close_loop=False) + try: + log.info("Starting bot…") + log.info("DB URL masked is set: %s", "***" if settings.DATABASE_URL else "") + application = build_app() + application.run_polling(close_loop=False) + except Exception as e: + log.exception("Bot crashed on startup: %s", e) + raise