i18n, messages are in templates
Some checks reported errors
continuous-integration/drone/push Build encountered an error

This commit is contained in:
2025-08-13 05:21:25 +09:00
parent 9af84db429
commit 0729e9196b
4 changed files with 466 additions and 88 deletions

4
.env
View File

@@ -10,4 +10,6 @@ DATABASE_URL=mysql+pymysql://match_user:match_password_change_me@db:3306/match_a
BOT_TOKEN=7839684534:AAGAUmO756P1T81q8ImndWgIBU9OsZkY06s BOT_TOKEN=7839684534:AAGAUmO756P1T81q8ImndWgIBU9OsZkY06s
PRIMARY_ADMIN_TELEGRAM_ID=556399210 PRIMARY_ADMIN_TELEGRAM_ID=556399210
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1
BOT_LOCALE=ru

View File

@@ -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": "Привет! Это анкета для брачного сервиса с сопровождением до визы F6 🇰🇷\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 F6 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": "안녕하세요! 혼인 서비스(F6 비자 지원) 신청서입니다. 이름이 무엇인가요?",
"wiz.exists": "프로필이 이미 있습니다. 보기: /my, 편집: /edit(곧), 다시 시작: /new",
},
}

View File

@@ -1,99 +1,197 @@
from __future__ import annotations from __future__ import annotations
from telegram import Update from dataclasses import dataclass
from telegram.ext import ContextTypes 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 sqlalchemy import desc
from app.core.db import db_session from app.core.db import db_session
from app.core.i18n import t
from app.repositories.admin_repo import AdminRepository from app.repositories.admin_repo import AdminRepository
from app.repositories.candidate_repo import CandidateRepository from app.repositories.candidate_repo import CandidateRepository
from app.models.candidate import Candidate from app.models.candidate import Candidate
from app.utils.common import csv_to_list from app.utils.common import csv_to_list
async def add_admin_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): PAGE_SIZE = 5 # можно менять
"""Команда /addadmin <telegram_id> — добавить нового администратора (только для админов)."""
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: def _caption(c: Candidate) -> str:
tid = int(args[0]) return t(
except ValueError: "cand.caption",
await update.message.reply_text("Неверный telegram_id.") id=c.id,
return 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 def cand_kb(c: Candidate) -> InlineKeyboardMarkup:
if s.query(Admin).filter(Admin.telegram_id == tid).first(): buttons = [
await update.message.reply_text("Этот администратор уже добавлен.") [
return 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)) def pager_kb(prefix: str, page: int, has_prev: bool, has_next: bool) -> InlineKeyboardMarkup | None:
s.commit() # prefix: "candlist" или "listres"
await update.message.reply_text(f"Администратор {tid} добавлен.") 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): async def list_candidates_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Команда /candidates — показать последние анкеты (только для админов)."""
with db_session() as s: with db_session() as s:
admin_repo = AdminRepository(s) if not AdminRepository(s).is_admin(update.effective_user.id):
if not admin_repo.is_admin(update.effective_user.id): await update.message.reply_text(t("err.not_allowed"))
await update.message.reply_text("Недостаточно прав.")
return 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: if not rows:
await update.message.reply_text("Кандидатов пока нет.") await update.message.reply_text(t("list.empty"))
return return
out = []
for c in rows: for c in rows:
out.append( if c.avatar_file_id:
f"#{c.id} {c.full_name or ''} | {c.city or ''} | цель: {c.goal or ''} | @{c.username or ''}" await update.message.reply_photo(photo=c.avatar_file_id, caption=_caption(c), reply_markup=cand_kb(c))
) else:
await update.message.reply_text("Последние анкеты:\n" + "\n".join(out)) 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:<n>
page = max(0, int(page_str))
context.user_data["candlist_page"] = page
async def verify_candidate_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Команда /verify <id> — пометить анкету как проверенную (только для админов)."""
with db_session() as s: with db_session() as s:
admin_repo = AdminRepository(s) if not AdminRepository(s).is_admin(update.effective_user.id):
if not admin_repo.is_admin(update.effective_user.id): await q.edit_message_text(t("err.not_allowed"))
await update.message.reply_text("Недостаточно прав.")
return return
args = context.args or [] total = s.query(Candidate).count()
if not args: rows = (s.query(Candidate)
await update.message.reply_text("Формат: /verify 12") .order_by(desc(Candidate.created_at))
return .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: try:
cid = int(args[0]) await q.edit_message_reply_markup(kb)
except ValueError: except Exception:
await update.message.reply_text("Укажи числовой id анкеты.") # если не панель, а текст — просто обновим текст
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 return
c = s.get(Candidate, cid) c = s.get(Candidate, cid)
if not c: 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 return
c.is_verified = True if action == "view":
s.commit() cap = _caption(c)
await update.message.reply_text(f"Анкета #{cid} помечена как проверенная.") 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): if action == "verify":
"""Команда /view <id> — показать карточку анкеты с фото (для админов).""" 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 <id> ===================================
async def view_candidate_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
with db_session() as s: with db_session() as s:
admin_repo = AdminRepository(s) if not AdminRepository(s).is_admin(update.effective_user.id):
if not admin_repo.is_admin(update.effective_user.id): await update.message.reply_text(t("err.not_allowed"))
await update.message.reply_text("Недостаточно прав.")
return return
args = context.args or [] args = context.args or []
@@ -109,33 +207,165 @@ async def view_candidate_handler(update, context):
c = s.get(Candidate, cid) c = s.get(Candidate, cid)
if not c: if not c:
await update.message.reply_text("Анкета не найдена.") await update.message.reply_text(t("err.not_found"))
return return
# Текстовая карточка caption = _caption(c)
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 chat_id = update.effective_chat.id
# Аватар (если есть)
if c.avatar_file_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: 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) gallery_ids = csv_to_list(c.gallery_file_ids)
if gallery_ids: if gallery_ids:
# Telegram принимает до 10 media за раз batch: List[InputMediaPhoto] = []
chunk = []
for fid in gallery_ids: for fid in gallery_ids:
chunk.append(InputMediaPhoto(media=fid)) batch.append(InputMediaPhoto(media=fid))
if len(chunk) == 10: if len(batch) == 10:
await context.bot.send_media_group(chat_id=chat_id, media=chunk) await context.bot.send_media_group(chat_id=chat_id, media=batch)
chunk = [] batch = []
if chunk: if batch:
await context.bot.send_media_group(chat_id=chat_id, media=chunk) 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:<val>
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:<n>
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)

