From d7dbfbfb557e2678d699e673a2692c624cba58fc Mon Sep 17 00:00:00 2001 From: Nikita Yefremov Date: Sun, 11 Jan 2026 15:43:35 +0500 Subject: [PATCH 1/8] ci|locales: introduce samle RU event texts and add tests to CI --- .github/workflows/ci.yml | 10 +++++++++ app/locales/event_ru.json | 44 +++++++++++++++++++++++++++++++++++++++ config.yaml | 2 ++ 3 files changed, 56 insertions(+) create mode 100644 app/locales/event_ru.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcb09e7..c5a6932 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,3 +38,13 @@ jobs: - name: Coverage run: uv run pytest --cov=app --cov-report=term --cov-report=xml --cov-fail-under=70 + + docker-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t event-organizer-bot . diff --git a/app/locales/event_ru.json b/app/locales/event_ru.json new file mode 100644 index 0000000..59f9267 --- /dev/null +++ b/app/locales/event_ru.json @@ -0,0 +1,44 @@ +{ + "buttons": { + "attend": "Принять участие", + "lecturer": "Подать заявку на доклад", + "showcase": "Показать проект" + }, + "start": { + "new": "Привет, {name}!\n\nДобро пожаловать в бот Event Organizer Summit 2025. Выберите формат участия.", + "returning": "С возвращением, {name}!\n\nВы уже зарегистрированы на Event Organizer Summit 2025:\n{summary}\n\nИспользуйте /status для обновлений или выберите другой вариант ниже.", + "summary_item": "• {category}: {status}" + }, + "help": { + "text": "Используйте /start, чтобы зарегистрироваться на Event Organizer Summit 2025, или /status, чтобы проверить статус заявки." + }, + "registration": { + "callback": { + "attendee": "Спасибо! Заявка на участие в Event Organizer Summit 2025 получена. Скоро подтвердим.", + "lecturer": "Спасибо! Ваша заявка на доклад для Event Organizer Summit 2025 принята.", + "showcase": "Отлично! Заявка на демонстрацию проекта для Event Organizer Summit 2025 записана." + } + }, + "admin_notifications": { + "approved": "Ваша заявка на Event Organizer Summit 2025 подтверждена. Ждем вас 18 октября в Берлине!", + "approved_priority": "Ваша заявка подтверждена с приоритетным доступом на Event Organizer Summit 2025. Ждем вас 18 октября в Берлине!", + "waitlisted": "Вы в листе ожидания Event Organizer Summit 2025. Сообщим, если появится место." + }, + "bot": { + "messages": { + "event_started_broadcast": "Event Organizer Summit 2025 начался! Следите за обновлениями в боте." + }, + "application": { + "confirmation": "Спасибо за регистрацию! Скоро пришлем детали по Event Organizer Summit 2025." + } + }, + "admin": { + "dashboard": { + "title": "Обзор Event Organizer Summit 2025", + "subtitle": "Сводка по регистрациям, лимиту и рассылкам для саммита." + }, + "posts": { + "title": "Запланированные посты для Event Organizer Summit 2025" + } + } +} diff --git a/config.yaml b/config.yaml index 0196072..ebf7c27 100644 --- a/config.yaml +++ b/config.yaml @@ -1,3 +1,5 @@ admin_usernames: [] database_url: sqlite:///./anonchatbot.db telegram_token: abcdefghijklmnopqrstuvwxyz +locale: event_ru +event_name: Event Organizer Summit 2025 From 3ca299c9c35f5d269be7ba65de61db553c0651aa Mon Sep 17 00:00:00 2001 From: Nikita Yefremov Date: Mon, 12 Jan 2026 13:55:43 +0500 Subject: [PATCH 2/8] app: localization: introduce logging --- app/localization.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/localization.py b/app/localization.py index 0074d16..2bec209 100644 --- a/app/localization.py +++ b/app/localization.py @@ -1,14 +1,13 @@ -"""Simple localization helper backed by JSON message catalogs.""" - -from __future__ import annotations - import json +import logging + from dataclasses import dataclass from functools import lru_cache from importlib import resources from typing import Any DEFAULT_LOCALE = "en" +logger = logging.getLogger(__name__) @dataclass(frozen=True) @@ -59,11 +58,13 @@ def _load_messages(locale: str) -> dict[str, Any]: @lru_cache(maxsize=None) def get_localizer(locale: str) -> Localizer: + logger.info('get_localizer: locale chosen is %s', locale) try: messages = _load_messages(locale) except FileNotFoundError: if locale == DEFAULT_LOCALE: raise + logger.error('Locale %s not found, using default one', locale) return get_localizer(DEFAULT_LOCALE) fallback = get_localizer(DEFAULT_LOCALE) if locale != DEFAULT_LOCALE else None From d03a004ae0c37cfb9f76179307dc6dfe08df635d Mon Sep 17 00:00:00 2001 From: Nikita Yefremov Date: Mon, 12 Jan 2026 13:55:49 +0500 Subject: [PATCH 3/8] app: telebot: handlers: fix some handler's buggy behaviour Double-sending Main menu messages, etc. --- app/telebot/handlers.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/app/telebot/handlers.py b/app/telebot/handlers.py index 23a07a0..da7293b 100644 --- a/app/telebot/handlers.py +++ b/app/telebot/handlers.py @@ -169,7 +169,9 @@ def get_admin_state(session, admin_id: int) -> AdminState | None: return state -async def send_welcome_message(update: Update, template: MessageTemplate | None) -> None: +async def send_welcome_message( + update: Update, context: ContextTypes.DEFAULT_TYPE, template: MessageTemplate | None +) -> None: if not update.effective_chat: return if not template: @@ -177,7 +179,7 @@ async def send_welcome_message(update: Update, template: MessageTemplate | None) await update.effective_chat.send_message(localizer.get("bot.templates.missing_welcome")) return try: - await update.effective_chat.bot.copy_message( + await context.bot.copy_message( chat_id=update.effective_chat.id, from_chat_id=template.admin_chat_id, message_id=template.message_id, @@ -188,7 +190,9 @@ async def send_welcome_message(update: Update, template: MessageTemplate | None) await update.effective_chat.send_message(localizer.get("bot.templates.missing_welcome")) -async def send_schedule_message(update: Update, template: MessageTemplate | None) -> None: +async def send_schedule_message( + update: Update, context: ContextTypes.DEFAULT_TYPE, template: MessageTemplate | None +) -> None: if not update.effective_chat: return if not template: @@ -196,7 +200,7 @@ async def send_schedule_message(update: Update, template: MessageTemplate | None await update.effective_chat.send_message(localizer.get("bot.templates.missing_schedule")) return try: - await update.effective_chat.bot.copy_message( + await context.bot.copy_message( chat_id=update.effective_chat.id, from_chat_id=template.admin_chat_id, message_id=template.message_id, @@ -219,12 +223,7 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: template = get_template(session, "welcome_message") localizer = get_bot_localizer() - await send_welcome_message(update, template) - await context.bot.send_message( - chat_id=chat.id, - text=localizer.get("bot.messages.main_menu"), - reply_markup=build_main_keyboard(db_user.status, event_state.event_started), - ) + await send_welcome_message(update, context, template) async def show_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -396,15 +395,8 @@ async def schedule(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: return with session_scope() as session: db_user, _ = upsert_user(session, user) - event_state = get_or_create_event_state(session) template = get_template(session, "schedule_message") - localizer = get_bot_localizer() - await send_schedule_message(update, template) - await context.bot.send_message( - chat_id=chat.id, - text=localizer.get("bot.messages.main_menu"), - reply_markup=build_main_keyboard(db_user.status, event_state.event_started), - ) + await send_schedule_message(update, context, template) async def feedback_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: @@ -488,11 +480,19 @@ async def handle_admin_payload(update: Update, context: ContextTypes.DEFAULT_TYP return message = update.message if state.waiting_for == AdminStateType.WELCOME: + if message.text and message.text.startswith("/"): + await update.message.reply_text(localizer.get("bot.admin.templates.awaiting_welcome")) + return set_template(session, "welcome_message", message.chat_id, message.message_id) clear_admin_state(session, user.id) await update.message.reply_text(localizer.get("bot.admin.templates.saved_welcome")) return if state.waiting_for == AdminStateType.SCHEDULE: + if message.text and message.text.startswith("/"): + await update.message.reply_text( + localizer.get("bot.admin.templates.awaiting_schedule") + ) + return set_template(session, "schedule_message", message.chat_id, message.message_id) clear_admin_state(session, user.id) await update.message.reply_text(localizer.get("bot.admin.templates.saved_schedule")) From fe49b6ac905df9303c3e048e3970030a8a0a05ac Mon Sep 17 00:00:00 2001 From: Nikita Yefremov Date: Mon, 12 Jan 2026 13:56:18 +0500 Subject: [PATCH 4/8] Introduce .dockerignore file --- .dockerignore | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..01165d7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +# Ignore common developer files and directories +.git +.gitignore +.vscode/ +.venv/ + +# Ignore dependency and build directories +__pycache__/ +dist/ + +# Ignore sensitive files and logs +.env +*.log From 48ac227682aa57e8983981874886a3fa16e86891 Mon Sep 17 00:00:00 2001 From: Nikita Yefremov Date: Wed, 14 Jan 2026 20:50:29 +0500 Subject: [PATCH 5/8] app: fix and move db upload --- app/config.py | 3 + app/telebot/db.py | 276 ++++++++++++++++++++++++++++++++++++ app/telebot/handlers.py | 157 +++----------------- config.yaml | 1 + tests/test_handlers_flow.py | 149 ++++++++++++++++++- 5 files changed, 448 insertions(+), 138 deletions(-) create mode 100644 app/telebot/db.py diff --git a/app/config.py b/app/config.py index f724854..8fcc734 100644 --- a/app/config.py +++ b/app/config.py @@ -24,6 +24,9 @@ class Settings(BaseSettings): basic_auth_username: str = Field(default="admin", env="ADMIN_BASIC_AUTH_USERNAME") basic_auth_password: str = Field(default="admin", env="ADMIN_BASIC_AUTH_PASSWORD") scheduler_interval_seconds: int = Field(default=60, env="SCHEDULER_INTERVAL_SECONDS") + allow_admin_upload_overwrite: bool = Field( + default=False, env="ALLOW_ADMIN_UPLOAD_OVERWRITE" + ) database_url: str = Field( default="sqlite:///./anonchatbot.db", diff --git a/app/telebot/db.py b/app/telebot/db.py new file mode 100644 index 0000000..8968529 --- /dev/null +++ b/app/telebot/db.py @@ -0,0 +1,276 @@ +import csv +import io +import logging +from datetime import datetime, timedelta + +from sqlalchemy import select +from telegram import Update +from telegram.ext import ContextTypes + +from app.config import get_settings +from app.database import session_scope +from app.models import ( + AdminState, + AdminStateType, + EventState, + Feedback, + MessageTemplate, + User, + UserStatus, +) + +logger = logging.getLogger(__name__) + + +def is_admin(username: str | None) -> bool: + if not username: + return False + settings = get_settings() + return username.lstrip("@").lower() in settings.admin_username_set + + +def get_or_create_event_state(session) -> EventState: + state = session.scalar(select(EventState)) + if state: + return state + state = EventState(event_started=False, current_event_id="default") + session.add(state) + session.flush() + return state + + +def upsert_user(session, tg_user) -> tuple[User, bool]: + user = session.scalar(select(User).where(User.telegram_id == tg_user.id)) + is_new = False + if not user: + user = User( + telegram_id=tg_user.id, + username=tg_user.username, + status=UserStatus.NONE, + notifications_enabled=True, + created_at=datetime.utcnow(), + ) + session.add(user) + session.flush() + is_new = True + user.username = tg_user.username + user.updated_at = datetime.utcnow() + return user, is_new + + +def get_template(session, name: str) -> MessageTemplate | None: + return session.scalar(select(MessageTemplate).where(MessageTemplate.name == name)) + + +def set_template(session, name: str, chat_id: int, message_id: int) -> None: + template = get_template(session, name) + if template: + template.admin_chat_id = chat_id + template.message_id = message_id + return + template = MessageTemplate(name=name, admin_chat_id=chat_id, message_id=message_id) + session.add(template) + + +def set_admin_state( + session, admin_id: int, waiting_for: AdminStateType, ttl_seconds: int = 300 +) -> None: + session.query(AdminState).where(AdminState.admin_id == admin_id).delete() + state = AdminState( + admin_id=admin_id, + waiting_for=waiting_for, + ttl_seconds=ttl_seconds, + created_at=datetime.utcnow(), + ) + session.add(state) + + +def clear_admin_state(session, admin_id: int) -> None: + session.query(AdminState).where(AdminState.admin_id == admin_id).delete() + + +def get_admin_state(session, admin_id: int) -> AdminState | None: + state = session.scalar(select(AdminState).where(AdminState.admin_id == admin_id)) + if not state: + return None + if datetime.utcnow() > state.created_at + timedelta(seconds=state.ttl_seconds): + session.delete(state) + return None + return state + + +def _parse_user_id(value: str) -> int | None: + stripped = value.strip() + if not stripped: + return None + if stripped.isdigit(): + return int(stripped) + try: + float_value = float(stripped) + except ValueError: + return None + if float_value.is_integer(): + return int(float_value) + return None + + +def _normalize_key(key: str) -> str: + return key.lstrip("\ufeff").strip() + + +def _parse_status(value: str | None) -> UserStatus | None: + if not value: + return None + normalized_status = value.strip().upper() + if normalized_status in UserStatus.__members__: + return UserStatus[normalized_status] + for candidate in UserStatus: + if candidate.value.upper() == normalized_status: + return candidate + return None + + +def _parse_bool(value: str | None) -> bool | None: + if value is None: + return None + normalized = value.strip().lower() + if normalized in {"true", "1", "yes"}: + return True + if normalized in {"false", "0", "no"}: + return False + return None + + +def _build_user_from_row(row: dict[str, str]) -> User | None: + user_id_value = row.get("user_id") + if not user_id_value: + logger.info("Skipping row without user_id: %s", row) + return None + telegram_id = _parse_user_id(str(user_id_value)) + if telegram_id is None: + logger.warning("Skipping row with invalid user_id=%s", user_id_value) + return None + username = row.get("username") or None + if username and is_admin(username) and not get_settings().allow_admin_upload_overwrite: + logger.info("Skipping admin row for username=%s user_id=%s", username, telegram_id) + return None + status = _parse_status(row.get("status")) + if row.get("status") and status is None: + logger.warning( + "Skipping row with invalid status=%s for user_id=%s", + row.get("status"), + telegram_id, + ) + return None + notifications_enabled = _parse_bool(row.get("notifications_enabled")) + return User( + telegram_id=telegram_id, + username=username, + full_name=row.get("full_name") or None, + job=row.get("job") or None, + career_path=row.get("career_path") or None, + status=status or UserStatus.NONE, + notifications_enabled=notifications_enabled + if notifications_enabled is not None + else True, + ) + + +def _normalize_row(row: dict[str, str | None]) -> dict[str, str]: + normalized: dict[str, str] = {} + for key, value in row.items(): + if key is None: + continue + normalized_key = _normalize_key(key) + if isinstance(value, str): + normalized_value = value.strip() + else: + normalized_value = value + normalized[normalized_key] = normalized_value + return normalized + + +def _validate_schema(fieldnames: list[str] | None) -> bool: + required_fields = { + "user_id", + "username", + "full_name", + "job", + "career_path", + "status", + "notifications_enabled", + } + if not fieldnames: + logger.error("Uploaded CSV has no headers") + return False + normalized_fields = {_normalize_key(field) for field in fieldnames} + missing_fields = required_fields - normalized_fields + if missing_fields: + logger.error("Uploaded CSV missing required columns: %s", sorted(missing_fields)) + return False + return True + + +async def process_upload_database( + update: Update, context: ContextTypes.DEFAULT_TYPE, admin_id: int +) -> None: + if not update.message or not update.message.document: + return + document_name = getattr(update.message.document, "file_name", None) + logger.info( + "Processing uploaded database from admin_id=%s document_name=%s", + admin_id, + document_name, + ) + file = await context.bot.get_file(update.message.document.file_id) + content = await file.download_as_bytearray() + text = content.decode("utf-8") + sample = text[:4096] + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;") + except csv.Error: + dialect = csv.excel + reader = csv.DictReader(io.StringIO(text), dialect=dialect) + logger.info( + "Detected CSV dialect for upload: delimiter=%r, headers=%s", + getattr(dialect, "delimiter", None), + reader.fieldnames, + ) + if not _validate_schema(reader.fieldnames): + logger.warning("Aborting upload due to invalid schema") + return + + with session_scope() as session: + clear_admin_state(session, admin_id) + total_rows = 0 + skipped_rows = 0 + inserted_rows = 0 + users_to_insert: list[User] = [] + for row in reader: + total_rows += 1 + normalized_row = _normalize_row(row) + logger.debug("Processing CSV row=%s", normalized_row) + csv_user = _build_user_from_row(normalized_row) + if not csv_user: + skipped_rows += 1 + continue + users_to_insert.append(csv_user) + if not users_to_insert: + logger.warning( + "No users parsed from upload; skipping delete to avoid empty reload" + ) + return + logger.info("Deleting existing feedback and users before upload") + session.query(Feedback).delete() + session.query(User).delete() + for user in users_to_insert: + user.created_at = datetime.utcnow() + user.updated_at = datetime.utcnow() + session.add(user) + inserted_rows += 1 + logger.info( + "Upload database summary: total_rows=%s inserted_rows=%s skipped_rows=%s", + total_rows, + inserted_rows, + skipped_rows, + ) diff --git a/app/telebot/handlers.py b/app/telebot/handlers.py index da7293b..f70b80c 100644 --- a/app/telebot/handlers.py +++ b/app/telebot/handlers.py @@ -2,9 +2,10 @@ import csv import io import logging -from datetime import datetime, timedelta +from datetime import datetime from sqlalchemy import select +from sqlalchemy.orm import selectinload from telegram import ReplyKeyboardMarkup, Update from telegram.ext import ( Application, @@ -18,14 +19,17 @@ from app.config import get_settings from app.database import session_scope from app.localization import DEFAULT_LOCALE, get_localizer -from app.models import ( - AdminState, - AdminStateType, - EventState, - Feedback, - MessageTemplate, - User, - UserStatus, +from app.models import AdminStateType, Feedback, MessageTemplate, User, UserStatus +from app.telebot.db import ( + clear_admin_state, + get_admin_state, + get_or_create_event_state, + get_template, + is_admin, + process_upload_database, + set_admin_state, + set_template, + upsert_user, ) logger = logging.getLogger(__name__) @@ -54,13 +58,6 @@ def get_bot_localizer(): FEEDBACK_TEXT = 10 -def is_admin(username: str | None) -> bool: - if not username: - return False - settings = get_settings() - return username.lstrip("@").lower() in settings.admin_username_set - - def build_main_keyboard(status: UserStatus, event_started: bool) -> ReplyKeyboardMarkup: if event_started and status == UserStatus.ATTENDEE: first_button = MENU_FEEDBACK @@ -99,76 +96,6 @@ def status_text(status: UserStatus) -> str: return localizer.get(mapping[status]) -def get_or_create_event_state(session) -> EventState: - state = session.scalar(select(EventState).limit(1)) - if state: - return state - state = EventState(event_started=False, current_event_id="default") - session.add(state) - session.flush() - return state - - -def upsert_user(session, tg_user) -> tuple[User, bool]: - user = session.scalar(select(User).where(User.telegram_id == tg_user.id)) - is_new = False - if not user: - user = User( - telegram_id=tg_user.id, - username=tg_user.username, - status=UserStatus.NONE, - notifications_enabled=True, - created_at=datetime.utcnow(), - ) - session.add(user) - session.flush() - is_new = True - user.username = tg_user.username - user.updated_at = datetime.utcnow() - return user, is_new - - -def get_template(session, name: str) -> MessageTemplate | None: - return session.scalar(select(MessageTemplate).where(MessageTemplate.name == name)) - - -def set_template(session, name: str, chat_id: int, message_id: int) -> None: - template = get_template(session, name) - if template: - template.admin_chat_id = chat_id - template.message_id = message_id - return - template = MessageTemplate(name=name, admin_chat_id=chat_id, message_id=message_id) - session.add(template) - - -def set_admin_state( - session, admin_id: int, waiting_for: AdminStateType, ttl_seconds: int = 300 -) -> None: - session.query(AdminState).where(AdminState.admin_id == admin_id).delete() - state = AdminState( - admin_id=admin_id, - waiting_for=waiting_for, - ttl_seconds=ttl_seconds, - created_at=datetime.utcnow(), - ) - session.add(state) - - -def clear_admin_state(session, admin_id: int) -> None: - session.query(AdminState).where(AdminState.admin_id == admin_id).delete() - - -def get_admin_state(session, admin_id: int) -> AdminState | None: - state = session.scalar(select(AdminState).where(AdminState.admin_id == admin_id)) - if not state: - return None - if datetime.utcnow() > state.created_at + timedelta(seconds=state.ttl_seconds): - session.delete(state) - return None - return state - - async def send_welcome_message( update: Update, context: ContextTypes.DEFAULT_TYPE, template: MessageTemplate | None ) -> None: @@ -474,9 +401,12 @@ async def handle_admin_payload(update: Update, context: ContextTypes.DEFAULT_TYP localizer = get_bot_localizer() if state.waiting_for == AdminStateType.UPLOAD_DB: if not update.message.document: + if update.message.text and update.message.text.startswith("/"): + return await update.message.reply_text(localizer.get("bot.admin.errors.expected_csv")) return await process_upload_database(update, context, state.admin_id) + await update.message.reply_text(localizer.get("bot.admin.database.updated")) return message = update.message if state.waiting_for == AdminStateType.WELCOME: @@ -572,55 +502,6 @@ async def broadcast_text(bot, text: str) -> None: logger.exception("Failed to send broadcast to %s", user.telegram_id) -async def process_upload_database( - update: Update, context: ContextTypes.DEFAULT_TYPE, admin_id: int -) -> None: - if not update.message or not update.message.document: - return - file = await context.bot.get_file(update.message.document.file_id) - content = await file.download_as_bytearray() - stream = io.StringIO(content.decode("utf-8")) - reader = csv.DictReader(stream) - with session_scope() as session: - clear_admin_state(session, admin_id) - for row in reader: - if not row.get("user_id"): - continue - username = row.get("username") or "" - if is_admin(username): - continue - telegram_id = int(row["user_id"]) - user = session.scalar(select(User).where(User.telegram_id == telegram_id)) - if not user: - user = User( - telegram_id=telegram_id, - notifications_enabled=True, - status=UserStatus.NONE, - created_at=datetime.utcnow(), - ) - session.add(user) - session.flush() - user.username = row.get("username") or user.username - user.full_name = row.get("full_name") or None - user.job = row.get("job") or None - user.career_path = row.get("career_path") or None - status_value = row.get("status") - if status_value: - normalized_status = status_value.strip().upper() - if normalized_status in UserStatus.__members__: - user.status = UserStatus[normalized_status] - else: - for candidate in UserStatus: - if candidate.value.upper() == normalized_status: - user.status = candidate - break - if row.get("notifications_enabled") is not None: - user.notifications_enabled = row["notifications_enabled"].lower() == "true" - user.updated_at = datetime.utcnow() - localizer = get_bot_localizer() - await update.message.reply_text(localizer.get("bot.admin.database.updated")) - - def ensure_admin(update: Update) -> bool: user = update.effective_user if not user or not is_admin(user.username): @@ -731,7 +612,11 @@ async def download_database(update: Update, context: ContextTypes.DEFAULT_TYPE) return with session_scope() as session: users = session.execute(select(User)).scalars().all() - feedback = session.execute(select(Feedback)).scalars().all() + feedback = ( + session.execute(select(Feedback).options(selectinload(Feedback.user))) + .scalars() + .all() + ) user_stream = io.StringIO() user_writer = csv.writer(user_stream) user_writer.writerow( diff --git a/config.yaml b/config.yaml index ebf7c27..906899d 100644 --- a/config.yaml +++ b/config.yaml @@ -3,3 +3,4 @@ database_url: sqlite:///./anonchatbot.db telegram_token: abcdefghijklmnopqrstuvwxyz locale: event_ru event_name: Event Organizer Summit 2025 +allow_admin_upload_overwrite: false diff --git a/tests/test_handlers_flow.py b/tests/test_handlers_flow.py index f4198e7..b28ddba 100644 --- a/tests/test_handlers_flow.py +++ b/tests/test_handlers_flow.py @@ -1,5 +1,7 @@ import asyncio +import csv import importlib +import io from dataclasses import dataclass from typing import Any @@ -122,8 +124,7 @@ def test_start_creates_user_and_event_state(tmp_path, monkeypatch): asyncio.run(handlers.start(update, context)) - assert chat.sent_messages - assert bot.sent_messages + assert chat.sent_messages or bot.sent_messages with database.session_scope() as session: db_user = session.scalar(select(models.User).where(models.User.telegram_id == 100)) @@ -257,6 +258,24 @@ def test_handle_admin_payload_saves_welcome_template(tmp_path, monkeypatch): assert message.replies +def test_handle_admin_payload_upload_db_command_does_not_reply(tmp_path, monkeypatch): + handlers, database, models = _reload_handlers(tmp_path, monkeypatch, admin_usernames="admin") + + bot = DummyBot() + chat = DummyChat(chat_id=17, bot=bot) + user = DummyUser(id=201, username="admin") + message = DummyMessage(text="/upload_database", chat_id=chat.id) + update = DummyUpdate(user=user, chat=chat, message=message) + context = DummyContext(bot) + + with database.session_scope() as session: + handlers.set_admin_state(session, user.id, models.AdminStateType.UPLOAD_DB) + + asyncio.run(handlers.handle_admin_payload(update, context)) + + assert message.replies == [] + + def test_process_upload_database_creates_users(tmp_path, monkeypatch): handlers, database, models = _reload_handlers(tmp_path, monkeypatch, admin_usernames="admin") @@ -286,6 +305,132 @@ def test_process_upload_database_creates_users(tmp_path, monkeypatch): assert db_user.status == models.UserStatus.ATTENDEE +def test_download_then_upload_database_updates_rows(tmp_path, monkeypatch): + handlers, database, models = _reload_handlers(tmp_path, monkeypatch, admin_usernames="admin") + + with database.session_scope() as session: + user_one = models.User( + telegram_id=101, + username="guest1", + full_name="Ada", + job="Engineer", + career_path="Backend", + status=models.UserStatus.PROCESSING, + notifications_enabled=True, + ) + user_two = models.User( + telegram_id=202, + username="guest2", + full_name="Bob", + job="Designer", + career_path="UX", + status=models.UserStatus.ATTENDEE, + notifications_enabled=True, + ) + session.add_all([user_one, user_two]) + session.add( + models.Feedback( + event_id="event-1", + user=user_one, + feedback_text="Great event!", + ) + ) + + bot = DummyBot() + chat = DummyChat(chat_id=25, bot=bot) + admin = DummyUser(id=999, username="admin") + update = DummyUpdate(user=admin, chat=chat) + context = DummyContext(bot) + + asyncio.run(handlers.download_database(update, context)) + + assert len(chat.sent_documents) == 2 + users_payload, users_filename = chat.sent_documents[0] + assert users_filename == "users.csv" + + users_csv = users_payload.decode("utf-8") + reader = csv.DictReader(io.StringIO(users_csv)) + rows = list(reader) + + for row in rows: + if row["user_id"] == "101": + row["full_name"] = "Ada Lovelace" + row["job"] = "Engineer II" + row["career_path"] = "Platform" + row["status"] = " waitlist " + row["notifications_enabled"] = " false " + if row["user_id"] == "202": + row["status"] = "PROCESSING" + + output = io.StringIO() + writer = csv.DictWriter(output, fieldnames=reader.fieldnames) + writer.writeheader() + writer.writerows(rows) + + bot.files["file-upload"] = DummyFile(output.getvalue().encode("utf-8")) + message = DummyMessage(text=None, chat_id=chat.id) + message.document = DummyDocument("file-upload") + upload_update = DummyUpdate(user=admin, chat=chat, message=message) + + asyncio.run(handlers.process_upload_database(upload_update, context, admin_id=admin.id)) + + with database.session_scope() as session: + refreshed_one = session.scalar( + select(models.User).where(models.User.telegram_id == 101) + ) + refreshed_two = session.scalar( + select(models.User).where(models.User.telegram_id == 202) + ) + + assert refreshed_one.full_name == "Ada Lovelace" + assert refreshed_one.job == "Engineer II" + assert refreshed_one.career_path == "Platform" + assert refreshed_one.status == models.UserStatus.WAITLIST + assert refreshed_one.notifications_enabled is False + assert refreshed_two.status == models.UserStatus.PROCESSING + + +def test_process_upload_database_accepts_semicolon_delimiter(tmp_path, monkeypatch): + handlers, database, models = _reload_handlers(tmp_path, monkeypatch, admin_usernames="admin") + + with database.session_scope() as session: + session.add( + models.User( + telegram_id=303, + username="guest3", + full_name="Carol", + status=models.UserStatus.NONE, + notifications_enabled=True, + ) + ) + + bot = DummyBot() + csv_payload = ( + "user_id;username;full_name;job;career_path;status;notifications_enabled\n" + "303;guest3;Carol Danvers;Pilot;Aviation;ATTENDEE;false\n" + ).encode("utf-8") + bot.files["file-2"] = DummyFile(csv_payload) + + chat = DummyChat(chat_id=18, bot=bot) + user = DummyUser(id=202, username="admin") + message = DummyMessage(text=None, chat_id=chat.id) + message.document = DummyDocument("file-2") + update = DummyUpdate(user=user, chat=chat, message=message) + context = DummyContext(bot) + + asyncio.run(handlers.process_upload_database(update, context, admin_id=user.id)) + + with database.session_scope() as session: + db_user = session.scalar(select(models.User).where(models.User.telegram_id == 303)) + + assert db_user is not None + assert db_user.full_name == "Carol Danvers" + assert db_user.job == "Pilot" + assert db_user.career_path == "Aviation" + assert db_user.status == models.UserStatus.ATTENDEE + assert db_user.notifications_enabled is False + + def test_update_status_by_id_handles_invalid_and_success(tmp_path, monkeypatch): handlers, database, models = _reload_handlers(tmp_path, monkeypatch, admin_usernames="admin") From 77f1992752ae1cfc7774b5d469a96fc931946ee5 Mon Sep 17 00:00:00 2001 From: Nikita Yefremov Date: Wed, 14 Jan 2026 23:00:36 +0500 Subject: [PATCH 6/8] app: introduce new question and friends route --- app/database.py | 2 +- app/locales/en.json | 6 +- app/locales/event_ru.json | 14 +++- app/locales/ru.json | 6 +- app/models.py | 1 + app/telebot/db.py | 18 +++++ app/telebot/handlers.py | 152 ++++++++++++++++++++++++++++++-------- 7 files changed, 166 insertions(+), 33 deletions(-) diff --git a/app/database.py b/app/database.py index 3d3cbdd..9c57187 100644 --- a/app/database.py +++ b/app/database.py @@ -24,7 +24,7 @@ Base = declarative_base() -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 def ensure_schema() -> None: diff --git a/app/locales/en.json b/app/locales/en.json index 958cfc0..171cee6 100644 --- a/app/locales/en.json +++ b/app/locales/en.json @@ -75,13 +75,16 @@ }, "messages": { "main_menu": "Главное меню", - "event_started_broadcast": "Мероприятие началось! Следите за обновлениями в боте." + "event_started_broadcast": "Мероприятие началось! Следите за обновлениями в боте.", + "friend_attending": "Your friend {friend} is already attending!", + "friend_attending_unknown": "Someone" }, "application": { "already_created": "Заявка уже создана.", "ask_full_name": "Как вас зовут? (Имя Фамилия)", "ask_job": "Кем и где вы работаете? (позиция, компания)", "ask_career": "Какой путь... (1)... (2)... В ответ напишите цифру", + "ask_friends": "Do you have/want any friends attending? Write down their usernames below.", "confirmation": "Спасибо за вашу заявку! Ожидайте новостей по мероприятию.", "cancelled": "Заявка отменена." }, @@ -96,6 +99,7 @@ "unknown": "Unknown command", "nickname_required": "Нужен nickname.", "user_not_found": "Пользователь не найден.", + "application_not_found": "У пользователя нет заявки.", "user_id_required": "Нужен user_id.", "user_id_invalid": "Неверный user_id.", "event_id_required": "Нужен id." diff --git a/app/locales/event_ru.json b/app/locales/event_ru.json index 59f9267..55d2b78 100644 --- a/app/locales/event_ru.json +++ b/app/locales/event_ru.json @@ -25,10 +25,22 @@ "waitlisted": "Вы в листе ожидания Event Organizer Summit 2025. Сообщим, если появится место." }, "bot": { + "menu": { + "application": "Заявка", + "cancel": "Отмена заявки", + "feedback": "Отзыв", + "schedule": "Афиша", + "status": "Статус", + "notifications": "Нотификации", + "home": "На главную" + }, "messages": { - "event_started_broadcast": "Event Organizer Summit 2025 начался! Следите за обновлениями в боте." + "event_started_broadcast": "Event Organizer Summit 2025 начался! Следите за обновлениями в боте.", + "friend_attending": "Ваш друг {friend} уже участвует!", + "friend_attending_unknown": "Кто-то" }, "application": { + "ask_friends": "Есть друзья, которые тоже придут? Напишите их username ниже.", "confirmation": "Спасибо за регистрацию! Скоро пришлем детали по Event Organizer Summit 2025." } }, diff --git a/app/locales/ru.json b/app/locales/ru.json index 81b9805..6579c78 100644 --- a/app/locales/ru.json +++ b/app/locales/ru.json @@ -75,13 +75,16 @@ }, "messages": { "main_menu": "Главное меню", - "event_started_broadcast": "Мероприятие началось! Следите за обновлениями в боте." + "event_started_broadcast": "Мероприятие началось! Следите за обновлениями в боте.", + "friend_attending": "Ваш друг {friend} уже участвует!", + "friend_attending_unknown": "Кто-то" }, "application": { "already_created": "Заявка уже создана.", "ask_full_name": "Как вас зовут? (Имя Фамилия)", "ask_job": "Кем и где вы работаете? (позиция, компания)", "ask_career": "Какой путь... (1)... (2)... В ответ напишите цифру", + "ask_friends": "Есть друзья, которые тоже придут? Напишите их username ниже.", "confirmation": "Спасибо за вашу заявку! Ожидайте новостей по мероприятию.", "cancelled": "Заявка отменена." }, @@ -96,6 +99,7 @@ "unknown": "Неизвестная команда", "nickname_required": "Нужен nickname.", "user_not_found": "Пользователь не найден.", + "application_not_found": "У пользователя нет заявки.", "user_id_required": "Нужен user_id.", "user_id_invalid": "Неверный user_id.", "event_id_required": "Нужен id." diff --git a/app/models.py b/app/models.py index ee8cc51..2f4618b 100644 --- a/app/models.py +++ b/app/models.py @@ -45,6 +45,7 @@ class User(Base): full_name = Column(String(255), nullable=True) job = Column(String(255), nullable=True) career_path = Column(String(255), nullable=True) + friend_usernames = Column(Text, nullable=True) status = Column(Enum(UserStatus), default=UserStatus.NONE, nullable=False) notifications_enabled = Column(Boolean, default=True, nullable=False) is_subscribed = Column(Boolean, default=True, nullable=False) diff --git a/app/telebot/db.py b/app/telebot/db.py index 8968529..ae6849b 100644 --- a/app/telebot/db.py +++ b/app/telebot/db.py @@ -1,6 +1,7 @@ import csv import io import logging +import re from datetime import datetime, timedelta from sqlalchemy import select @@ -141,6 +142,21 @@ def _parse_bool(value: str | None) -> bool | None: return None +def _normalize_friend_usernames(value: str | None) -> str | None: + if not value: + return None + tokens = re.split(r"[,\s]+", value) + seen: set[str] = set() + normalized: list[str] = [] + for token in tokens: + item = token.lstrip("@").strip().lower() + if not item or item in seen: + continue + seen.add(item) + normalized.append(item) + return ",".join(normalized) if normalized else None + + def _build_user_from_row(row: dict[str, str]) -> User | None: user_id_value = row.get("user_id") if not user_id_value: @@ -163,12 +179,14 @@ def _build_user_from_row(row: dict[str, str]) -> User | None: ) return None notifications_enabled = _parse_bool(row.get("notifications_enabled")) + friend_usernames = _normalize_friend_usernames(row.get("friend_usernames")) return User( telegram_id=telegram_id, username=username, full_name=row.get("full_name") or None, job=row.get("job") or None, career_path=row.get("career_path") or None, + friend_usernames=friend_usernames, status=status or UserStatus.NONE, notifications_enabled=notifications_enabled if notifications_enabled is not None diff --git a/app/telebot/handlers.py b/app/telebot/handlers.py index f70b80c..3a5944d 100644 --- a/app/telebot/handlers.py +++ b/app/telebot/handlers.py @@ -2,6 +2,7 @@ import csv import io import logging +import re from datetime import datetime from sqlalchemy import select @@ -54,15 +55,16 @@ def get_bot_localizer(): APPLICATION_FULL_NAME = 1 APPLICATION_JOB = 2 APPLICATION_CAREER = 3 +APPLICATION_FRIENDS = 4 FEEDBACK_TEXT = 10 def build_main_keyboard(status: UserStatus, event_started: bool) -> ReplyKeyboardMarkup: - if event_started and status == UserStatus.ATTENDEE: - first_button = MENU_FEEDBACK - elif status == UserStatus.NONE: + if status == UserStatus.NONE: first_button = MENU_APPLICATION + elif event_started: + first_button = MENU_FEEDBACK else: first_button = MENU_CANCEL keyboard = [ @@ -96,6 +98,40 @@ def status_text(status: UserStatus) -> str: return localizer.get(mapping[status]) +def normalize_friend_username(value: str) -> str | None: + normalized = value.lstrip("@").strip().lower() + return normalized or None + + +def parse_friend_usernames(text: str) -> list[str]: + if not text: + return [] + tokens = re.split(r"[,\s]+", text) + seen: set[str] = set() + result: list[str] = [] + for token in tokens: + normalized = normalize_friend_username(token) + if not normalized or normalized in seen: + continue + seen.add(normalized) + result.append(normalized) + return result + + +def serialize_friend_usernames(usernames: list[str]) -> str | None: + if not usernames: + return None + return ",".join(usernames) + + +def deserialize_friend_usernames(value: str | None) -> set[str]: + if not value: + return set() + tokens = re.split(r"[,\s]+", value) + usernames = {normalize_friend_username(token) for token in tokens} + return {item for item in usernames if item} + + async def send_welcome_message( update: Update, context: ContextTypes.DEFAULT_TYPE, template: MessageTemplate | None ) -> None: @@ -146,11 +182,36 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: with session_scope() as session: db_user, _ = upsert_user(session, user) - event_state = get_or_create_event_state(session) template = get_template(session, "welcome_message") + friend_matches: list[User] = [] + normalized_username = normalize_friend_username(user.username or "") + if normalized_username: + candidates = ( + session.execute(select(User).where(User.friend_usernames.is_not(None))) + .scalars() + .all() + ) + for candidate in candidates: + if candidate.telegram_id == user.id or candidate.status == UserStatus.NONE: + continue + if normalized_username in deserialize_friend_usernames( + candidate.friend_usernames + ): + friend_matches.append(candidate) localizer = get_bot_localizer() await send_welcome_message(update, context, template) + for friend in friend_matches: + if not update.effective_chat: + return + friend_label = ( + f"@{friend.username}" + if friend.username + else friend.full_name or localizer.get("bot.messages.friend_attending_unknown") + ) + await update.effective_chat.send_message( + localizer.format("bot.messages.friend_attending", friend=friend_label) + ) async def show_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -258,7 +319,16 @@ async def application_job(update: Update, context: ContextTypes.DEFAULT_TYPE) -> async def application_career(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: if not update.message or not update.message.text: return APPLICATION_CAREER - career_path = update.message.text.strip() + context.user_data["career_path"] = update.message.text.strip() + localizer = get_bot_localizer() + await update.message.reply_text(localizer.get("bot.application.ask_friends")) + return APPLICATION_FRIENDS + + +async def application_friends(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not update.message or update.message.text is None: + return APPLICATION_FRIENDS + friend_usernames = parse_friend_usernames(update.message.text) user = update.effective_user chat = update.effective_chat if not user or not chat: @@ -267,7 +337,8 @@ async def application_career(update: Update, context: ContextTypes.DEFAULT_TYPE) db_user, _ = upsert_user(session, user) db_user.full_name = context.user_data.get("full_name") db_user.job = context.user_data.get("job") - db_user.career_path = career_path + db_user.career_path = context.user_data.get("career_path") + db_user.friend_usernames = serialize_friend_usernames(friend_usernames) db_user.status = UserStatus.PROCESSING event_state = get_or_create_event_state(session) localizer = get_bot_localizer() @@ -307,6 +378,7 @@ async def cancel_application(update: Update, context: ContextTypes.DEFAULT_TYPE) db_user.full_name = None db_user.job = None db_user.career_path = None + db_user.friend_usernames = None event_state = get_or_create_event_state(session) localizer = get_bot_localizer() await chat.send_message( @@ -335,7 +407,7 @@ async def feedback_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> db_user, _ = upsert_user(session, user) event_state = get_or_create_event_state(session) localizer = get_bot_localizer() - if not (event_state.event_started and db_user.status == UserStatus.ATTENDEE): + if not (event_state.event_started and db_user.status != UserStatus.NONE): await chat.send_message( localizer.get("bot.messages.main_menu"), reply_markup=build_main_keyboard(db_user.status, event_state.event_started), @@ -486,22 +558,6 @@ async def broadcast_payload(session, context, message, waiting_for: AdminStateTy logger.exception("Failed to send broadcast to %s", target.telegram_id) -async def broadcast_text(bot, text: str) -> None: - with session_scope() as session: - users = ( - session.execute(select(User).where(User.notifications_enabled.is_(True))) - .scalars() - .all() - ) - for user in users: - if not user.telegram_id: - continue - try: - await bot.send_message(chat_id=user.telegram_id, text=text) - except Exception: - logger.exception("Failed to send broadcast to %s", user.telegram_id) - - def ensure_admin(update: Update) -> bool: user = update.effective_user if not user or not is_admin(user.username): @@ -551,8 +607,6 @@ async def set_welcome_message(update: Update, context: ContextTypes.DEFAULT_TYPE return with session_scope() as session: set_admin_state(session, user.id, AdminStateType.WELCOME) - localizer = get_bot_localizer() - await update.effective_chat.send_message(localizer.get("bot.admin.templates.awaiting_welcome")) async def set_schedule_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -563,8 +617,6 @@ async def set_schedule_message(update: Update, context: ContextTypes.DEFAULT_TYP return with session_scope() as session: set_admin_state(session, user.id, AdminStateType.SCHEDULE) - localizer = get_bot_localizer() - await update.effective_chat.send_message(localizer.get("bot.admin.templates.awaiting_schedule")) async def urgent_notification(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -626,6 +678,7 @@ async def download_database(update: Update, context: ContextTypes.DEFAULT_TYPE) "full_name", "job", "career_path", + "friend_usernames", "status", "notifications_enabled", "created_at", @@ -640,6 +693,7 @@ async def download_database(update: Update, context: ContextTypes.DEFAULT_TYPE) user.full_name or "", user.job or "", user.career_path or "", + user.friend_usernames or "", user.status.value, str(user.notifications_enabled).lower(), user.created_at.isoformat(), @@ -723,6 +777,12 @@ async def update_status_by_username( localizer.get("bot.admin.errors.user_not_found") ) return + if user.status == UserStatus.NONE: + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.admin.errors.application_not_found") + ) + return user.status = status user.updated_at = datetime.utcnow() if status == UserStatus.ATTENDEE: @@ -757,6 +817,12 @@ async def update_status_by_id( localizer.get("bot.admin.errors.user_not_found") ) return + if user.status == UserStatus.NONE: + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.admin.errors.application_not_found") + ) + return user.status = status user.updated_at = datetime.utcnow() if status == UserStatus.ATTENDEE: @@ -778,10 +844,21 @@ async def event_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non ) return state.event_started = True + users = session.execute(select(User)).scalars().all() localizer = get_bot_localizer() if update.effective_chat: await update.effective_chat.send_message(localizer.get("bot.admin.event_started")) - await broadcast_text(context.bot, localizer.get("bot.messages.event_started_broadcast")) + for user in users: + if not user.telegram_id: + continue + try: + await context.bot.send_message( + chat_id=user.telegram_id, + text=localizer.get("bot.messages.event_started_broadcast"), + reply_markup=build_main_keyboard(user.status, True), + ) + except Exception: + logger.exception("Failed to send event started broadcast to %s", user.telegram_id) async def event_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -790,9 +867,20 @@ async def event_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No with session_scope() as session: state = get_or_create_event_state(session) state.event_started = False + users = session.execute(select(User)).scalars().all() localizer = get_bot_localizer() if update.effective_chat: - await update.effective_chat.send_message(localizer.get("bot.admin.event_cancelled")) + for user in users: + if not user.telegram_id: + continue + try: + await context.bot.send_message( + chat_id=user.telegram_id, + text=localizer.get("bot.admin.event_cancelled"), + reply_markup=build_main_keyboard(user.status, False), + ) + except Exception: + logger.exception("Failed to send event cancelled broadcast to %s", user.telegram_id) async def set_event_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -912,6 +1000,12 @@ def register(application: Application) -> None: application_career, ) ], + APPLICATION_FRIENDS: [ + MessageHandler( + filters.TEXT & ~filters.COMMAND & ~filters.Regex(f"^{MENU_HOME}$"), + application_friends, + ) + ], }, fallbacks=[MessageHandler(filters.Regex(f"^{MENU_HOME}$"), application_cancel)], ) From bbf4c2bbfd91f17a6ffd70ad0e7cbb5cba98dc77 Mon Sep 17 00:00:00 2001 From: Nikita Yefremov Date: Thu, 15 Jan 2026 14:16:01 +0500 Subject: [PATCH 7/8] tests|app increase tests coverage --- app/config.py | 4 +- app/localization.py | 5 +- app/telebot/handlers.py | 89 ++++--- tests/test_db_helpers.py | 138 ++++++++++ tests/test_handlers_flow.py | 490 +++++++++++++++++++++++++++++++++++- tests/test_web_admin.py | 65 +++++ 6 files changed, 740 insertions(+), 51 deletions(-) create mode 100644 tests/test_db_helpers.py diff --git a/app/config.py b/app/config.py index 8fcc734..48e7218 100644 --- a/app/config.py +++ b/app/config.py @@ -24,9 +24,7 @@ class Settings(BaseSettings): basic_auth_username: str = Field(default="admin", env="ADMIN_BASIC_AUTH_USERNAME") basic_auth_password: str = Field(default="admin", env="ADMIN_BASIC_AUTH_PASSWORD") scheduler_interval_seconds: int = Field(default=60, env="SCHEDULER_INTERVAL_SECONDS") - allow_admin_upload_overwrite: bool = Field( - default=False, env="ALLOW_ADMIN_UPLOAD_OVERWRITE" - ) + allow_admin_upload_overwrite: bool = Field(default=False, env="ALLOW_ADMIN_UPLOAD_OVERWRITE") database_url: str = Field( default="sqlite:///./anonchatbot.db", diff --git a/app/localization.py b/app/localization.py index 2bec209..10b12e5 100644 --- a/app/localization.py +++ b/app/localization.py @@ -1,6 +1,5 @@ import json import logging - from dataclasses import dataclass from functools import lru_cache from importlib import resources @@ -58,13 +57,13 @@ def _load_messages(locale: str) -> dict[str, Any]: @lru_cache(maxsize=None) def get_localizer(locale: str) -> Localizer: - logger.info('get_localizer: locale chosen is %s', locale) + logger.info("get_localizer: locale chosen is %s", locale) try: messages = _load_messages(locale) except FileNotFoundError: if locale == DEFAULT_LOCALE: raise - logger.error('Locale %s not found, using default one', locale) + logger.error("Locale %s not found, using default one", locale) return get_localizer(DEFAULT_LOCALE) fallback = get_localizer(DEFAULT_LOCALE) if locale != DEFAULT_LOCALE else None diff --git a/app/telebot/handlers.py b/app/telebot/handlers.py index 3a5944d..67c1577 100644 --- a/app/telebot/handlers.py +++ b/app/telebot/handlers.py @@ -78,6 +78,22 @@ def home_keyboard() -> ReplyKeyboardMarkup: return ReplyKeyboardMarkup([[MENU_HOME]], resize_keyboard=True) +def _persist_application( + session, + user, + context: ContextTypes.DEFAULT_TYPE, + friend_usernames: list[str], +) -> tuple[User, "EventState"]: + db_user, _ = upsert_user(session, user) + db_user.full_name = context.user_data.get("full_name") + db_user.job = context.user_data.get("job") + db_user.career_path = context.user_data.get("career_path") + db_user.friend_usernames = serialize_friend_usernames(friend_usernames) + db_user.status = UserStatus.PROCESSING + event_state = get_or_create_event_state(session) + return db_user, event_state + + def notifications_text(enabled: bool) -> str: localizer = get_bot_localizer() status_key = ( @@ -132,6 +148,24 @@ def deserialize_friend_usernames(value: str | None) -> set[str]: return {item for item in usernames if item} +async def broadcast_text(bot, text: str) -> None: + with session_scope() as session: + users = session.execute(select(User)).scalars().all() + event_state = get_or_create_event_state(session) + event_started = event_state.event_started + for user in users: + if not user.telegram_id: + continue + try: + await bot.send_message( + chat_id=user.telegram_id, + text=text, + reply_markup=build_main_keyboard(user.status, event_started), + ) + except Exception: + logger.exception("Failed to broadcast message to %s", user.telegram_id) + + async def send_welcome_message( update: Update, context: ContextTypes.DEFAULT_TYPE, template: MessageTemplate | None ) -> None: @@ -182,6 +216,7 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: with session_scope() as session: db_user, _ = upsert_user(session, user) + get_or_create_event_state(session) template = get_template(session, "welcome_message") friend_matches: list[User] = [] normalized_username = normalize_friend_username(user.username or "") @@ -320,9 +355,19 @@ async def application_career(update: Update, context: ContextTypes.DEFAULT_TYPE) if not update.message or not update.message.text: return APPLICATION_CAREER context.user_data["career_path"] = update.message.text.strip() + user = update.effective_user + chat = update.effective_chat + if not user or not chat: + return ConversationHandler.END + with session_scope() as session: + db_user, event_state = _persist_application(session, user, context, []) localizer = get_bot_localizer() - await update.message.reply_text(localizer.get("bot.application.ask_friends")) - return APPLICATION_FRIENDS + await chat.send_message( + localizer.get("bot.application.confirmation"), + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + context.user_data.clear() + return ConversationHandler.END async def application_friends(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: @@ -334,13 +379,7 @@ async def application_friends(update: Update, context: ContextTypes.DEFAULT_TYPE if not user or not chat: return ConversationHandler.END with session_scope() as session: - db_user, _ = upsert_user(session, user) - db_user.full_name = context.user_data.get("full_name") - db_user.job = context.user_data.get("job") - db_user.career_path = context.user_data.get("career_path") - db_user.friend_usernames = serialize_friend_usernames(friend_usernames) - db_user.status = UserStatus.PROCESSING - event_state = get_or_create_event_state(session) + db_user, event_state = _persist_application(session, user, context, friend_usernames) localizer = get_bot_localizer() await chat.send_message( localizer.get("bot.application.confirmation"), @@ -817,12 +856,6 @@ async def update_status_by_id( localizer.get("bot.admin.errors.user_not_found") ) return - if user.status == UserStatus.NONE: - localizer = get_bot_localizer() - await update.effective_chat.send_message( - localizer.get("bot.admin.errors.application_not_found") - ) - return user.status = status user.updated_at = datetime.utcnow() if status == UserStatus.ATTENDEE: @@ -844,21 +877,10 @@ async def event_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non ) return state.event_started = True - users = session.execute(select(User)).scalars().all() localizer = get_bot_localizer() if update.effective_chat: await update.effective_chat.send_message(localizer.get("bot.admin.event_started")) - for user in users: - if not user.telegram_id: - continue - try: - await context.bot.send_message( - chat_id=user.telegram_id, - text=localizer.get("bot.messages.event_started_broadcast"), - reply_markup=build_main_keyboard(user.status, True), - ) - except Exception: - logger.exception("Failed to send event started broadcast to %s", user.telegram_id) + await broadcast_text(context.bot, localizer.get("bot.messages.event_started_broadcast")) async def event_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -867,20 +889,9 @@ async def event_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No with session_scope() as session: state = get_or_create_event_state(session) state.event_started = False - users = session.execute(select(User)).scalars().all() localizer = get_bot_localizer() if update.effective_chat: - for user in users: - if not user.telegram_id: - continue - try: - await context.bot.send_message( - chat_id=user.telegram_id, - text=localizer.get("bot.admin.event_cancelled"), - reply_markup=build_main_keyboard(user.status, False), - ) - except Exception: - logger.exception("Failed to send event cancelled broadcast to %s", user.telegram_id) + await broadcast_text(context.bot, localizer.get("bot.admin.event_cancelled")) async def set_event_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: diff --git a/tests/test_db_helpers.py b/tests/test_db_helpers.py new file mode 100644 index 0000000..4f9105e --- /dev/null +++ b/tests/test_db_helpers.py @@ -0,0 +1,138 @@ +import importlib +from datetime import datetime, timedelta + +import app.config as config +import app.telebot.db as telebot_db +from app.models import UserStatus + + +def _configure_settings(monkeypatch) -> None: + monkeypatch.setenv("TELEGRAM_TOKEN", "token") + monkeypatch.setenv("DATABASE_URL", "sqlite:///./test.db") + monkeypatch.setenv("ADMIN_USERNAMES", "admin") + config.get_settings.cache_clear() + + +def test_parse_helpers(monkeypatch): + _configure_settings(monkeypatch) + + assert telebot_db._parse_user_id("42") == 42 + assert telebot_db._parse_user_id("42.0") == 42 + assert telebot_db._parse_user_id("42.5") is None + assert telebot_db._parse_user_id("nope") is None + assert telebot_db._parse_user_id(" ") is None + assert telebot_db.is_admin(None) is False + + assert telebot_db._parse_status("attendee") == UserStatus.ATTENDEE + assert telebot_db._parse_status("WAITLIST") == UserStatus.WAITLIST + assert telebot_db._parse_status("unknown") is None + assert telebot_db._parse_status(None) is None + + assert telebot_db._parse_bool("yes") is True + assert telebot_db._parse_bool("0") is False + assert telebot_db._parse_bool("maybe") is None + assert telebot_db._parse_bool(None) is None + + assert telebot_db._normalize_friend_usernames("@Alice, bob ,bob") == "alice,bob" + + +def test_build_user_from_row(monkeypatch): + _configure_settings(monkeypatch) + + assert telebot_db._build_user_from_row({"username": "missing"}) is None + assert telebot_db._build_user_from_row({"user_id": "oops"}) is None + + row = { + "user_id": "100", + "username": "member", + "full_name": "Ada Lovelace", + "job": "Engineer", + "career_path": "Backend", + "status": "processing", + "notifications_enabled": "true", + "friend_usernames": "@buddy, pal", + } + user = telebot_db._build_user_from_row(row) + assert user is not None + assert user.telegram_id == 100 + assert user.status == UserStatus.PROCESSING + assert user.friend_usernames == "buddy,pal" + + invalid_status = { + "user_id": "102", + "username": "member", + "status": "not-a-status", + "notifications_enabled": "true", + } + assert telebot_db._build_user_from_row(invalid_status) is None + + admin_row = { + "user_id": "101", + "username": "admin", + "full_name": "Admin", + "job": "", + "career_path": "", + "status": "attendee", + "notifications_enabled": "true", + } + assert telebot_db._build_user_from_row(admin_row) is None + + +def test_normalize_row_and_schema_validation(monkeypatch): + _configure_settings(monkeypatch) + + normalized = telebot_db._normalize_row({" user_id ": " 42 ", None: "skip"}) + assert normalized == {"user_id": "42"} + normalized = telebot_db._normalize_row({"user_id": None}) + assert normalized == {"user_id": None} + + assert telebot_db._validate_schema( + [ + "user_id", + "username", + "full_name", + "job", + "career_path", + "status", + "notifications_enabled", + ] + ) + assert telebot_db._validate_schema(["user_id", "username"]) is False + assert telebot_db._validate_schema(None) is False + + +def test_admin_state_and_template_updates(tmp_path, monkeypatch): + monkeypatch.setenv("TELEGRAM_TOKEN", "token") + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'admin.db'}") + config.get_settings.cache_clear() + + import app.database as database + import app.models as models + import app.telebot.db as db_module + + database = importlib.reload(database) + models = importlib.reload(models) + db_module = importlib.reload(db_module) + database.ensure_schema() + + with database.session_scope() as session: + db_module.set_template(session, "welcome_message", 1, 10) + session.flush() + db_module.set_template(session, "welcome_message", 2, 20) + template = session.get(models.MessageTemplate, "welcome_message") + + assert template.admin_chat_id == 2 + assert template.message_id == 20 + + with database.session_scope() as session: + session.add( + models.AdminState( + admin_id=1, + waiting_for=models.AdminStateType.WELCOME, + ttl_seconds=300, + created_at=datetime.utcnow() - timedelta(seconds=400), + ) + ) + + with database.session_scope() as session: + assert db_module.get_admin_state(session, 1) is None diff --git a/tests/test_handlers_flow.py b/tests/test_handlers_flow.py index b28ddba..bc1d9ab 100644 --- a/tests/test_handlers_flow.py +++ b/tests/test_handlers_flow.py @@ -3,11 +3,13 @@ import importlib import io from dataclasses import dataclass +from datetime import datetime from typing import Any import app.config as config import app.localization as localization from sqlalchemy import select +from telegram.ext import Application @dataclass @@ -375,12 +377,8 @@ def test_download_then_upload_database_updates_rows(tmp_path, monkeypatch): asyncio.run(handlers.process_upload_database(upload_update, context, admin_id=admin.id)) with database.session_scope() as session: - refreshed_one = session.scalar( - select(models.User).where(models.User.telegram_id == 101) - ) - refreshed_two = session.scalar( - select(models.User).where(models.User.telegram_id == 202) - ) + refreshed_one = session.scalar(select(models.User).where(models.User.telegram_id == 101)) + refreshed_two = session.scalar(select(models.User).where(models.User.telegram_id == 202)) assert refreshed_one.full_name == "Ada Lovelace" assert refreshed_one.job == "Engineer II" @@ -507,3 +505,483 @@ async def fake_broadcast_text(bot_obj, text: str) -> None: state = session.scalar(select(models.EventState)) assert state.event_started is False + + +def test_broadcast_text_sends_to_users(tmp_path, monkeypatch): + handlers, database, models = _reload_handlers(tmp_path, monkeypatch) + + bot = DummyBot() + + with database.session_scope() as session: + session.add_all( + [ + models.User( + telegram_id=201, + username="alpha", + status=models.UserStatus.ATTENDEE, + notifications_enabled=True, + ), + models.User( + telegram_id=202, + username="beta", + status=models.UserStatus.PROCESSING, + notifications_enabled=True, + ), + ] + ) + session.add(models.EventState(event_started=True, current_event_id="event-1")) + + asyncio.run(handlers.broadcast_text(bot, "Broadcast!")) + + assert len(bot.sent_messages) == 2 + assert {message["chat_id"] for message in bot.sent_messages} == {201, 202} + + +def test_show_status_and_notifications(tmp_path, monkeypatch): + handlers, _database, models = _reload_handlers(tmp_path, monkeypatch) + + bot = DummyBot() + chat = DummyChat(chat_id=18, bot=bot) + user = DummyUser(id=18, username="guest") + context = DummyContext(bot) + + update = DummyUpdate(user=user, chat=chat) + + asyncio.run(handlers.show_status(update, context)) + asyncio.run(handlers.show_notifications(update, context)) + + assert bot.sent_messages[0]["text"] == handlers.status_text(models.UserStatus.NONE) + assert bot.sent_messages[1]["text"] == handlers.notifications_text(True) + + +def test_notifications_toggle_and_cancel_application(tmp_path, monkeypatch): + handlers, database, models = _reload_handlers(tmp_path, monkeypatch) + + bot = DummyBot() + chat = DummyChat(chat_id=19, bot=bot) + user = DummyUser(id=19, username="guest") + context = DummyContext(bot) + update = DummyUpdate(user=user, chat=chat) + + asyncio.run(handlers.notifications_disable(update, context)) + asyncio.run(handlers.notifications_enable(update, context)) + + with database.session_scope() as session: + db_user = session.scalar(select(models.User).where(models.User.telegram_id == 19)) + + assert db_user.notifications_enabled is True + + with database.session_scope() as session: + session.add( + models.User( + telegram_id=204, + username="applicant", + status=models.UserStatus.PROCESSING, + full_name="Ada", + job="Engineer", + career_path="Backend", + friend_usernames="buddy", + notifications_enabled=True, + ) + ) + + applicant = DummyUser(id=204, username="applicant") + applicant_chat = DummyChat(chat_id=204, bot=bot) + applicant_update = DummyUpdate(user=applicant, chat=applicant_chat) + asyncio.run(handlers.cancel_application(applicant_update, context)) + + with database.session_scope() as session: + db_user = session.scalar(select(models.User).where(models.User.telegram_id == 204)) + + assert db_user.status == models.UserStatus.NONE + assert db_user.full_name is None + assert db_user.friend_usernames is None + + +def test_schedule_and_feedback_exit_paths(tmp_path, monkeypatch): + handlers, database, models = _reload_handlers(tmp_path, monkeypatch) + + bot = DummyBot() + chat = DummyChat(chat_id=22, bot=bot) + user = DummyUser(id=22, username="guest") + context = DummyContext(bot) + + with database.session_scope() as session: + session.add( + models.MessageTemplate( + name="schedule_message", + admin_chat_id=55, + message_id=77, + ) + ) + + update = DummyUpdate(user=user, chat=chat) + asyncio.run(handlers.schedule(update, context)) + assert bot.copied_messages == [{"chat_id": 22, "from_chat_id": 55, "message_id": 77}] + + feedback_result = asyncio.run(handlers.feedback_start(update, context)) + assert feedback_result == handlers.ConversationHandler.END + + +def test_admin_schedule_template_and_broadcasts(tmp_path, monkeypatch): + handlers, database, models = _reload_handlers(tmp_path, monkeypatch, admin_usernames="admin") + + bot = DummyBot() + chat = DummyChat(chat_id=30, bot=bot) + user = DummyUser(id=30, username="admin") + context = DummyContext(bot) + + with database.session_scope() as session: + session.add( + models.AdminState( + admin_id=user.id, + waiting_for=models.AdminStateType.SCHEDULE, + ttl_seconds=300, + ) + ) + + message = DummyMessage(text="Schedule update", chat_id=chat.id, message_id=99) + update = DummyUpdate(user=user, chat=chat, message=message) + asyncio.run(handlers.handle_admin_payload(update, context)) + + with database.session_scope() as session: + template = session.scalar( + select(models.MessageTemplate).where(models.MessageTemplate.name == "schedule_message") + ) + + assert template is not None + assert template.message_id == 99 + + with database.session_scope() as session: + session.add_all( + [ + models.User( + telegram_id=301, + username="attendee", + status=models.UserStatus.ATTENDEE, + notifications_enabled=True, + ), + models.User( + telegram_id=302, + username="silent", + status=models.UserStatus.ATTENDEE, + notifications_enabled=False, + ), + models.User( + telegram_id=303, + username="waiter", + status=models.UserStatus.WAITLIST, + notifications_enabled=True, + ), + ] + ) + + payload = DummyMessage(text="Broadcast", chat_id=chat.id, message_id=101) + with database.session_scope() as session: + asyncio.run( + handlers.broadcast_payload( + session, + context, + payload, + models.AdminStateType.BROADCAST_ATTENDEE, + ) + ) + + assert bot.copied_messages == [{"chat_id": 301, "from_chat_id": 30, "message_id": 101}] + + +def test_download_database_and_unknown_command(tmp_path, monkeypatch): + handlers, database, models = _reload_handlers(tmp_path, monkeypatch, admin_usernames="admin") + + bot = DummyBot() + chat = DummyChat(chat_id=40, bot=bot) + user = DummyUser(id=40, username="admin") + context = DummyContext(bot) + + with database.session_scope() as session: + db_user = models.User( + telegram_id=401, + username="member", + status=models.UserStatus.ATTENDEE, + notifications_enabled=True, + ) + session.add(db_user) + session.add(models.EventState(event_started=True, current_event_id="event-1")) + session.flush() + session.add( + models.Feedback( + event_id="event-1", + user_id=db_user.id, + feedback_text="Nice", + created_at=datetime.utcnow(), + ) + ) + + update = DummyUpdate(user=user, chat=chat) + asyncio.run(handlers.download_database(update, context)) + + assert len(chat.sent_documents) == 2 + assert chat.sent_documents[0][1] == "users.csv" + assert chat.sent_documents[1][1] == "feedback.csv" + + non_admin = DummyUser(id=41, username="guest") + non_admin_chat = DummyChat(chat_id=41, bot=bot) + non_admin_update = DummyUpdate(user=non_admin, chat=non_admin_chat) + asyncio.run(handlers.unknown_command(non_admin_update, context)) + + assert non_admin_chat.sent_messages + + +def test_friend_helpers_and_application_restart(tmp_path, monkeypatch): + handlers, database, models = _reload_handlers(tmp_path, monkeypatch) + + assert handlers.normalize_friend_username("@Guest") == "guest" + assert handlers.parse_friend_usernames("@Guest, @Guest friend") == ["guest", "friend"] + assert handlers.serialize_friend_usernames(["guest", "friend"]) == "guest,friend" + assert handlers.deserialize_friend_usernames("guest, friend") == {"guest", "friend"} + + bot = DummyBot() + chat = DummyChat(chat_id=50, bot=bot) + user = DummyUser(id=50, username="guest") + context = DummyContext(bot) + + with database.session_scope() as session: + session.add( + models.User( + telegram_id=50, + username="guest", + status=models.UserStatus.PROCESSING, + notifications_enabled=True, + ) + ) + + update = DummyUpdate(user=user, chat=chat) + result = asyncio.run(handlers.application_start(update, context)) + assert result == handlers.ConversationHandler.END + + +def test_application_friends_persists_data(tmp_path, monkeypatch): + handlers, database, models = _reload_handlers(tmp_path, monkeypatch) + + bot = DummyBot() + chat = DummyChat(chat_id=52, bot=bot) + user = DummyUser(id=52, username="guest") + context = DummyContext(bot) + context.user_data.update({"full_name": "Ada", "job": "Engineer", "career_path": "Backend"}) + + message = DummyMessage(text="@FriendOne friendtwo") + update = DummyUpdate(user=user, chat=chat, message=message) + result = asyncio.run(handlers.application_friends(update, context)) + assert result == handlers.ConversationHandler.END + + with database.session_scope() as session: + db_user = session.scalar(select(models.User).where(models.User.telegram_id == 52)) + + assert db_user.friend_usernames == "friendone,friendtwo" + assert db_user.status == models.UserStatus.PROCESSING + + +def test_template_helpers_and_attendee_notification(tmp_path, monkeypatch): + handlers, database, models = _reload_handlers(tmp_path, monkeypatch) + + bot = DummyBot() + chat = DummyChat(chat_id=60, bot=bot) + user = DummyUser(id=60, username="guest") + context = DummyContext(bot) + update = DummyUpdate(user=user, chat=chat) + + asyncio.run(handlers.send_welcome_message(update, context, None)) + asyncio.run(handlers.send_schedule_message(update, context, None)) + + localizer = handlers.get_bot_localizer() + assert chat.sent_messages[0][0] == localizer.get("bot.templates.missing_welcome") + assert chat.sent_messages[1][0] == localizer.get("bot.templates.missing_schedule") + + with database.session_scope() as session: + session.add( + models.User( + telegram_id=601, + username="attendee", + status=models.UserStatus.ATTENDEE, + notifications_enabled=True, + ) + ) + + async def fake_sleep(_: float) -> None: + return None + + monkeypatch.setattr(handlers.asyncio, "sleep", fake_sleep) + asyncio.run(handlers.send_attendee_notification(bot, 601)) + assert bot.sent_messages + + +def test_admin_commands_and_event_id(tmp_path, monkeypatch): + handlers, database, models = _reload_handlers(tmp_path, monkeypatch, admin_usernames="admin") + + bot = DummyBot() + chat = DummyChat(chat_id=70, bot=bot) + user = DummyUser(id=70, username="admin") + context = DummyContext(bot) + + update = DummyUpdate(user=user, chat=chat, message=DummyMessage(text="/set_event_id")) + asyncio.run(handlers.set_event_id(update, context)) + assert chat.sent_messages + + update = DummyUpdate(user=user, chat=chat, message=DummyMessage(text="/set_event_id event-2")) + asyncio.run(handlers.set_event_id(update, context)) + + with database.session_scope() as session: + state = session.scalar(select(models.EventState)) + + assert state.current_event_id == "event-2" + + asyncio.run(handlers.admin_help(update, context)) + asyncio.run(handlers.set_welcome_message(update, context)) + asyncio.run(handlers.set_schedule_message(update, context)) + asyncio.run(handlers.urgent_notification(update, context)) + asyncio.run(handlers.urgent_notification_attendee(update, context)) + asyncio.run(handlers.upload_database(update, context)) + + with database.session_scope() as session: + admin_state = session.scalar(select(models.AdminState)) + + assert admin_state is not None + + +def test_status_updates_and_admin_guard(tmp_path, monkeypatch): + handlers, database, models = _reload_handlers(tmp_path, monkeypatch, admin_usernames="admin") + + bot = DummyBot() + chat = DummyChat(chat_id=80, bot=bot) + user = DummyUser(id=80, username="admin") + context = DummyContext(bot) + + with database.session_scope() as session: + session.add( + models.User( + telegram_id=801, + username="member", + status=models.UserStatus.PROCESSING, + notifications_enabled=True, + ) + ) + session.add( + models.User( + telegram_id=802, + username="empty", + status=models.UserStatus.NONE, + notifications_enabled=True, + ) + ) + + update = DummyUpdate(user=user, chat=chat, message=DummyMessage(text="/approve missing")) + asyncio.run(handlers.update_status_by_username(update, context, models.UserStatus.ATTENDEE)) + + update = DummyUpdate(user=user, chat=chat, message=DummyMessage(text="/approve empty")) + asyncio.run(handlers.update_status_by_username(update, context, models.UserStatus.ATTENDEE)) + + update = DummyUpdate(user=user, chat=chat, message=DummyMessage(text="/approve member")) + asyncio.run(handlers.update_status_by_username(update, context, models.UserStatus.ATTENDEE)) + + with database.session_scope() as session: + db_user = session.scalar(select(models.User).where(models.User.username == "member")) + + assert db_user.status == models.UserStatus.ATTENDEE + + non_admin = DummyUser(id=81, username="guest") + non_admin_chat = DummyChat(chat_id=81, bot=bot) + non_admin_update = DummyUpdate(user=non_admin, chat=non_admin_chat) + + created: list[object] = [] + + def fake_create_task(coro): + created.append(coro) + coro.close() + return None + + monkeypatch.setattr(handlers.asyncio, "create_task", fake_create_task) + + assert handlers.ensure_admin(non_admin_update) is False + assert created + + +def test_check_applications_summary(tmp_path, monkeypatch): + handlers, database, models = _reload_handlers(tmp_path, monkeypatch, admin_usernames="admin") + + bot = DummyBot() + chat = DummyChat(chat_id=90, bot=bot) + user = DummyUser(id=90, username="admin") + context = DummyContext(bot) + + with database.session_scope() as session: + session.add_all( + [ + models.User( + telegram_id=901, + username="applicant", + status=models.UserStatus.PROCESSING, + notifications_enabled=True, + ), + models.User( + telegram_id=902, + username="waiter", + status=models.UserStatus.WAITLIST, + notifications_enabled=True, + ), + ] + ) + + update = DummyUpdate(user=user, chat=chat) + asyncio.run(handlers.check_applications(update, context)) + + assert chat.sent_messages + + +def test_friend_helpers_empty_and_template_success(tmp_path, monkeypatch): + handlers, _database, models = _reload_handlers(tmp_path, monkeypatch) + + assert handlers.parse_friend_usernames("") == [] + assert handlers.serialize_friend_usernames([]) is None + assert handlers.deserialize_friend_usernames(None) == set() + + bot = DummyBot() + chat = DummyChat(chat_id=95, bot=bot) + user = DummyUser(id=95, username="guest") + context = DummyContext(bot) + update = DummyUpdate(user=user, chat=chat) + + welcome = models.MessageTemplate(name="welcome_message", admin_chat_id=11, message_id=22) + schedule = models.MessageTemplate(name="schedule_message", admin_chat_id=11, message_id=33) + + asyncio.run(handlers.send_welcome_message(update, context, welcome)) + asyncio.run(handlers.send_schedule_message(update, context, schedule)) + + assert bot.copied_messages == [ + {"chat_id": 95, "from_chat_id": 11, "message_id": 22}, + {"chat_id": 95, "from_chat_id": 11, "message_id": 33}, + ] + + +def test_check_applications_guard_paths(tmp_path, monkeypatch): + handlers, _database, _models = _reload_handlers(tmp_path, monkeypatch, admin_usernames="admin") + + bot = DummyBot() + chat = DummyChat(chat_id=96, bot=bot) + non_admin = DummyUser(id=96, username="guest") + update = DummyUpdate(user=non_admin, chat=chat) + + asyncio.run(handlers.check_applications(update, DummyContext(bot))) + + admin = DummyUser(id=97, username="admin") + update = DummyUpdate(user=admin, chat=None) + asyncio.run(handlers.check_applications(update, DummyContext(bot))) + + +def test_register_adds_handlers(tmp_path, monkeypatch): + handlers, _database, _models = _reload_handlers(tmp_path, monkeypatch) + + application = Application.builder().token("token").build() + handlers.register(application) + + total_handlers = sum(len(group) for group in application.handlers.values()) + assert total_handlers > 0 diff --git a/tests/test_web_admin.py b/tests/test_web_admin.py index d0f73e9..d2177f5 100644 --- a/tests/test_web_admin.py +++ b/tests/test_web_admin.py @@ -182,6 +182,13 @@ def test_update_timezone_and_limit(tmp_path, monkeypatch): ) assert response.status_code == 303 + response = client.post( + "/admin/event/limit", + auth=("admin", "secret"), + data={"limit": "-1"}, + ) + assert response.status_code == 400 + def test_registration_updates_and_manual_entry(tmp_path, monkeypatch): client, database, models, settings = _setup_admin_app(tmp_path, monkeypatch) @@ -258,3 +265,61 @@ def test_registration_updates_and_manual_entry(tmp_path, monkeypatch): manual = session.scalar(select(models.User).where(models.User.display_name == "Manual")) assert manual is not None assert manual.is_manual is True + + +def test_registration_lists_and_urgent(tmp_path, monkeypatch): + client, database, models, settings = _setup_admin_app(tmp_path, monkeypatch) + + with database.session_scope() as session: + event = models.Event(name=settings.event_name, capacity=5) + session.add(event) + session.flush() + + user = models.User(telegram_id=999, username="notify", notifications_enabled=True) + session.add(user) + session.flush() + + session.add_all( + [ + models.Registration( + event_id=event.id, + user_id=user.id, + category=models.RegistrationCategory.ATTENDEE, + status=models.RegistrationStatus.APPROVED, + is_priority=True, + ), + models.Registration( + event_id=event.id, + user_id=user.id, + category=models.RegistrationCategory.LECTURER, + status=models.RegistrationStatus.WAITLISTED, + ), + models.Registration( + event_id=event.id, + user_id=user.id, + category=models.RegistrationCategory.SHOWCASE, + status=models.RegistrationStatus.REJECTED, + ), + ] + ) + + for path in [ + "/admin/registrations", + "/admin/registrations/approved", + "/admin/registrations/approved-priority", + "/admin/registrations/waitlisted", + "/admin/registrations/declined", + ]: + response = client.get(path, auth=("admin", "secret")) + assert response.status_code == 200 + + response = client.get("/admin/urgent", auth=("admin", "secret")) + assert response.status_code == 200 + + response = client.post( + "/admin/urgent", + auth=("admin", "secret"), + data={"message": "Emergency"}, + ) + assert response.status_code == 200 + assert client.app.state.bot.sent_messages == [(999, "Emergency")] From d58979b37209ee9f9684c5fab1a1b3a6a1b0b53e Mon Sep 17 00:00:00 2001 From: Nikita Yefremov Date: Thu, 15 Jan 2026 14:42:51 +0500 Subject: [PATCH 8/8] app: telebot: restructure files --- app/localization.py | 2 +- app/telebot/admin_handlers.py | 489 ++++++++++++++++ app/telebot/common.py | 99 ++++ app/telebot/db.py | 8 +- app/telebot/handlers.py | 1011 +++++---------------------------- app/telebot/user_handlers.py | 436 ++++++++++++++ 6 files changed, 1166 insertions(+), 879 deletions(-) create mode 100644 app/telebot/admin_handlers.py create mode 100644 app/telebot/common.py create mode 100644 app/telebot/user_handlers.py diff --git a/app/localization.py b/app/localization.py index 10b12e5..6dc4d97 100644 --- a/app/localization.py +++ b/app/localization.py @@ -5,7 +5,7 @@ from importlib import resources from typing import Any -DEFAULT_LOCALE = "en" +DEFAULT_LOCALE = "ru" logger = logging.getLogger(__name__) diff --git a/app/telebot/admin_handlers.py b/app/telebot/admin_handlers.py new file mode 100644 index 0000000..2bf321a --- /dev/null +++ b/app/telebot/admin_handlers.py @@ -0,0 +1,489 @@ +import asyncio +import csv +import io +import logging +from datetime import datetime + +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from telegram import Update +from telegram.ext import ContextTypes + +from app.database import session_scope +from app.models import AdminStateType, Feedback, User, UserStatus +from app.telebot.common import get_bot_localizer +from app.telebot.db import ( + clear_admin_state, + get_admin_state, + get_or_create_event_state, + is_admin, + process_upload_database, + set_admin_state, + set_template, +) +from app.telebot.user_handlers import broadcast_text as user_broadcast_text + +logger = logging.getLogger(__name__) + + +async def handle_admin_payload(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + if not user or not update.message or not is_admin(user.username): + return + with session_scope() as session: + state = get_admin_state(session, user.id) + if not state: + logger.debug("Admin payload ignored: no state for admin_id=%s", user.id) + return + localizer = get_bot_localizer() + if state.waiting_for == AdminStateType.UPLOAD_DB: + if not update.message.document: + if update.message.text and update.message.text.startswith("/"): + return + await update.message.reply_text(localizer.get("bot.admin.errors.expected_csv")) + logger.info("Admin upload rejected: expected document admin_id=%s", user.id) + return + logger.info("Admin upload received admin_id=%s", user.id) + await process_upload_database(update, context, state.admin_id) + await update.message.reply_text(localizer.get("bot.admin.database.updated")) + return + message = update.message + if state.waiting_for == AdminStateType.WELCOME: + if message.text and message.text.startswith("/"): + await update.message.reply_text( + localizer.get("bot.admin.templates.awaiting_welcome") + ) + return + set_template(session, "welcome_message", message.chat_id, message.message_id) + clear_admin_state(session, user.id) + logger.info("Admin welcome template saved admin_id=%s", user.id) + await update.message.reply_text(localizer.get("bot.admin.templates.saved_welcome")) + return + if state.waiting_for == AdminStateType.SCHEDULE: + if message.text and message.text.startswith("/"): + await update.message.reply_text( + localizer.get("bot.admin.templates.awaiting_schedule") + ) + return + set_template(session, "schedule_message", message.chat_id, message.message_id) + clear_admin_state(session, user.id) + logger.info("Admin schedule template saved admin_id=%s", user.id) + await update.message.reply_text(localizer.get("bot.admin.templates.saved_schedule")) + return + if state.waiting_for in (AdminStateType.BROADCAST_ALL, AdminStateType.BROADCAST_ATTENDEE): + if message.text and message.text.startswith("/"): + await update.message.reply_text( + localizer.get("bot.admin.broadcast.awaiting_message") + ) + return + clear_admin_state(session, user.id) + await broadcast_payload(session, context, message, state.waiting_for) + return + + +async def broadcast_payload(session, context, message, waiting_for: AdminStateType) -> None: + if waiting_for == AdminStateType.BROADCAST_ATTENDEE: + users = ( + session.execute( + select(User).where( + User.status == UserStatus.ATTENDEE, + User.notifications_enabled.is_(True), + ) + ) + .scalars() + .all() + ) + else: + users = ( + session.execute(select(User).where(User.notifications_enabled.is_(True))) + .scalars() + .all() + ) + logger.info("Broadcasting admin message to %s users", len(users)) + for target in users: + try: + await context.bot.copy_message( + chat_id=target.telegram_id, + from_chat_id=message.chat_id, + message_id=message.message_id, + ) + except Exception: + logger.exception("Failed to send broadcast to %s", target.telegram_id) + + +def ensure_admin(update: Update) -> bool: + user = update.effective_user + if not user or not is_admin(user.username): + if update.effective_chat: + asyncio.create_task( + update.effective_chat.send_message( + get_bot_localizer().get("bot.admin.errors.unknown_or_forbidden") + ) + ) + return False + return True + + +async def admin_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + commands = [ + "/admin", + "/download_database", + "/upload_database", + "/check_applications", + "/approve {nickname}", + "/disapprove {nickname}", + "/processing {nickname}", + "/approve_id {user_id}", + "/disapprove_id {user_id}", + "/processing_id {user_id}", + "/set_welcome_message", + "/set_schedule_message", + "/urgent_notification", + "/urgent_notification_attendee", + "/event_start", + "/event_cancel", + "/set_event_id {id}", + ] + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.format("bot.admin.commands_list", commands="\n".join(commands)) + ) + + +async def set_welcome_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + user = update.effective_user + if not user or not update.effective_chat: + return + with session_scope() as session: + set_admin_state(session, user.id, AdminStateType.WELCOME) + logger.info("Admin requested welcome template admin_id=%s", user.id) + + +async def set_schedule_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + user = update.effective_user + if not user or not update.effective_chat: + return + with session_scope() as session: + set_admin_state(session, user.id, AdminStateType.SCHEDULE) + logger.info("Admin requested schedule template admin_id=%s", user.id) + + +async def urgent_notification(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + user = update.effective_user + if not user or not update.effective_chat: + return + with session_scope() as session: + set_admin_state(session, user.id, AdminStateType.BROADCAST_ALL) + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.broadcast.awaiting_all")) + logger.info("Admin requested urgent broadcast to all admin_id=%s", user.id) + + +async def urgent_notification_attendee(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + user = update.effective_user + if not user or not update.effective_chat: + return + with session_scope() as session: + set_admin_state(session, user.id, AdminStateType.BROADCAST_ATTENDEE) + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.admin.broadcast.awaiting_attendees") + ) + logger.info("Admin requested urgent broadcast to attendees admin_id=%s", user.id) + + +async def upload_database(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + user = update.effective_user + if not user or not update.effective_chat: + return + with session_scope() as session: + set_admin_state(session, user.id, AdminStateType.UPLOAD_DB) + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.database.awaiting_upload")) + logger.info("Admin requested database upload admin_id=%s", user.id) + + +async def download_database(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + if not update.effective_chat: + return + with session_scope() as session: + users = session.execute(select(User)).scalars().all() + feedback = ( + session.execute(select(Feedback).options(selectinload(Feedback.user))).scalars().all() + ) + user_stream = io.StringIO() + user_writer = csv.writer(user_stream) + user_writer.writerow( + [ + "user_id", + "username", + "full_name", + "job", + "career_path", + "friend_usernames", + "status", + "notifications_enabled", + "created_at", + "updated_at", + ] + ) + for user in users: + user_writer.writerow( + [ + user.telegram_id, + user.username or "", + user.full_name or "", + user.job or "", + user.career_path or "", + user.friend_usernames or "", + user.status.value, + str(user.notifications_enabled).lower(), + user.created_at.isoformat(), + user.updated_at.isoformat() if user.updated_at else "", + ] + ) + user_stream.seek(0) + await update.effective_chat.send_document( + document=io.BytesIO(user_stream.getvalue().encode("utf-8")), + filename="users.csv", + ) + + feedback_stream = io.StringIO() + feedback_writer = csv.writer(feedback_stream) + feedback_writer.writerow(["event_id", "user_id", "feedback_text", "created_at"]) + for item in feedback: + feedback_writer.writerow( + [ + item.event_id, + item.user.telegram_id if item.user else "", + item.feedback_text, + item.created_at.isoformat(), + ] + ) + feedback_stream.seek(0) + await update.effective_chat.send_document( + document=io.BytesIO(feedback_stream.getvalue().encode("utf-8")), + filename="feedback.csv", + ) + logger.info("Admin database download completed users=%s feedback=%s", len(users), len(feedback)) + + +async def check_applications(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + if not update.effective_chat: + return + with session_scope() as session: + users = session.execute(select(User)).scalars().all() + applications = [user for user in users if user.status != UserStatus.NONE] + attendee_count = len([user for user in users if user.status == UserStatus.ATTENDEE]) + localizer = get_bot_localizer() + lines = [ + localizer.format( + "bot.admin.applications.summary", + applications=len(applications), + attendees=attendee_count, + ) + ] + for user in applications: + label = f"@{user.username}" if user.username else str(user.telegram_id) + lines.append(f"{label} -> {user.status.value}") + await update.effective_chat.send_message("\n".join(lines)) + logger.info( + "Admin application list sent admin_id=%s applications=%s", + update.effective_user.id if update.effective_user else None, + len(applications), + ) + + +def parse_username(text: str | None) -> str | None: + if not text: + return None + return text.lstrip("@").strip() + + +async def update_status_by_username( + update: Update, context: ContextTypes.DEFAULT_TYPE, status: UserStatus +) -> None: + if not ensure_admin(update): + return + if not update.effective_chat or not update.message: + return + parts = update.message.text.split(maxsplit=1) + if len(parts) < 2: + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.admin.errors.nickname_required") + ) + return + nickname = parse_username(parts[1]) + with session_scope() as session: + user = session.scalar(select(User).where(User.username.ilike(nickname))) + if not user: + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.admin.errors.user_not_found") + ) + return + if user.status == UserStatus.NONE: + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.admin.errors.application_not_found") + ) + return + user.status = status + user.updated_at = datetime.utcnow() + if status == UserStatus.ATTENDEE: + asyncio.create_task(send_attendee_notification(context.bot, user.telegram_id)) + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.status.updated")) + logger.info( + "Admin status updated by username admin_id=%s username=%s status=%s", + update.effective_user.id if update.effective_user else None, + nickname, + status.value, + ) + + +async def update_status_by_id( + update: Update, context: ContextTypes.DEFAULT_TYPE, status: UserStatus +) -> None: + if not ensure_admin(update): + return + if not update.effective_chat or not update.message: + return + parts = update.message.text.split(maxsplit=1) + if len(parts) < 2: + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.errors.user_id_required")) + return + try: + user_id = int(parts[1]) + except ValueError: + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.errors.user_id_invalid")) + return + with session_scope() as session: + user = session.scalar(select(User).where(User.telegram_id == user_id)) + if not user: + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.admin.errors.user_not_found") + ) + return + user.status = status + user.updated_at = datetime.utcnow() + if status == UserStatus.ATTENDEE: + asyncio.create_task(send_attendee_notification(context.bot, user.telegram_id)) + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.status.updated")) + logger.info( + "Admin status updated by user_id admin_id=%s user_id=%s status=%s", + update.effective_user.id if update.effective_user else None, + user_id, + status.value, + ) + + +async def event_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + with session_scope() as session: + state = get_or_create_event_state(session) + if state.event_started: + localizer = get_bot_localizer() + if update.effective_chat: + await update.effective_chat.send_message( + localizer.get("bot.admin.event_already_started") + ) + logger.info( + "Admin event start ignored (already started) admin_id=%s", + update.effective_user.id if update.effective_user else None, + ) + return + state.event_started = True + localizer = get_bot_localizer() + if update.effective_chat: + await update.effective_chat.send_message(localizer.get("bot.admin.event_started")) + await _broadcast_text(context.bot, localizer.get("bot.messages.event_started_broadcast")) + logger.info( + "Admin event started admin_id=%s", + update.effective_user.id if update.effective_user else None, + ) + + +async def event_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + with session_scope() as session: + state = get_or_create_event_state(session) + state.event_started = False + localizer = get_bot_localizer() + if update.effective_chat: + await _broadcast_text(context.bot, localizer.get("bot.admin.event_cancelled")) + logger.info( + "Admin event cancelled admin_id=%s", + update.effective_user.id if update.effective_user else None, + ) + + +async def set_event_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not ensure_admin(update): + return + if not update.message or not update.effective_chat: + return + parts = update.message.text.split(maxsplit=1) + if len(parts) < 2: + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.admin.errors.event_id_required") + ) + return + with session_scope() as session: + state = get_or_create_event_state(session) + state.current_event_id = parts[1] + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.admin.event_id_updated")) + logger.info( + "Admin event id updated admin_id=%s event_id=%s", + update.effective_user.id if update.effective_user else None, + parts[1], + ) + + +async def send_attendee_notification(bot, telegram_id: int) -> None: + await asyncio.sleep(30) + with session_scope() as session: + user = session.scalar(select(User).where(User.telegram_id == telegram_id)) + if not user or user.status != UserStatus.ATTENDEE or not user.notifications_enabled: + return + localizer = get_bot_localizer() + await bot.send_message( + chat_id=telegram_id, + text=localizer.get("bot.status.attendee_notification"), + ) + logger.info("Attendee notification sent telegram_id=%s", telegram_id) + + +async def _broadcast_text(bot, text: str) -> None: + try: + from app.telebot import handlers as handlers_module + + broadcast = getattr(handlers_module, "broadcast_text", None) + except Exception: + broadcast = None + await (broadcast or user_broadcast_text)(bot, text) diff --git a/app/telebot/common.py b/app/telebot/common.py new file mode 100644 index 0000000..6601138 --- /dev/null +++ b/app/telebot/common.py @@ -0,0 +1,99 @@ +import logging +import re + +from telegram import ReplyKeyboardMarkup + +from app.config import get_settings +from app.localization import DEFAULT_LOCALE, get_localizer +from app.models import UserStatus + +logger = logging.getLogger(__name__) + + +def get_bot_localizer(): + settings = get_settings() + locale = getattr(settings, "locale", DEFAULT_LOCALE) + return get_localizer(locale) + + +LOCALIZER = get_bot_localizer() + +MENU_APPLICATION = LOCALIZER.get("bot.menu.application") +MENU_CANCEL = LOCALIZER.get("bot.menu.cancel") +MENU_FEEDBACK = LOCALIZER.get("bot.menu.feedback") +MENU_SCHEDULE = LOCALIZER.get("bot.menu.schedule") +MENU_STATUS = LOCALIZER.get("bot.menu.status") +MENU_NOTIFICATIONS = LOCALIZER.get("bot.menu.notifications") +MENU_HOME = LOCALIZER.get("bot.menu.home") + + +def build_main_keyboard(status: UserStatus, event_started: bool) -> ReplyKeyboardMarkup: + if status == UserStatus.NONE: + first_button = MENU_APPLICATION + elif event_started: + first_button = MENU_FEEDBACK + else: + first_button = MENU_CANCEL + keyboard = [ + [first_button, MENU_SCHEDULE], + [MENU_STATUS, MENU_NOTIFICATIONS], + ] + return ReplyKeyboardMarkup(keyboard, resize_keyboard=True) + + +def home_keyboard() -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup([[MENU_HOME]], resize_keyboard=True) + + +def notifications_text(enabled: bool) -> str: + localizer = get_bot_localizer() + status_key = ( + "bot.notifications.status.enabled" if enabled else "bot.notifications.status.disabled" + ) + status = localizer.get(status_key) + return localizer.format("bot.notifications.message", status=status) + + +def status_text(status: UserStatus) -> str: + localizer = get_bot_localizer() + mapping = { + UserStatus.NONE: "bot.status.none", + UserStatus.PROCESSING: "bot.status.processing", + UserStatus.ATTENDEE: "bot.status.attendee", + UserStatus.WAITLIST: "bot.status.waitlist", + } + return localizer.get(mapping[status]) + + +def normalize_friend_username(value: str) -> str | None: + normalized = value.lstrip("@").strip().lower() + return normalized or None + + +def parse_friend_usernames(text: str) -> list[str]: + if not text: + return [] + tokens = re.split(r"[,\s]+", text) + seen: set[str] = set() + result: list[str] = [] + for token in tokens: + normalized = normalize_friend_username(token) + if not normalized or normalized in seen: + continue + seen.add(normalized) + result.append(normalized) + return result + + +def serialize_friend_usernames(usernames: list[str]) -> str | None: + if not usernames: + return None + return ",".join(usernames) + + +def deserialize_friend_usernames(value: str | None) -> set[str]: + if not value: + return set() + tokens = re.split(r"[,\s]+", value) + usernames = {normalize_friend_username(token) for token in tokens} + return {item for item in usernames if item} diff --git a/app/telebot/db.py b/app/telebot/db.py index ae6849b..b0dedc4 100644 --- a/app/telebot/db.py +++ b/app/telebot/db.py @@ -188,9 +188,7 @@ def _build_user_from_row(row: dict[str, str]) -> User | None: career_path=row.get("career_path") or None, friend_usernames=friend_usernames, status=status or UserStatus.NONE, - notifications_enabled=notifications_enabled - if notifications_enabled is not None - else True, + notifications_enabled=notifications_enabled if notifications_enabled is not None else True, ) @@ -274,9 +272,7 @@ async def process_upload_database( continue users_to_insert.append(csv_user) if not users_to_insert: - logger.warning( - "No users parsed from upload; skipping delete to avoid empty reload" - ) + logger.warning("No users parsed from upload; skipping delete to avoid empty reload") return logger.info("Deleting existing feedback and users before upload") session.query(Feedback).delete() diff --git a/app/telebot/handlers.py b/app/telebot/handlers.py index 67c1577..85a53b9 100644 --- a/app/telebot/handlers.py +++ b/app/telebot/handlers.py @@ -1,13 +1,6 @@ -import asyncio -import csv -import io import logging -import re -from datetime import datetime -from sqlalchemy import select -from sqlalchemy.orm import selectinload -from telegram import ReplyKeyboardMarkup, Update +from telegram import Update from telegram.ext import ( Application, CommandHandler, @@ -19,534 +12,147 @@ from app.config import get_settings from app.database import session_scope -from app.localization import DEFAULT_LOCALE, get_localizer -from app.models import AdminStateType, Feedback, MessageTemplate, User, UserStatus +from app.models import UserStatus +from app.telebot.admin_handlers import ( + admin_help, + broadcast_payload, + check_applications, + download_database, + ensure_admin, + event_cancel, + event_start, + handle_admin_payload, + parse_username, + send_attendee_notification, + set_event_id, + set_schedule_message, + set_welcome_message, + update_status_by_id, + update_status_by_username, + upload_database, + urgent_notification, + urgent_notification_attendee, +) +from app.telebot.common import ( + MENU_APPLICATION, + MENU_CANCEL, + MENU_FEEDBACK, + MENU_HOME, + MENU_NOTIFICATIONS, + MENU_SCHEDULE, + MENU_STATUS, + build_main_keyboard, + deserialize_friend_usernames, + get_bot_localizer, + normalize_friend_username, + notifications_text, + parse_friend_usernames, + serialize_friend_usernames, + status_text, +) from app.telebot.db import ( - clear_admin_state, - get_admin_state, get_or_create_event_state, - get_template, is_admin, process_upload_database, set_admin_state, - set_template, upsert_user, ) +from app.telebot.user_handlers import ( + APPLICATION_CAREER, + APPLICATION_FRIENDS, + APPLICATION_FULL_NAME, + APPLICATION_JOB, + FEEDBACK_TEXT, + application_cancel, + application_career, + application_friends, + application_full_name, + application_job, + application_start, + broadcast_text, + cancel_application, + feedback_cancel, + feedback_save, + feedback_start, + go_home, + notifications_disable, + notifications_enable, + schedule, + send_schedule_message, + send_welcome_message, + show_notifications, + show_status, + start, +) logger = logging.getLogger(__name__) - -def get_bot_localizer(): - settings = get_settings() - locale = getattr(settings, "locale", DEFAULT_LOCALE) - return get_localizer(locale) - - -LOCALIZER = get_bot_localizer() - -MENU_APPLICATION = LOCALIZER.get("bot.menu.application") -MENU_CANCEL = LOCALIZER.get("bot.menu.cancel") -MENU_FEEDBACK = LOCALIZER.get("bot.menu.feedback") -MENU_SCHEDULE = LOCALIZER.get("bot.menu.schedule") -MENU_STATUS = LOCALIZER.get("bot.menu.status") -MENU_NOTIFICATIONS = LOCALIZER.get("bot.menu.notifications") -MENU_HOME = LOCALIZER.get("bot.menu.home") - -APPLICATION_FULL_NAME = 1 -APPLICATION_JOB = 2 -APPLICATION_CAREER = 3 -APPLICATION_FRIENDS = 4 - -FEEDBACK_TEXT = 10 - - -def build_main_keyboard(status: UserStatus, event_started: bool) -> ReplyKeyboardMarkup: - if status == UserStatus.NONE: - first_button = MENU_APPLICATION - elif event_started: - first_button = MENU_FEEDBACK - else: - first_button = MENU_CANCEL - keyboard = [ - [first_button, MENU_SCHEDULE], - [MENU_STATUS, MENU_NOTIFICATIONS], - ] - return ReplyKeyboardMarkup(keyboard, resize_keyboard=True) - - -def home_keyboard() -> ReplyKeyboardMarkup: - return ReplyKeyboardMarkup([[MENU_HOME]], resize_keyboard=True) - - -def _persist_application( - session, - user, - context: ContextTypes.DEFAULT_TYPE, - friend_usernames: list[str], -) -> tuple[User, "EventState"]: - db_user, _ = upsert_user(session, user) - db_user.full_name = context.user_data.get("full_name") - db_user.job = context.user_data.get("job") - db_user.career_path = context.user_data.get("career_path") - db_user.friend_usernames = serialize_friend_usernames(friend_usernames) - db_user.status = UserStatus.PROCESSING - event_state = get_or_create_event_state(session) - return db_user, event_state - - -def notifications_text(enabled: bool) -> str: - localizer = get_bot_localizer() - status_key = ( - "bot.notifications.status.enabled" if enabled else "bot.notifications.status.disabled" - ) - status = localizer.get(status_key) - return localizer.format("bot.notifications.message", status=status) - - -def status_text(status: UserStatus) -> str: - localizer = get_bot_localizer() - mapping = { - UserStatus.NONE: "bot.status.none", - UserStatus.PROCESSING: "bot.status.processing", - UserStatus.ATTENDEE: "bot.status.attendee", - UserStatus.WAITLIST: "bot.status.waitlist", - } - return localizer.get(mapping[status]) - - -def normalize_friend_username(value: str) -> str | None: - normalized = value.lstrip("@").strip().lower() - return normalized or None - - -def parse_friend_usernames(text: str) -> list[str]: - if not text: - return [] - tokens = re.split(r"[,\s]+", text) - seen: set[str] = set() - result: list[str] = [] - for token in tokens: - normalized = normalize_friend_username(token) - if not normalized or normalized in seen: - continue - seen.add(normalized) - result.append(normalized) - return result - - -def serialize_friend_usernames(usernames: list[str]) -> str | None: - if not usernames: - return None - return ",".join(usernames) - - -def deserialize_friend_usernames(value: str | None) -> set[str]: - if not value: - return set() - tokens = re.split(r"[,\s]+", value) - usernames = {normalize_friend_username(token) for token in tokens} - return {item for item in usernames if item} - - -async def broadcast_text(bot, text: str) -> None: - with session_scope() as session: - users = session.execute(select(User)).scalars().all() - event_state = get_or_create_event_state(session) - event_started = event_state.event_started - for user in users: - if not user.telegram_id: - continue - try: - await bot.send_message( - chat_id=user.telegram_id, - text=text, - reply_markup=build_main_keyboard(user.status, event_started), - ) - except Exception: - logger.exception("Failed to broadcast message to %s", user.telegram_id) - - -async def send_welcome_message( - update: Update, context: ContextTypes.DEFAULT_TYPE, template: MessageTemplate | None -) -> None: - if not update.effective_chat: - return - if not template: - localizer = get_bot_localizer() - await update.effective_chat.send_message(localizer.get("bot.templates.missing_welcome")) - return - try: - await context.bot.copy_message( - chat_id=update.effective_chat.id, - from_chat_id=template.admin_chat_id, - message_id=template.message_id, - ) - except Exception: - logger.exception("Failed to send welcome template message") - localizer = get_bot_localizer() - await update.effective_chat.send_message(localizer.get("bot.templates.missing_welcome")) - - -async def send_schedule_message( - update: Update, context: ContextTypes.DEFAULT_TYPE, template: MessageTemplate | None -) -> None: - if not update.effective_chat: - return - if not template: - localizer = get_bot_localizer() - await update.effective_chat.send_message(localizer.get("bot.templates.missing_schedule")) - return - try: - await context.bot.copy_message( - chat_id=update.effective_chat.id, - from_chat_id=template.admin_chat_id, - message_id=template.message_id, - ) - except Exception: - logger.exception("Failed to send schedule template message") - localizer = get_bot_localizer() - await update.effective_chat.send_message(localizer.get("bot.templates.missing_schedule")) - - -async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - user = update.effective_user - chat = update.effective_chat - if not user or not chat: - return - - with session_scope() as session: - db_user, _ = upsert_user(session, user) - get_or_create_event_state(session) - template = get_template(session, "welcome_message") - friend_matches: list[User] = [] - normalized_username = normalize_friend_username(user.username or "") - if normalized_username: - candidates = ( - session.execute(select(User).where(User.friend_usernames.is_not(None))) - .scalars() - .all() - ) - for candidate in candidates: - if candidate.telegram_id == user.id or candidate.status == UserStatus.NONE: - continue - if normalized_username in deserialize_friend_usernames( - candidate.friend_usernames - ): - friend_matches.append(candidate) - - localizer = get_bot_localizer() - await send_welcome_message(update, context, template) - for friend in friend_matches: - if not update.effective_chat: - return - friend_label = ( - f"@{friend.username}" - if friend.username - else friend.full_name or localizer.get("bot.messages.friend_attending_unknown") - ) - await update.effective_chat.send_message( - localizer.format("bot.messages.friend_attending", friend=friend_label) - ) - - -async def show_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - user = update.effective_user - chat = update.effective_chat - if not user or not chat: - return - with session_scope() as session: - db_user, _ = upsert_user(session, user) - event_state = get_or_create_event_state(session) - message = status_text(db_user.status) - await context.bot.send_message( - chat_id=chat.id, - text=message, - reply_markup=build_main_keyboard(db_user.status, event_state.event_started), - ) - - -async def show_notifications(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - user = update.effective_user - chat = update.effective_chat - if not user or not chat: - return - with session_scope() as session: - db_user, _ = upsert_user(session, user) - event_state = get_or_create_event_state(session) - message = notifications_text(db_user.notifications_enabled) - await context.bot.send_message( - chat_id=chat.id, - text=message, - reply_markup=build_main_keyboard(db_user.status, event_state.event_started), - ) - - -async def notifications_disable(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - user = update.effective_user - if not user or not update.effective_chat: - return - with session_scope() as session: - db_user, _ = upsert_user(session, user) - db_user.notifications_enabled = False - event_state = get_or_create_event_state(session) - message = notifications_text(db_user.notifications_enabled) - await context.bot.send_message( - chat_id=update.effective_chat.id, - text=message, - reply_markup=build_main_keyboard(db_user.status, event_state.event_started), - ) - - -async def notifications_enable(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - user = update.effective_user - if not user or not update.effective_chat: - return - with session_scope() as session: - db_user, _ = upsert_user(session, user) - db_user.notifications_enabled = True - event_state = get_or_create_event_state(session) - message = notifications_text(db_user.notifications_enabled) - await context.bot.send_message( - chat_id=update.effective_chat.id, - text=message, - reply_markup=build_main_keyboard(db_user.status, event_state.event_started), - ) - - -async def application_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - user = update.effective_user - if not user or not update.effective_chat: - return ConversationHandler.END - with session_scope() as session: - db_user, _ = upsert_user(session, user) - localizer = get_bot_localizer() - if db_user.status != UserStatus.NONE: - await update.effective_chat.send_message( - localizer.get("bot.application.already_created"), - reply_markup=home_keyboard(), - ) - return ConversationHandler.END - await update.effective_chat.send_message( - localizer.get("bot.application.ask_full_name"), - reply_markup=home_keyboard(), - ) - return APPLICATION_FULL_NAME - - -async def application_full_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - if not update.message or not update.message.text: - return APPLICATION_FULL_NAME - context.user_data["full_name"] = update.message.text.strip() - localizer = get_bot_localizer() - await update.message.reply_text(localizer.get("bot.application.ask_job")) - return APPLICATION_JOB - - -async def application_job(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - if not update.message or not update.message.text: - return APPLICATION_JOB - context.user_data["job"] = update.message.text.strip() - localizer = get_bot_localizer() - await update.message.reply_text(localizer.get("bot.application.ask_career")) - return APPLICATION_CAREER - - -async def application_career(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - if not update.message or not update.message.text: - return APPLICATION_CAREER - context.user_data["career_path"] = update.message.text.strip() - user = update.effective_user - chat = update.effective_chat - if not user or not chat: - return ConversationHandler.END - with session_scope() as session: - db_user, event_state = _persist_application(session, user, context, []) - localizer = get_bot_localizer() - await chat.send_message( - localizer.get("bot.application.confirmation"), - reply_markup=build_main_keyboard(db_user.status, event_state.event_started), - ) - context.user_data.clear() - return ConversationHandler.END - - -async def application_friends(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - if not update.message or update.message.text is None: - return APPLICATION_FRIENDS - friend_usernames = parse_friend_usernames(update.message.text) - user = update.effective_user - chat = update.effective_chat - if not user or not chat: - return ConversationHandler.END - with session_scope() as session: - db_user, event_state = _persist_application(session, user, context, friend_usernames) - localizer = get_bot_localizer() - await chat.send_message( - localizer.get("bot.application.confirmation"), - reply_markup=build_main_keyboard(db_user.status, event_state.event_started), - ) - context.user_data.clear() - return ConversationHandler.END - - -async def application_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - user = update.effective_user - chat = update.effective_chat - if not user or not chat: - return ConversationHandler.END - with session_scope() as session: - db_user, _ = upsert_user(session, user) - event_state = get_or_create_event_state(session) - context.user_data.clear() - localizer = get_bot_localizer() - await chat.send_message( - localizer.get("bot.messages.main_menu"), - reply_markup=build_main_keyboard(db_user.status, event_state.event_started), - ) - return ConversationHandler.END - - -async def cancel_application(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - user = update.effective_user - chat = update.effective_chat - if not user or not chat: - return - with session_scope() as session: - db_user, _ = upsert_user(session, user) - db_user.status = UserStatus.NONE - db_user.full_name = None - db_user.job = None - db_user.career_path = None - db_user.friend_usernames = None - event_state = get_or_create_event_state(session) - localizer = get_bot_localizer() - await chat.send_message( - localizer.get("bot.application.cancelled"), - reply_markup=build_main_keyboard(db_user.status, event_state.event_started), - ) - - -async def schedule(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - user = update.effective_user - chat = update.effective_chat - if not user or not chat: - return - with session_scope() as session: - db_user, _ = upsert_user(session, user) - template = get_template(session, "schedule_message") - await send_schedule_message(update, context, template) - - -async def feedback_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - user = update.effective_user - chat = update.effective_chat - if not user or not chat: - return ConversationHandler.END - with session_scope() as session: - db_user, _ = upsert_user(session, user) - event_state = get_or_create_event_state(session) - localizer = get_bot_localizer() - if not (event_state.event_started and db_user.status != UserStatus.NONE): - await chat.send_message( - localizer.get("bot.messages.main_menu"), - reply_markup=build_main_keyboard(db_user.status, event_state.event_started), - ) - return ConversationHandler.END - await chat.send_message( - localizer.get("bot.feedback.prompt"), - reply_markup=home_keyboard(), - ) - return FEEDBACK_TEXT - - -async def feedback_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - if not update.message or not update.message.text: - return FEEDBACK_TEXT - user = update.effective_user - chat = update.effective_chat - if not user or not chat: - return ConversationHandler.END - with session_scope() as session: - db_user, _ = upsert_user(session, user) - event_state = get_or_create_event_state(session) - feedback = Feedback( - event_id=event_state.current_event_id or "default", - user_id=db_user.id, - feedback_text=update.message.text.strip(), - created_at=datetime.utcnow(), - ) - session.add(feedback) - localizer = get_bot_localizer() - await chat.send_message( - localizer.get("bot.feedback.confirmation"), - reply_markup=build_main_keyboard(db_user.status, event_state.event_started), - ) - return ConversationHandler.END - - -async def feedback_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - return await application_cancel(update, context) - - -async def send_attendee_notification(bot, telegram_id: int) -> None: - await asyncio.sleep(30) - with session_scope() as session: - user = session.scalar(select(User).where(User.telegram_id == telegram_id)) - if not user or user.status != UserStatus.ATTENDEE or not user.notifications_enabled: - return - localizer = get_bot_localizer() - await bot.send_message( - chat_id=telegram_id, - text=localizer.get("bot.status.attendee_notification"), - ) - - -async def handle_admin_payload(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - user = update.effective_user - if not user or not update.message or not is_admin(user.username): - return - with session_scope() as session: - state = get_admin_state(session, user.id) - if not state: - return - localizer = get_bot_localizer() - if state.waiting_for == AdminStateType.UPLOAD_DB: - if not update.message.document: - if update.message.text and update.message.text.startswith("/"): - return - await update.message.reply_text(localizer.get("bot.admin.errors.expected_csv")) - return - await process_upload_database(update, context, state.admin_id) - await update.message.reply_text(localizer.get("bot.admin.database.updated")) - return - message = update.message - if state.waiting_for == AdminStateType.WELCOME: - if message.text and message.text.startswith("/"): - await update.message.reply_text(localizer.get("bot.admin.templates.awaiting_welcome")) - return - set_template(session, "welcome_message", message.chat_id, message.message_id) - clear_admin_state(session, user.id) - await update.message.reply_text(localizer.get("bot.admin.templates.saved_welcome")) - return - if state.waiting_for == AdminStateType.SCHEDULE: - if message.text and message.text.startswith("/"): - await update.message.reply_text( - localizer.get("bot.admin.templates.awaiting_schedule") - ) - return - set_template(session, "schedule_message", message.chat_id, message.message_id) - clear_admin_state(session, user.id) - await update.message.reply_text(localizer.get("bot.admin.templates.saved_schedule")) - return - if state.waiting_for in (AdminStateType.BROADCAST_ALL, AdminStateType.BROADCAST_ATTENDEE): - if message.text and message.text.startswith("/"): - await update.message.reply_text( - localizer.get("bot.admin.broadcast.awaiting_message") - ) - return - clear_admin_state(session, user.id) - await broadcast_payload(session, context, message, state.waiting_for) - return +__all__ = [ + "APPLICATION_CAREER", + "APPLICATION_FRIENDS", + "APPLICATION_FULL_NAME", + "APPLICATION_JOB", + "FEEDBACK_TEXT", + "MENU_APPLICATION", + "MENU_CANCEL", + "MENU_FEEDBACK", + "MENU_HOME", + "MENU_NOTIFICATIONS", + "MENU_SCHEDULE", + "MENU_STATUS", + "admin_help", + "application_cancel", + "application_career", + "application_friends", + "application_full_name", + "application_job", + "application_start", + "broadcast_payload", + "broadcast_text", + "build_main_keyboard", + "cancel_application", + "check_applications", + "deserialize_friend_usernames", + "download_database", + "ensure_admin", + "event_cancel", + "event_start", + "feedback_cancel", + "feedback_save", + "feedback_start", + "get_bot_localizer", + "get_settings", + "handle_admin_payload", + "is_admin", + "log_update", + "normalize_friend_username", + "notifications_disable", + "notifications_enable", + "notifications_text", + "parse_friend_usernames", + "parse_username", + "process_upload_database", + "register", + "schedule", + "send_attendee_notification", + "send_schedule_message", + "send_welcome_message", + "serialize_friend_usernames", + "set_admin_state", + "set_event_id", + "set_schedule_message", + "set_welcome_message", + "show_notifications", + "show_status", + "start", + "status_text", + "unknown_command", + "update_status_by_id", + "update_status_by_username", + "upload_database", + "urgent_notification", + "urgent_notification_attendee", +] async def log_update(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -568,358 +174,17 @@ async def log_update(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None ) -async def broadcast_payload(session, context, message, waiting_for: AdminStateType) -> None: - if waiting_for == AdminStateType.BROADCAST_ATTENDEE: - users = ( - session.execute( - select(User).where( - User.status == UserStatus.ATTENDEE, - User.notifications_enabled.is_(True), - ) - ) - .scalars() - .all() - ) - else: - users = ( - session.execute(select(User).where(User.notifications_enabled.is_(True))) - .scalars() - .all() - ) - for target in users: - try: - await context.bot.copy_message( - chat_id=target.telegram_id, - from_chat_id=message.chat_id, - message_id=message.message_id, - ) - except Exception: - logger.exception("Failed to send broadcast to %s", target.telegram_id) - - -def ensure_admin(update: Update) -> bool: - user = update.effective_user - if not user or not is_admin(user.username): - if update.effective_chat: - asyncio.create_task( - update.effective_chat.send_message( - get_bot_localizer().get("bot.admin.errors.unknown_or_forbidden") - ) - ) - return False - return True - - -async def admin_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not ensure_admin(update): - return - commands = [ - "/admin", - "/download_database", - "/upload_database", - "/check_applications", - "/approve {nickname}", - "/disapprove {nickname}", - "/processing {nickname}", - "/approve_id {user_id}", - "/disapprove_id {user_id}", - "/processing_id {user_id}", - "/set_welcome_message", - "/set_schedule_message", - "/urgent_notification", - "/urgent_notification_attendee", - "/event_start", - "/event_cancel", - "/set_event_id {id}", - ] - localizer = get_bot_localizer() - await update.effective_chat.send_message( - localizer.format("bot.admin.commands_list", commands="\n".join(commands)) - ) - - -async def set_welcome_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not ensure_admin(update): - return - user = update.effective_user - if not user or not update.effective_chat: - return - with session_scope() as session: - set_admin_state(session, user.id, AdminStateType.WELCOME) - - -async def set_schedule_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not ensure_admin(update): - return - user = update.effective_user - if not user or not update.effective_chat: - return - with session_scope() as session: - set_admin_state(session, user.id, AdminStateType.SCHEDULE) - - -async def urgent_notification(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not ensure_admin(update): - return - user = update.effective_user - if not user or not update.effective_chat: - return - with session_scope() as session: - set_admin_state(session, user.id, AdminStateType.BROADCAST_ALL) - localizer = get_bot_localizer() - await update.effective_chat.send_message(localizer.get("bot.admin.broadcast.awaiting_all")) - - -async def urgent_notification_attendee(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not ensure_admin(update): - return - user = update.effective_user - if not user or not update.effective_chat: - return - with session_scope() as session: - set_admin_state(session, user.id, AdminStateType.BROADCAST_ATTENDEE) - localizer = get_bot_localizer() - await update.effective_chat.send_message( - localizer.get("bot.admin.broadcast.awaiting_attendees") - ) - - -async def upload_database(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not ensure_admin(update): - return - user = update.effective_user - if not user or not update.effective_chat: - return - with session_scope() as session: - set_admin_state(session, user.id, AdminStateType.UPLOAD_DB) - localizer = get_bot_localizer() - await update.effective_chat.send_message(localizer.get("bot.admin.database.awaiting_upload")) - - -async def download_database(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not ensure_admin(update): - return - if not update.effective_chat: - return - with session_scope() as session: - users = session.execute(select(User)).scalars().all() - feedback = ( - session.execute(select(Feedback).options(selectinload(Feedback.user))) - .scalars() - .all() - ) - user_stream = io.StringIO() - user_writer = csv.writer(user_stream) - user_writer.writerow( - [ - "user_id", - "username", - "full_name", - "job", - "career_path", - "friend_usernames", - "status", - "notifications_enabled", - "created_at", - "updated_at", - ] - ) - for user in users: - user_writer.writerow( - [ - user.telegram_id, - user.username or "", - user.full_name or "", - user.job or "", - user.career_path or "", - user.friend_usernames or "", - user.status.value, - str(user.notifications_enabled).lower(), - user.created_at.isoformat(), - user.updated_at.isoformat() if user.updated_at else "", - ] - ) - user_stream.seek(0) - await update.effective_chat.send_document( - document=io.BytesIO(user_stream.getvalue().encode("utf-8")), - filename="users.csv", - ) - - feedback_stream = io.StringIO() - feedback_writer = csv.writer(feedback_stream) - feedback_writer.writerow(["event_id", "user_id", "feedback_text", "created_at"]) - for item in feedback: - feedback_writer.writerow( - [ - item.event_id, - item.user.telegram_id if item.user else "", - item.feedback_text, - item.created_at.isoformat(), - ] - ) - feedback_stream.seek(0) - await update.effective_chat.send_document( - document=io.BytesIO(feedback_stream.getvalue().encode("utf-8")), - filename="feedback.csv", - ) - - -async def check_applications(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not ensure_admin(update): - return - if not update.effective_chat: - return - with session_scope() as session: - users = session.execute(select(User)).scalars().all() - applications = [user for user in users if user.status != UserStatus.NONE] - attendee_count = len([user for user in users if user.status == UserStatus.ATTENDEE]) - localizer = get_bot_localizer() - lines = [ - localizer.format( - "bot.admin.applications.summary", - applications=len(applications), - attendees=attendee_count, - ) - ] - for user in applications: - label = f"@{user.username}" if user.username else str(user.telegram_id) - lines.append(f"{label} -> {user.status.value}") - await update.effective_chat.send_message("\n".join(lines)) - - -def parse_username(text: str | None) -> str | None: - if not text: - return None - return text.lstrip("@").strip() - - -async def update_status_by_username( - update: Update, context: ContextTypes.DEFAULT_TYPE, status: UserStatus -) -> None: - if not ensure_admin(update): - return - if not update.effective_chat or not update.message: - return - parts = update.message.text.split(maxsplit=1) - if len(parts) < 2: - localizer = get_bot_localizer() - await update.effective_chat.send_message( - localizer.get("bot.admin.errors.nickname_required") - ) - return - nickname = parse_username(parts[1]) - with session_scope() as session: - user = session.scalar(select(User).where(User.username.ilike(nickname))) - if not user: - localizer = get_bot_localizer() - await update.effective_chat.send_message( - localizer.get("bot.admin.errors.user_not_found") - ) - return - if user.status == UserStatus.NONE: - localizer = get_bot_localizer() - await update.effective_chat.send_message( - localizer.get("bot.admin.errors.application_not_found") - ) - return - user.status = status - user.updated_at = datetime.utcnow() - if status == UserStatus.ATTENDEE: - asyncio.create_task(send_attendee_notification(context.bot, user.telegram_id)) - localizer = get_bot_localizer() - await update.effective_chat.send_message(localizer.get("bot.admin.status.updated")) - - -async def update_status_by_id( - update: Update, context: ContextTypes.DEFAULT_TYPE, status: UserStatus -) -> None: - if not ensure_admin(update): - return - if not update.effective_chat or not update.message: - return - parts = update.message.text.split(maxsplit=1) - if len(parts) < 2: - localizer = get_bot_localizer() - await update.effective_chat.send_message(localizer.get("bot.admin.errors.user_id_required")) - return - try: - user_id = int(parts[1]) - except ValueError: - localizer = get_bot_localizer() - await update.effective_chat.send_message(localizer.get("bot.admin.errors.user_id_invalid")) - return - with session_scope() as session: - user = session.scalar(select(User).where(User.telegram_id == user_id)) - if not user: - localizer = get_bot_localizer() - await update.effective_chat.send_message( - localizer.get("bot.admin.errors.user_not_found") - ) - return - user.status = status - user.updated_at = datetime.utcnow() - if status == UserStatus.ATTENDEE: - asyncio.create_task(send_attendee_notification(context.bot, user.telegram_id)) - localizer = get_bot_localizer() - await update.effective_chat.send_message(localizer.get("bot.admin.status.updated")) - - -async def event_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not ensure_admin(update): - return - with session_scope() as session: - state = get_or_create_event_state(session) - if state.event_started: - localizer = get_bot_localizer() - if update.effective_chat: - await update.effective_chat.send_message( - localizer.get("bot.admin.event_already_started") - ) - return - state.event_started = True - localizer = get_bot_localizer() - if update.effective_chat: - await update.effective_chat.send_message(localizer.get("bot.admin.event_started")) - await broadcast_text(context.bot, localizer.get("bot.messages.event_started_broadcast")) - - -async def event_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not ensure_admin(update): - return - with session_scope() as session: - state = get_or_create_event_state(session) - state.event_started = False - localizer = get_bot_localizer() - if update.effective_chat: - await broadcast_text(context.bot, localizer.get("bot.admin.event_cancelled")) - - -async def set_event_id(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if not ensure_admin(update): - return - if not update.message or not update.effective_chat: - return - parts = update.message.text.split(maxsplit=1) - if len(parts) < 2: - localizer = get_bot_localizer() - await update.effective_chat.send_message( - localizer.get("bot.admin.errors.event_id_required") - ) - return - with session_scope() as session: - state = get_or_create_event_state(session) - state.current_event_id = parts[1] - localizer = get_bot_localizer() - await update.effective_chat.send_message(localizer.get("bot.admin.event_id_updated")) - - async def unknown_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: user = update.effective_user if not user or not update.effective_chat: return if not is_admin(user.username): + with session_scope() as session: + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) await update.effective_chat.send_message( - get_bot_localizer().get("bot.admin.errors.unknown_or_forbidden") + get_bot_localizer().get("bot.admin.errors.unknown_or_forbidden"), + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), ) else: await update.effective_chat.send_message( @@ -1043,5 +308,7 @@ def register(application: Application) -> None: application.add_handler( MessageHandler(filters.Regex(f"^{MENU_NOTIFICATIONS}$"), show_notifications) ) + application.add_handler(MessageHandler(filters.Regex(f"^{MENU_HOME}$"), go_home)) application.add_handler(MessageHandler(filters.COMMAND, unknown_command)) + application.add_handler(MessageHandler(filters.ALL, unknown_command)) diff --git a/app/telebot/user_handlers.py b/app/telebot/user_handlers.py new file mode 100644 index 0000000..1cfcbe6 --- /dev/null +++ b/app/telebot/user_handlers.py @@ -0,0 +1,436 @@ +import logging +from datetime import datetime + +from sqlalchemy import select +from telegram import Update +from telegram.ext import ContextTypes, ConversationHandler + +from app.database import session_scope +from app.models import EventState, Feedback, MessageTemplate, User, UserStatus +from app.telebot.common import ( + build_main_keyboard, + deserialize_friend_usernames, + get_bot_localizer, + home_keyboard, + notifications_text, + parse_friend_usernames, + serialize_friend_usernames, + status_text, +) +from app.telebot.db import get_or_create_event_state, get_template, upsert_user + +logger = logging.getLogger(__name__) + +APPLICATION_FULL_NAME = 1 +APPLICATION_JOB = 2 +APPLICATION_CAREER = 3 +APPLICATION_FRIENDS = 4 + +FEEDBACK_TEXT = 10 + + +async def broadcast_text(bot, text: str) -> None: + with session_scope() as session: + users = session.execute(select(User)).scalars().all() + event_state = get_or_create_event_state(session) + event_started = event_state.event_started + logger.info("Broadcasting text message to %s users", len(users)) + for user in users: + if not user.telegram_id: + continue + try: + await bot.send_message( + chat_id=user.telegram_id, + text=text, + reply_markup=build_main_keyboard(user.status, event_started), + ) + except Exception: + logger.exception("Failed to broadcast message to %s", user.telegram_id) + + +async def send_welcome_message( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + template: MessageTemplate | None, + reply_markup=None, +) -> None: + if not update.effective_chat: + return + if not template: + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.templates.missing_welcome"), + reply_markup=reply_markup, + ) + logger.warning("Welcome template missing for chat_id=%s", update.effective_chat.id) + return + try: + await context.bot.copy_message( + chat_id=update.effective_chat.id, + from_chat_id=template.admin_chat_id, + message_id=template.message_id, + reply_markup=reply_markup, + ) + except Exception: + logger.exception("Failed to send welcome template message") + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.templates.missing_welcome"), + reply_markup=reply_markup, + ) + + +async def send_schedule_message( + update: Update, context: ContextTypes.DEFAULT_TYPE, template: MessageTemplate | None +) -> None: + if not update.effective_chat: + return + if not template: + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.templates.missing_schedule")) + logger.warning("Schedule template missing for chat_id=%s", update.effective_chat.id) + return + try: + await context.bot.copy_message( + chat_id=update.effective_chat.id, + from_chat_id=template.admin_chat_id, + message_id=template.message_id, + ) + except Exception: + logger.exception("Failed to send schedule template message") + localizer = get_bot_localizer() + await update.effective_chat.send_message(localizer.get("bot.templates.missing_schedule")) + + +def _persist_application( + session, + user, + context: ContextTypes.DEFAULT_TYPE, + friend_usernames: list[str], +) -> tuple[User, EventState]: + db_user, is_new = upsert_user(session, user) + db_user.full_name = context.user_data.get("full_name") + db_user.job = context.user_data.get("job") + db_user.career_path = context.user_data.get("career_path") + db_user.friend_usernames = serialize_friend_usernames(friend_usernames) + db_user.status = UserStatus.PROCESSING + event_state = get_or_create_event_state(session) + logger.info( + "Application saved user_id=%s new_user=%s friend_count=%s", + db_user.telegram_id, + is_new, + len(friend_usernames), + ) + return db_user, event_state + + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + chat = update.effective_chat + if not user or not chat: + return + + with session_scope() as session: + db_user, is_new = upsert_user(session, user) + event_state = get_or_create_event_state(session) + template = get_template(session, "welcome_message") + friend_matches: list[User] = [] + normalized_username = user.username or "" + normalized_username = normalized_username.lstrip("@").strip().lower() + if normalized_username: + candidates = ( + session.execute(select(User).where(User.friend_usernames.is_not(None))) + .scalars() + .all() + ) + for candidate in candidates: + if candidate.telegram_id == user.id or candidate.status == UserStatus.NONE: + continue + if normalized_username in deserialize_friend_usernames(candidate.friend_usernames): + friend_matches.append(candidate) + + logger.info( + "Start handled user_id=%s new_user=%s friend_matches=%s", + db_user.telegram_id, + is_new, + len(friend_matches), + ) + localizer = get_bot_localizer() + await send_welcome_message( + update, + context, + template, + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + for friend in friend_matches: + if not update.effective_chat: + return + friend_label = ( + f"@{friend.username}" + if friend.username + else friend.full_name or localizer.get("bot.messages.friend_attending_unknown") + ) + await update.effective_chat.send_message( + localizer.format("bot.messages.friend_attending", friend=friend_label) + ) + + +async def show_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + chat = update.effective_chat + if not user or not chat: + return + with session_scope() as session: + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) + message = status_text(db_user.status) + await context.bot.send_message( + chat_id=chat.id, + text=message, + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + logger.debug("Status shown user_id=%s status=%s", user.id, db_user.status.value) + + +async def show_notifications(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + chat = update.effective_chat + if not user or not chat: + return + with session_scope() as session: + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) + message = notifications_text(db_user.notifications_enabled) + await context.bot.send_message( + chat_id=chat.id, + text=message, + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + logger.debug( + "Notifications status shown user_id=%s enabled=%s", + user.id, + db_user.notifications_enabled, + ) + + +async def notifications_disable(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + if not user or not update.effective_chat: + return + with session_scope() as session: + db_user, _ = upsert_user(session, user) + db_user.notifications_enabled = False + event_state = get_or_create_event_state(session) + message = notifications_text(db_user.notifications_enabled) + await context.bot.send_message( + chat_id=update.effective_chat.id, + text=message, + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + logger.info("Notifications disabled user_id=%s", user.id) + + +async def notifications_enable(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + if not user or not update.effective_chat: + return + with session_scope() as session: + db_user, _ = upsert_user(session, user) + db_user.notifications_enabled = True + event_state = get_or_create_event_state(session) + message = notifications_text(db_user.notifications_enabled) + await context.bot.send_message( + chat_id=update.effective_chat.id, + text=message, + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + logger.info("Notifications enabled user_id=%s", user.id) + + +async def go_home(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + if not user or not update.effective_chat: + return + with session_scope() as session: + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) + localizer = get_bot_localizer() + await update.effective_chat.send_message( + localizer.get("bot.messages.main_menu"), + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + + +async def application_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + user = update.effective_user + if not user or not update.effective_chat: + return ConversationHandler.END + with session_scope() as session: + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) + localizer = get_bot_localizer() + if db_user.status != UserStatus.NONE: + await update.effective_chat.send_message( + localizer.get("bot.application.already_created"), + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + return ConversationHandler.END + await update.effective_chat.send_message( + localizer.get("bot.application.ask_full_name"), + reply_markup=home_keyboard(), + ) + logger.info("Application flow started user_id=%s", user.id) + return APPLICATION_FULL_NAME + + +async def application_full_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not update.message or not update.message.text: + return APPLICATION_FULL_NAME + context.user_data["full_name"] = update.message.text.strip() + localizer = get_bot_localizer() + await update.message.reply_text(localizer.get("bot.application.ask_job")) + logger.debug("Application full name received user_id=%s", update.effective_user.id) + return APPLICATION_JOB + + +async def application_job(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not update.message or not update.message.text: + return APPLICATION_JOB + context.user_data["job"] = update.message.text.strip() + localizer = get_bot_localizer() + await update.message.reply_text(localizer.get("bot.application.ask_career")) + logger.debug("Application job received user_id=%s", update.effective_user.id) + return APPLICATION_CAREER + + +async def application_career(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not update.message or not update.message.text: + return APPLICATION_CAREER + context.user_data["career_path"] = update.message.text.strip() + localizer = get_bot_localizer() + await update.message.reply_text(localizer.get("bot.application.ask_friends")) + logger.debug("Application career path received user_id=%s", update.effective_user.id) + return APPLICATION_FRIENDS + + +async def application_friends(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not update.message or update.message.text is None: + return APPLICATION_FRIENDS + friend_usernames = parse_friend_usernames(update.message.text) + user = update.effective_user + chat = update.effective_chat + if not user or not chat: + return ConversationHandler.END + with session_scope() as session: + db_user, event_state = _persist_application(session, user, context, friend_usernames) + localizer = get_bot_localizer() + await chat.send_message( + localizer.get("bot.application.confirmation"), + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + context.user_data.clear() + return ConversationHandler.END + + +async def application_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + user = update.effective_user + chat = update.effective_chat + if not user or not chat: + return ConversationHandler.END + with session_scope() as session: + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) + context.user_data.clear() + localizer = get_bot_localizer() + await chat.send_message( + localizer.get("bot.messages.main_menu"), + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + logger.info("Application flow cancelled user_id=%s", user.id) + return ConversationHandler.END + + +async def cancel_application(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + chat = update.effective_chat + if not user or not chat: + return + with session_scope() as session: + db_user, _ = upsert_user(session, user) + db_user.status = UserStatus.NONE + db_user.full_name = None + db_user.job = None + db_user.career_path = None + db_user.friend_usernames = None + event_state = get_or_create_event_state(session) + localizer = get_bot_localizer() + await chat.send_message( + localizer.get("bot.application.cancelled"), + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + logger.info("Application cancelled user_id=%s", user.id) + + +async def schedule(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = update.effective_user + chat = update.effective_chat + if not user or not chat: + return + with session_scope() as session: + template = get_template(session, "schedule_message") + await send_schedule_message(update, context, template) + logger.info("Schedule requested user_id=%s", user.id) + + +async def feedback_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + user = update.effective_user + chat = update.effective_chat + if not user or not chat: + return ConversationHandler.END + with session_scope() as session: + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) + localizer = get_bot_localizer() + if not (event_state.event_started and db_user.status != UserStatus.NONE): + await chat.send_message( + localizer.get("bot.messages.main_menu"), + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + logger.info("Feedback blocked user_id=%s status=%s", user.id, db_user.status.value) + return ConversationHandler.END + await chat.send_message( + localizer.get("bot.feedback.prompt"), + reply_markup=home_keyboard(), + ) + logger.info("Feedback flow started user_id=%s", user.id) + return FEEDBACK_TEXT + + +async def feedback_save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not update.message or not update.message.text: + return FEEDBACK_TEXT + user = update.effective_user + chat = update.effective_chat + if not user or not chat: + return ConversationHandler.END + with session_scope() as session: + db_user, _ = upsert_user(session, user) + event_state = get_or_create_event_state(session) + feedback = Feedback( + event_id=event_state.current_event_id or "default", + user_id=db_user.id, + feedback_text=update.message.text.strip(), + created_at=datetime.utcnow(), + ) + session.add(feedback) + localizer = get_bot_localizer() + await chat.send_message( + localizer.get("bot.feedback.confirmation"), + reply_markup=build_main_keyboard(db_user.status, event_state.event_started), + ) + logger.info("Feedback saved user_id=%s event_id=%s", user.id, feedback.event_id) + return ConversationHandler.END + + +async def feedback_cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + return await application_cancel(update, context)