View File

@@ -1,28 +1,64 @@
from __future__ import annotations from __future__ import annotations
import logging
from telegram.ext import Application, CommandHandler from telegram.ext import Application, CommandHandler
from app.core.config import settings from app.core.config import settings
from app.handlers.conversation import build_conversation 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.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): async def start_cmd(update, context):
await update.message.reply_text("Бот запущен. Набери /new, чтобы заполнить анкету.") await update.message.reply_text("Бот запущен. Набери /new, чтобы заполнить анкету.")
def build_app() -> Application: 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 = Application.builder().token(settings.BOT_TOKEN).build()
# Хендлеры
# визард пользователя
app.add_handler(build_conversation()) app.add_handler(build_conversation())
# базовые
app.add_handler(CommandHandler("start", start_cmd)) app.add_handler(CommandHandler("start", start_cmd))
app.add_handler(CommandHandler("my", my_profile_handler)) app.add_handler(CommandHandler("my", my_profile_handler))
app.add_handler(CommandHandler("edit", edit_hint_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("candidates", list_candidates_handler))
app.add_handler(CommandHandler("verify", verify_candidate_handler))
app.add_handler(CommandHandler("view", view_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 return app
if __name__ == "__main__": if __name__ == "__main__":
application = build_app() try:
# В v21 это блокирующий вызов; updater больше не используется. log.info("Starting bot…")
application.run_polling(close_loop=False) log.info("DB URL masked is set: %s", "***" if settings.DATABASE_URL else "<empty>")
application = build_app()
application.run_polling(close_loop=False)
except Exception as e:
log.exception("Bot crashed on startup: %s", e)
raise