Untitled
4ae4d
plain_text
a month ago
118 kB
4
Indexable
diff --git a/services/bot/core/config/__init__.py b/services/bot/core/config/__init__.py
index c476155..6901585 100644
--- a/services/bot/core/config/__init__.py
+++ b/services/bot/core/config/__init__.py
@@ -1,9 +1,11 @@
from .bootstrap import BotWithDI, bot
from .settings import bot_config, settings
+from .validation_config import validation_cfg
__all__ = [
"BotWithDI",
"bot",
"bot_config",
"settings",
+ "validation_cfg",
]
diff --git a/services/bot/core/config/validation_config.py b/services/bot/core/config/validation_config.py
index f9c3b23..3a4dd30 100644
--- a/services/bot/core/config/validation_config.py
+++ b/services/bot/core/config/validation_config.py
@@ -1,28 +1,77 @@
import os
+
import yaml
+
class ValidationConfig:
-
def __init__(self, path: str = "/bot/validation.yml"):
if not os.path.exists(path):
raise FileNotFoundError(f"Файл не найден: {path}")
try:
- with open(path, "r", encoding="utf-8") as f:
- self._data = yaml.safe_load(f) or {}
+ with open(path, encoding="utf-8") as f:
+ raw = yaml.safe_load(f) or {}
except yaml.YAMLError as e:
raise ValueError(f"Ошибка парсинга YAML: {e}") from e
+ self._data = self._cast_numeric_rules(raw)
+
+ def _cast_numeric_rules(self, data: dict) -> dict:
+ result = {}
+ for k, v in data.items():
+ if isinstance(v, dict):
+ result[k] = self._cast_numeric_rules(v)
+ elif k in ("min", "max", "min_length", "max_length"):
+ try:
+ result[k] = int(v)
+ except (TypeError, ValueError):
+ result[k] = v
+ else:
+ result[k] = v
+ return result
+
def get(self, key: str, default=None):
+ # Если ключ не начинается с известного корневого раздела — добавляем event
+ if not any(key.startswith(prefix) for prefix in ("event.", "event")):
+ key = f"event.{key}"
+
val = self._data
- for k in key.split("."):
- if isinstance(val, dict) and k in val:
- val = val[k]
+ for part in key.split("."):
+ if isinstance(val, dict) and part in val:
+ val = val[part]
else:
return default
return val
- def __getitem__(self, key):
- return self._data[key]
+ def __getitem__(self, key: str):
+ """
+ - cfg["title.min_length"] → event.title.min_length
+ - cfg["event.hours.min"] → event.hours.min
+ - cfg["event"]["title"] → прямой доступ к вложенному словарю
+ """
+ if "." in key:
+ path = key
+ if not any(path.startswith(p) for p in ("event.", "event")):
+ path = f"event.{path}"
+
+ val = self._data
+ for part in path.split("."):
+ if isinstance(val, dict) and part in val:
+ val = val[part]
+ else:
+ raise KeyError(f"Ключ не найден: {key}")
+ return val
+
+ if key in self._data:
+ return self._data[key]
+ raise KeyError(f"Ключ не найден: {key}")
+
+ def __contains__(self, key: str) -> bool:
+ try:
+ self[key]
+ return True
+ except KeyError:
+ return False
+
- def __contains__(self, key):
- return key in self._data
\ No newline at end of file
+# Глобальный инстанс
+validation_cfg = ValidationConfig()
diff --git a/services/bot/core/handlers/admin.py b/services/bot/core/handlers/admin.py
index 5e29941..0c3f9aa 100644
--- a/services/bot/core/handlers/admin.py
+++ b/services/bot/core/handlers/admin.py
@@ -54,7 +54,7 @@ def set_moderator_value_handler(
try:
moderator_id, tb_id = map(str, text.split(maxsplit=1))
if " " in moderator_id + tb_id:
- raise RuntimeError
+ raise RuntimeError()
moderator_id = int(moderator_id)
except Exception:
bot.messaging.send_message(
@@ -115,7 +115,7 @@ def del_moderator_value_handler(
try:
moderator_id, tb_id = map(str, text.split(maxsplit=1))
if " " in moderator_id + tb_id:
- raise RuntimeError
+ raise RuntimeError()
moderator_id = int(moderator_id)
except Exception:
bot.messaging.send_message(
diff --git a/services/bot/core/handlers/moderation.py b/services/bot/core/handlers/moderation.py
index b8a32e1..f4a15ff 100644
--- a/services/bot/core/handlers/moderation.py
+++ b/services/bot/core/handlers/moderation.py
@@ -1,4 +1,3 @@
-import re
from datetime import datetime
from dialog_bot_sdk.entities.messaging import UpdateInteractiveMediaEvent, UpdateMessage
@@ -6,7 +5,7 @@ from dialog_bot_sdk.interactive_media import Button
from core.bot_kit.fsm import FSMContext, State, StatesGroup
from core.bot_kit.router import Router
-from core.config import bot
+from core.config import bot, validation_cfg
from core.handlers.events_ui import (
build_back_to_event_editing_keyboard,
build_back_to_event_keyboard,
@@ -17,6 +16,26 @@ from core.handlers.events_ui import (
split_event_value,
)
from core.handlers.request import build_request_actions_keyboard
+from core.handlers.validators import (
+ DescriptionOutOfRange,
+ EventValidationError,
+ HoursInvalidFormat,
+ HoursOutOfRange,
+ ParticipationLimitInvalidFormat,
+ ParticipationLimitOutOfRange,
+ TitleOutOfRange,
+ UnsupportedDateFormat,
+ UnsupportedTimeFormat,
+ VerificationCodeInvalidFormat,
+ VerificationCodeOutOfRange,
+ validate_date,
+ validate_description,
+ validate_hours,
+ validate_participation_limit,
+ validate_time,
+ validate_title,
+ validate_verification_code,
+)
from core.markups import (
back_to_moderation_and_skip_keyboard,
back_to_moderation_keyboard,
@@ -41,7 +60,107 @@ from core.utils import (
TAGS_MEDIA_ID = "event_tags_toggle"
TAGS_TOGGLE_PREFIX = "toggle:"
TAGS_DONE_VALUE = "done"
+
+
# TODO убрать хардкод
+# ─────────────────────────────────────────────────────────────
+# 🔧 Хелперы для адаптивных сообщений (используют validation_cfg["..."])
+# ─────────────────────────────────────────────────────────────
+def _cfg(field: str, rule: str, default=None):
+ """Короткий доступ: _cfg('title', 'min_length', 4) → validation_cfg["title.min_length"] или default"""
+ try:
+ return validation_cfg[f"{field}.{rule}"]
+ except KeyError:
+ return default
+
+
+def _err(field: str, message: str, **kwargs):
+ """Форматирует ошибку, подставляя лимиты из YAML: {min}, {max}, {min_len}, {max_len}"""
+ limits = {
+ "min": _cfg(field, "min"),
+ "max": _cfg(field, "max"),
+ "min_len": _cfg(field, "min_length"),
+ "max_len": _cfg(field, "max_length"),
+ }
+ limits = {k: v for k, v in limits.items() if v is not None}
+ return message.format(**kwargs, **limits)
+
+
+def _validation_error_text(error: EventValidationError) -> str:
+ if isinstance(error, TitleOutOfRange):
+ return _err(
+ "title",
+ "⚠️ Название должно содержать от {min_len} до {max_len} символов. Попробуй ещё раз:",
+ )
+
+ if isinstance(error, DescriptionOutOfRange):
+ return _err(
+ "description",
+ "⚠️ Описание не должно быть длиннее {max_len} символов. Попробуй ещё раз:",
+ )
+
+ if isinstance(error, UnsupportedDateFormat):
+ return (
+ "⚠️ Я не смог распознать дату.\n"
+ "Пожалуйста, введи дату в формате `dd.mm.yyyy`, например: `20.02.2002`:"
+ )
+
+ if isinstance(error, UnsupportedTimeFormat):
+ return (
+ "⚠️ Я не смог распознать время.\n"
+ "Пожалуйста, введи время в формате `HH:MM`, например: `10:30`:"
+ )
+
+ if isinstance(error, (HoursInvalidFormat, HoursOutOfRange)):
+ return _err(
+ "hours",
+ "⚠️ Количество часов должно быть целым числом от {min} до {max}. Попробуй ещё раз:",
+ )
+
+ if isinstance(error, (ParticipationLimitInvalidFormat, ParticipationLimitOutOfRange)):
+ return _err(
+ "participation_limit",
+ "⚠️ Количество участников должно быть целым числом от {min} до {max}. Попробуй ещё раз:",
+ )
+
+ if isinstance(error, VerificationCodeOutOfRange):
+ return _err(
+ "verification_code",
+ "⚠️ Код должен быть длиной от {min_len} до {max_len} символов. Попробуй ещё раз:",
+ )
+
+ if isinstance(error, VerificationCodeInvalidFormat):
+ return (
+ "⚠️ Код должен содержать только русские или латинские буквы и цифры. Попробуй ещё раз:"
+ )
+
+ return "⚠️ Некорректное значение. Попробуй ещё раз:"
+
+
+def _msg_err_create(message, error) -> None:
+ bot.messaging.send_message(
+ peer=message.peer,
+ text=_validation_error_text(error),
+ interactive_media_groups=back_to_moderation_keyboard(),
+ )
+
+
+def _msg_err_edit(
+ message,
+ error,
+ event_id: str | None = None,
+ ctx_name: str | None = None,
+) -> None:
+ if event_id is None or ctx_name is None:
+ raise Exception("event_id and ctx_name must be provided for _msg_err_edit helper function")
+ bot.messaging.send_message(
+ peer=message.peer,
+ text=_validation_error_text(error),
+ interactive_media_groups=build_back_to_event_editing_keyboard(
+ event_id=event_id,
+ ctx_name=ctx_name,
+ ),
+ )
events_rt = Router()
@@ -239,10 +358,12 @@ def create_event_start_handler(
@bot.di
def event_create_name_step(message: UpdateMessage, context: FSMContext):
delete_prev_message(bot, message)
-
- name = message.message.text_message.text.strip()
+ try:
+ name = validate_title(message.message.text_message.text.strip())
+ except EventValidationError as e:
+ _msg_err_create(message, e)
+ return
context.update_data({"event_name": name})
-
bot.messaging.send_message(
peer=message.peer,
text="Шаг 2/11: введи, пожалуйста, дату мероприятия например, в формате `20.02.2002`:",
@@ -251,58 +372,23 @@ def event_create_name_step(message: UpdateMessage, context: FSMContext):
context.set_state(EventCreateState.date)
-def _validate_date(text): # throws ValueError
- formats = []
- for first_gap in ". -_":
- for second_gap in ". -_":
- formats.append(f"%d{first_gap}%m{second_gap}%Y")
-
- date_obj = None
- for format in formats:
- try:
- date_obj = datetime.strptime(text, format).date()
- except ValueError:
- continue
-
- if date_obj is None:
- raise ValueError(f"unsupported date format in date {text!r}")
-
- return date_obj
-
-
@events_rt.message(state=EventCreateState.date)
@bot.di
def event_create_date_step(message: UpdateMessage, context: FSMContext):
delete_prev_message(bot, message)
-
- date = message.message.text_message.text.strip()
-
try:
- date_obj = _validate_date(date)
- except ValueError:
- bot.messaging.send_message(
- peer=message.peer,
- text=(
- "⚠️ Я не смог распознать дату.\n"
- "Пожалуйста, введи дату, например, в формате `20.02.2002`:"
- ),
- interactive_media_groups=back_to_moderation_keyboard(),
- )
+ date_obj = validate_date(message.message.text_message.text.strip())
+ except EventValidationError as e:
+ _msg_err_create(message, e)
return
-
if date_obj <= datetime.now().date():
bot.messaging.send_message(
peer=message.peer,
- text=(
- "⚠️ Ты не можешь создать мероприятие раньше завтрашнего дня.\n"
- "Пожалуйста, введи дату, например, в формате `20.02.2002`:"
- ),
+ text="⚠️ Ты не можешь создать мероприятие раньше завтрашнего дня.\nПожалуйста, введи дату, например, в формате `20.02.2002`:",
interactive_media_groups=back_to_moderation_keyboard(),
)
return
-
context.update_data({"event_date": date_obj})
-
bot.messaging.send_message(
peer=message.peer,
text="Шаг 3/11: введи, пожалуйста, время мероприятия например, в формате `10:30`:",
@@ -311,49 +397,20 @@ def event_create_date_step(message: UpdateMessage, context: FSMContext):
context.set_state(EventCreateState.time)
-def _validate_time(text): # throws ValueError
- time_obj = None
- for format in ["%H:%M", "%H %M", "%H-%M", "%H_%M"]:
- try:
- time_obj = datetime.strptime(text, format).time()
- except ValueError:
- continue
-
- if time_obj is None:
- raise ValueError(f"unsupported time format in time {text!r}", text)
-
- return time_obj
-
-
@events_rt.message(state=EventCreateState.time)
@bot.di
def event_create_time_step(
message: UpdateMessage, context: FSMContext, terbank_service: TerbankService
):
delete_prev_message(bot, message)
-
- time = message.message.text_message.text.strip()
-
try:
- time_obj = _validate_time(time)
- except ValueError:
- bot.messaging.send_message(
- peer=message.peer,
- text=(
- "⚠️ Я не смог распознать время.\n"
- "Пожалуйста, введи в формате `HH:MM`, например: `10:30`:"
- ),
- interactive_media_groups=back_to_moderation_keyboard(),
- )
+ time_obj = validate_time(message.message.text_message.text.strip())
+ except EventValidationError as e:
+ _msg_err_create(message, e)
return
-
context.update_data({"event_time": time_obj})
-
send_tb_select_menu(
- peer=message.peer,
- terbank_service=terbank_service,
- context=context,
- is_edit=False,
+ peer=message.peer, terbank_service=terbank_service, context=context, is_edit=False
)
@@ -381,12 +438,8 @@ def _tb_select_footer_buttons(
@bot.di
-def event_create_back_to_time_handler(
- event: UpdateInteractiveMediaEvent,
- context: FSMContext,
-):
+def event_create_back_to_time_handler(event: UpdateInteractiveMediaEvent, context: FSMContext):
delete_prev_message_by_peer(bot, event.peer)
-
bot.messaging.send_message(
peer=event.peer,
text="Шаг 3/11: введи, пожалуйста, время мероприятия например, в формате `10:30`:",
@@ -619,8 +672,12 @@ def _handle_project_input(peer, raw_text: str, context: FSMContext):
@bot.di
def event_create_description_step(message: UpdateMessage, context: FSMContext):
delete_prev_message(bot, message)
+ try:
+ description = validate_description(message.message.text_message.text.strip())
+ except EventValidationError as e:
+ _msg_err_create(message, e)
+ return
- description = message.message.text_message.text.strip()
context.update_data({"event_description": description})
bot.messaging.send_message(
peer=message.peer,
@@ -630,20 +687,6 @@ def event_create_description_step(message: UpdateMessage, context: FSMContext):
context.set_state(EventCreateState.hours)
-def _validate_hours_amount(text) -> int: # throws ValueError
- if not isinstance(text, int) and not isinstance(text, str):
- logger.error("expected type str or int for hours amount, got '%s'", type(text))
- raise ValueError(f"expected type str or int for hours amount, got {type(text)!r}")
- text = str(text)
- if not re.fullmatch(r"^[0-9]{1,2}$", text):
- logger.error("expected <= 2 long digit sequence for hours amount, got '%s'", text)
- raise ValueError(f"expected <= 2 long digit sequence for hours amount, got {text!r}")
- hours = int(text)
- if hours <= 0 or hours >= 19:
- raise ValueError(f"hours value must be positive integer <= 18, got {hours!r}")
- return hours
-
-
@events_rt.message(state=EventCreateState.hours)
@bot.di
def event_create_hours_step(
@@ -652,16 +695,10 @@ def event_create_hours_step(
tag_service: TagService,
):
delete_prev_message(bot, message)
-
- text = message.message.text_message.text.strip()
try:
- hours = _validate_hours_amount(text)
- except ValueError:
- bot.messaging.send_message(
- peer=message.peer,
- text="⚠️ Количество часов должно быть натуральным числом, меньшим 19. Попробуй ещё раз.",
- interactive_media_groups=back_to_moderation_keyboard(),
- )
+ hours = validate_hours(message.message.text_message.text.strip())
+ except EventValidationError as e:
+ _msg_err_create(message, e)
return
context.update_data({"hours": hours})
@@ -719,7 +756,10 @@ def event_tags_toggle_handler(
context.update_data({"event_app_tag_ids": app_tag_ids})
bot.messaging.send_message(
peer=event.peer,
- text="Шаг 10/11: введи код мероприятия:\nБез пробелов, не больше 16 символов. Например: WELCOME2025",
+ text=_err(
+ "verification_code",
+ "Шаг 10/11: введи код мероприятия:\nБез пробелов, от {min_len} до {max_len} символов. Например: WELCOME2025",
+ ),
interactive_media_groups=back_to_moderation_keyboard(),
)
context.set_state(EventCreateState.code)
@@ -840,40 +880,6 @@ def event_tags_toggle_handler(
return
-def _validate_verification_code(text) -> str: # throws ValueError
- if not isinstance(text, str):
- logger.error("expected type str for verification code, got '%s'", type(text))
- raise ValueError(f"expected type str for verification code, got {type(text)!r}")
- if not re.fullmatch(r"^[A-Za-zА-Яа-я0-9]{1,16}$", text):
- logger.error("unsupported verification code format: '%s'", text)
- raise ValueError(f"unsupported verification code format: {text!r}")
- return text
-
-
-def _validate_participation_limit(value) -> int: # throws ValueError
- try:
- limit = int(value)
- except (TypeError, ValueError) as e:
- logger.error(
- "expected int or numeric string for participation limit, got '%s', which has type '%s'",
- value,
- type(value),
- )
- raise ValueError(
- f"expected int or numeric string for participation limit, got {value!r}, which has type {type(value)!r}"
- ) from e
-
- if limit < 1:
- logger.error("participation limit must be positive integer, got '%d'", limit)
- raise ValueError(f"participation limit must be positive integer, got {limit!r}")
-
- if limit > 10000:
- logger.error("participation limit is too high: %d", limit)
- raise ValueError(f"participation limit is too high: {limit}")
-
- return limit
-
-
@events_rt.message(state=EventCreateState.code)
@bot.di
def event_create_code_step(
@@ -885,25 +891,20 @@ def event_create_code_step(
user: UserSchema,
):
delete_prev_message(bot, message)
-
try:
- code = _validate_verification_code(message.message.text_message.text.strip())
- except ValueError:
- bot.messaging.send_message(
- peer=message.peer,
- text=(
- "⚠️ Код должен быть комбинацией русских и латинских букв и цифр, "
- "длиной не больше 16 символов. Попробуй еще раз:"
- ),
- interactive_media_groups=back_to_moderation_keyboard(),
- )
+ code = validate_verification_code(message.message.text_message.text.strip())
+ except EventValidationError as e:
+ _msg_err_create(message, e)
return
context.update_data({"event_verification_code": code})
bot.messaging.send_message(
peer=message.peer,
- text="Шаг 11/11: введи требуемое количество участников:\nПусть это будет число от 1 до 10000",
+ text=_err( # 🔹 адаптивный текст через _err
+ "participation_limit",
+ "Шаг 11/11: введи требуемое количество участников:\nПусть это будет число от {min} до {max}",
+ ),
interactive_media_groups=back_to_moderation_keyboard(),
)
@@ -921,15 +922,10 @@ def event_create_participation_limit_step(
user: UserSchema,
):
delete_prev_message(bot, message)
-
try:
- limit = _validate_participation_limit(message.message.text_message.text.strip())
- except ValueError:
- bot.messaging.send_message(
- peer=message.peer,
- text=("⚠️ Количество участников должно быть числом от 1 до 10000. Попробуй еще раз:"),
- interactive_media_groups=back_to_moderation_keyboard(),
- )
+ limit = validate_participation_limit(message.message.text_message.text.strip())
+ except EventValidationError as e:
+ _msg_err_create(message, e)
return
context.update_data({"event_participation_limit": limit})
@@ -1415,7 +1411,15 @@ def event_menu_edit_field_handler(
elif field == "time":
hint = "*Введи время в формате* `HH:MM`, например: `10:30`"
elif field == "participation_limit":
- hint = "*Введи максимальное количество участников* от 1 до 10000"
+ hint = _err("participation_limit", "*Введи число от {min} до {max}*")
+ elif field == "hours":
+ hint = _err("hours", "*Введи число от {min} до {max}*")
+ elif field == "title":
+ hint = _err("title", "*Введи от {min_len} до {max_len} символов*")
+ elif field == "code":
+ hint = _err("verification_code", "*От {min_len} до {max_len} символов, буквы и цифры*")
+ elif field == "description":
+ hint = _err("description", "*Максимум {max_len} символов*")
else:
hint = "*Введи новое значение.* Чтобы очистить поле - отправь: «-»"
@@ -1469,65 +1473,50 @@ def event_edit_value_step(
if field == "hours":
try:
- value = _validate_hours_amount(value)
- except ValueError:
- bot.messaging.send_message(
- peer=message.peer,
- text="⚠️ Количество часов должно быть натуральным числом, меньшим 19. Попробуй ещё раз.",
- interactive_media_groups=build_back_to_event_editing_keyboard(
- event_id=event_id,
- ctx_name=ctx_name,
- ),
- )
+ value = validate_hours(value)
+ except EventValidationError as e:
+ _msg_err_edit(message, e, event_id, ctx_name)
return
if field == "participation_limit":
try:
- value = _validate_participation_limit(value)
- except ValueError:
- bot.messaging.send_message(
- peer=message.peer,
- text=(
- "⚠️ Количество участников должно быть числом от 1 до 10000. Попробуй еще раз:"
- ),
- interactive_media_groups=build_back_to_event_editing_keyboard(
- event_id=event_id,
- ctx_name=ctx_name,
- ),
- )
+ value = validate_participation_limit(value)
+ except EventValidationError as e:
+ _msg_err_edit(message, e, event_id, ctx_name)
return
if field == "code":
try:
- value = _validate_verification_code(value)
- except ValueError:
- bot.messaging.send_message(
- peer=message.peer,
- text=(
- "⚠️ Код должен быть комбинацией русских и латинских букв и цифр, "
- "длиной не больше 16 символов. Попробуй еще раз:"
- ),
- interactive_media_groups=build_back_to_event_editing_keyboard(
- event_id=event_id,
- ctx_name=ctx_name,
- ),
- )
+ value = validate_verification_code(str(value))
+ except EventValidationError as e:
+ _msg_err_edit(message, e, event_id, ctx_name)
+ return
+
+ if field == "title":
+ try:
+ value = validate_title(str(value))
+ except EventValidationError as e:
+ _msg_err_edit(message, e, event_id, ctx_name)
+ return
+
+ if field == "description":
+ try:
+ value = validate_description(str(value))
+ except EventValidationError as e:
+ _msg_err_edit(message, e, event_id, ctx_name)
return
if field == "date":
try:
- date_obj = _validate_date(str(value))
- except ValueError:
+ date_obj = validate_date(str(value))
+ except EventValidationError as e:
+ _msg_err_edit(message, e, event_id, ctx_name)
+ return
+ if date_obj <= datetime.now().date():
bot.messaging.send_message(
peer=message.peer,
- text=(
- "⚠️ Я не смог распознать дату.\n"
- "Пожалуйста, введи дату в формате `dd.mm.yyyy`, например: `20.02.2002`:"
- ),
- interactive_media_groups=build_back_to_event_editing_keyboard(
- event_id=event_id,
- ctx_name=ctx_name,
- ),
+ text="⚠️ Ты не можешь создать мероприятие раньше завтрашнего дня.\nПожалуйста, введи дату, например, в формате `20.02.2002`:",
+ interactive_media_groups=back_to_moderation_keyboard(),
)
return
@@ -1556,19 +1545,9 @@ def event_edit_value_step(
if field == "time":
try:
- time_obj = _validate_time(str(value))
- except ValueError:
- bot.messaging.send_message(
- peer=message.peer,
- text=(
- "⚠️ Я не смог распознать время.\n"
- "Пожалуйста, введи в формате `HH:MM`, например: `10:30`:"
- ),
- interactive_media_groups=build_back_to_event_editing_keyboard(
- event_id=event_id,
- ctx_name=ctx_name,
- ),
- )
+ time_obj = validate_time(str(value))
+ except EventValidationError as e:
+ _msg_err_edit(message, e, event_id, ctx_name)
return
current_event_card = event_service.get_event_by_id(event_id, message.peer.id)
diff --git a/services/bot/core/handlers/report.py b/services/bot/core/handlers/report.py
index 2b35302..5834416 100644
--- a/services/bot/core/handlers/report.py
+++ b/services/bot/core/handlers/report.py
@@ -327,10 +327,10 @@ def report_create_beneficiaries_step(message: UpdateMessage, context: FSMContext
text = message.message.text_message.text.strip()
try:
if not re.fullmatch(r"^[0-9]{1,9}$", text):
- raise ValueError
+ raise ValueError()
val = int(text)
if val < 0 or val >= 2**31:
- raise ValueError
+ raise ValueError()
except ValueError:
bot.messaging.send_message(
peer=message.peer,
diff --git a/services/bot/core/handlers/validators.py b/services/bot/core/handlers/validators.py
new file mode 100644
index 0000000..86ea8de
--- /dev/null
+++ b/services/bot/core/handlers/validators.py
@@ -0,0 +1,142 @@
+import re
+from datetime import datetime
+from typing import TypeVar
+
+from core.config import validation_cfg
+
+T = TypeVar("T")
+
+
+class EventValidationError(ValueError): ...
+
+
+class TitleOutOfRange(EventValidationError): ...
+
+
+class DescriptionOutOfRange(EventValidationError): ...
+
+
+class UnsupportedDateFormat(EventValidationError): ...
+
+
+class UnsupportedTimeFormat(EventValidationError): ...
+
+
+class HoursInvalidFormat(EventValidationError): ...
+
+
+class HoursOutOfRange(EventValidationError): ...
+
+
+class VerificationCodeInvalidFormat(EventValidationError): ...
+
+
+class VerificationCodeOutOfRange(EventValidationError): ...
+
+
+class ParticipationLimitInvalidFormat(EventValidationError): ...
+
+
+class ParticipationLimitOutOfRange(EventValidationError): ...
+
+
+def _get_rule(field: str, rule: str, default: T, excepted_type: type[T]) -> T:
+ value = validation_cfg.get(f"event.{field}.{rule}", default)
+
+ if not isinstance(value, excepted_type):
+ raise RuntimeError(
+ f"validation rule event.{field}.{rule} must be {excepted_type}, got {type(value)}"
+ )
+ return value
+
+
+def validate_title(text: str):
+ min_len = _get_rule("title", "min_length", 4, int)
+ max_len = _get_rule("title", "max_length", 20, int)
+
+ if len(text) < min_len or len(text) > max_len:
+ raise TitleOutOfRange()
+ return text
+
+
+def validate_description(text: str):
+ max_len = _get_rule("description", "max_length", 20, int)
+
+ if len(text) > max_len:
+ raise DescriptionOutOfRange()
+ return text
+
+
+def validate_date(text: str):
+ """Парсинг даты с поддержкой разделителей . - _ . Возвращает date."""
+ formats = []
+ for first_gap in ". -_":
+ for second_gap in ". -_":
+ formats.append(f"%d{first_gap}%m{second_gap}%Y")
+
+ date_obj = None
+ for fmt in formats:
+ try:
+ date_obj = datetime.strptime(text.strip(), fmt).date()
+ break
+ except ValueError:
+ continue
+
+ if date_obj is None:
+ raise UnsupportedDateFormat()
+ return date_obj
+
+
+def validate_time(text: str):
+ time_obj = None
+ for fmt in ["%H:%M", "%H %M", "%H-%M", "%H_%M"]:
+ try:
+ time_obj = datetime.strptime(text.strip(), fmt).time()
+ break
+ except ValueError:
+ continue
+
+ if time_obj is None:
+ raise UnsupportedTimeFormat()
+ return time_obj
+
+
+def validate_hours(text: str | int):
+ """Валидация часов: 1-2 цифры, диапазон 1-18 (из YAML)."""
+ text = str(text).strip()
+ if not re.fullmatch(r"^[0-9]{1,2}$", text):
+ raise HoursInvalidFormat()
+
+ hours = int(text)
+ min_val = _get_rule("hours", "min", 1, int)
+ max_val = _get_rule("hours", "max", 18, int)
+
+ if hours < min_val or hours > max_val:
+ raise HoursOutOfRange()
+ return hours
+
+
+def validate_verification_code(text: str):
+ if not re.fullmatch(r"^[A-Za-zА-Яа-я0-9]+$", text):
+ raise VerificationCodeInvalidFormat()
+
+ min_len = _get_rule("verification_code", "min_length", 4, int)
+ max_len = _get_rule("verification_code", "max_length", 20, int)
+
+ if len(text) < min_len or len(text) > max_len:
+ raise VerificationCodeOutOfRange()
+ return text
+
+
+def validate_participation_limit(value: str | int):
+ try:
+ limit = int(value)
+ except (TypeError, ValueError) as e:
+ raise ParticipationLimitInvalidFormat from e
+
+ min_val = _get_rule("participation_limit", "min", 1, int)
+ max_val = _get_rule("participation_limit", "max", 1000, int)
+
+ if limit < min_val or limit > max_val:
+ raise ParticipationLimitOutOfRange()
+ return limit
а вот файлы, изменённые в этой ветке:
f=services/bot/core/config/__init__.py
f=services/bot/core/config/validation_config.py
f=services/bot/core/handlers/admin.py
f=services/bot/core/handlers/moderation.py
f=services/bot/core/handlers/report.py
--- services/bot/core/config/__init__.py ---
from .bootstrap import BotWithDI, bot
from .settings import bot_config, settings
from .validation_config import validation_cfg
__all__ = [
"BotWithDI",
"bot",
"bot_config",
"settings",
"validation_cfg",
]
--- services/bot/core/config/validation_config.py ---
import os
import yaml
class ValidationConfig:
def __init__(self, path: str = "/bot/validation.yml"):
if not os.path.exists(path):
raise FileNotFoundError(f"Файл не найден: {path}")
try:
with open(path, encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
except yaml.YAMLError as e:
raise ValueError(f"Ошибка парсинга YAML: {e}") from e
self._data = self._cast_numeric_rules(raw)
def _cast_numeric_rules(self, data: dict) -> dict:
result = {}
for k, v in data.items():
if isinstance(v, dict):
result[k] = self._cast_numeric_rules(v)
elif k in ("min", "max", "min_length", "max_length"):
try:
result[k] = int(v)
except (TypeError, ValueError):
result[k] = v
else:
result[k] = v
return result
def get(self, key: str, default=None):
# Если ключ не начинается с известного корневого раздела — добавляем event
if not any(key.startswith(prefix) for prefix in ("event.", "event")):
key = f"event.{key}"
val = self._data
for part in key.split("."):
if isinstance(val, dict) and part in val:
val = val[part]
else:
return default
return val
def __getitem__(self, key: str):
"""
- cfg["title.min_length"] → event.title.min_length
- cfg["event.hours.min"] → event.hours.min
- cfg["event"]["title"] → прямой доступ к вложенному словарю
"""
if "." in key:
path = key
if not any(path.startswith(p) for p in ("event.", "event")):
path = f"event.{path}"
val = self._data
for part in path.split("."):
if isinstance(val, dict) and part in val:
val = val[part]
else:
raise KeyError(f"Ключ не найден: {key}")
return val
if key in self._data:
return self._data[key]
raise KeyError(f"Ключ не найден: {key}")
def __contains__(self, key: str) -> bool:
try:
self[key]
return True
except KeyError:
return False
# Глобальный инстанс
validation_cfg = ValidationConfig()
--- services/bot/core/handlers/admin.py ---
from dialog_bot_sdk.entities.messaging import UpdateInteractiveMediaEvent, UpdateMessage
from core.bot_kit.fsm import FSMContext, State, StatesGroup
from core.bot_kit.router import Router
from core.config import bot
from core.markups import admin_menu_keyboard, back_to_admin_menu_keyboard
from core.services import AdminService
from core.utils import delete_prev_message, delete_prev_message_by_peer
admin_rt = Router()
class ManageModeratorState(StatesGroup):
set_wait_value = State()
del_wait_value = State()
def admin_menu_handler(event: UpdateInteractiveMediaEvent):
delete_prev_message_by_peer(bot, event.peer)
bot.messaging.send_message(
peer=event.peer,
text="Ты в доме администрации!\n\nПожалуйста, выбери, что хочешь сделать 👇",
interactive_media_groups=admin_menu_keyboard(),
)
@bot.di
def set_moderator_handler(
event: UpdateInteractiveMediaEvent,
context: FSMContext,
):
delete_prev_message_by_peer(bot, event.peer)
bot.messaging.send_message(
peer=event.peer,
text="Введи `messenger_id` модератора, которого хочешь назначить и `tb_id` (через пробел), в который ты хочешь его назначить.",
interactive_media_groups=back_to_admin_menu_keyboard(),
)
context.set_state(ManageModeratorState.set_wait_value)
@admin_rt.message(state=ManageModeratorState.set_wait_value)
@bot.di
def set_moderator_value_handler(
message: UpdateMessage,
context: FSMContext,
admin_service: AdminService,
):
delete_prev_message(bot, message)
text = message.message.text_message.text.strip()
try:
moderator_id, tb_id = map(str, text.split(maxsplit=1))
if " " in moderator_id + tb_id:
raise RuntimeError()
moderator_id = int(moderator_id)
except Exception:
bot.messaging.send_message(
peer=message.peer,
text="⚠️ Нужно ввести два значения через пробел: `messenger_id` модератора, которого хочешь назначить и `tb_id`, в который ты хочешь его назначить. Попробуй ещё раз.",
interactive_media_groups=back_to_admin_menu_keyboard(),
)
return
try:
admin_service.set_moderator(
messenger_id=message.peer.id,
tb_id=tb_id,
moderator_id=moderator_id,
)
except Exception as e:
bot.messaging.send_message(
peer=message.peer,
text=f"⚠️ Не удалось назначить модератора. Ошибка: {e}",
interactive_media_groups=back_to_admin_menu_keyboard(),
)
bot.messaging.send_message(
peer=message.peer,
text="✅ Модератор успешно назначен!",
interactive_media_groups=back_to_admin_menu_keyboard(),
)
context.set_state(None)
@bot.di
def del_moderator_handler(
event: UpdateInteractiveMediaEvent,
context: FSMContext,
):
delete_prev_message_by_peer(bot, event.peer)
bot.messaging.send_message(
peer=event.peer,
text="Введи `messenger_id` модератора, которого хочешь снять с должности и `tb_id` (через пробел), в который в котором ты хочешь его снять с должности.",
interactive_media_groups=back_to_admin_menu_keyboard(),
)
context.set_state(ManageModeratorState.del_wait_value)
@admin_rt.message(state=ManageModeratorState.del_wait_value)
@bot.di
def del_moderator_value_handler(
message: UpdateMessage,
context: FSMContext,
admin_service: AdminService,
):
delete_prev_message(bot, message)
text = message.message.text_message.text.strip()
try:
moderator_id, tb_id = map(str, text.split(maxsplit=1))
if " " in moderator_id + tb_id:
raise RuntimeError()
moderator_id = int(moderator_id)
except Exception:
bot.messaging.send_message(
peer=message.peer,
text="⚠️ Нужно ввести два значения через пробел: `messenger_id` модератора, которого хочешь снять с должности и `tb_id`, в который ты хочешь его снять с должности. Попробуй ещё раз.",
interactive_media_groups=back_to_admin_menu_keyboard(),
)
return
try:
admin_service.del_moderator(
messenger_id=message.peer.id,
tb_id=tb_id,
moderator_id=moderator_id,
)
except Exception as e:
bot.messaging.send_message(
peer=message.peer,
text=f"⚠️ Не удалось снять с должности модератора. Ошибка: {e}",
interactive_media_groups=back_to_admin_menu_keyboard(),
)
bot.messaging.send_message(
peer=message.peer,
text="✅ Модератор успешно снят!",
interactive_media_groups=back_to_admin_menu_keyboard(),
)
context.set_state(None)
--- services/bot/core/handlers/moderation.py ---
from datetime import datetime
from dialog_bot_sdk.entities.messaging import UpdateInteractiveMediaEvent, UpdateMessage
from dialog_bot_sdk.interactive_media import Button
from core.bot_kit.fsm import FSMContext, State, StatesGroup
from core.bot_kit.router import Router
from core.config import bot, validation_cfg
from core.handlers.events_ui import (
build_back_to_event_editing_keyboard,
build_back_to_event_keyboard,
get_ui,
send_event_card,
send_event_cards_page,
send_my_events_page,
split_event_value,
)
from core.handlers.request import build_request_actions_keyboard
from core.handlers.validators import (
DescriptionOutOfRange,
EventValidationError,
HoursInvalidFormat,
HoursOutOfRange,
ParticipationLimitInvalidFormat,
ParticipationLimitOutOfRange,
TitleOutOfRange,
UnsupportedDateFormat,
UnsupportedTimeFormat,
VerificationCodeInvalidFormat,
VerificationCodeOutOfRange,
validate_date,
validate_description,
validate_hours,
validate_participation_limit,
validate_time,
validate_title,
validate_verification_code,
)
from core.markups import (
back_to_moderation_and_skip_keyboard,
back_to_moderation_keyboard,
choices_from_backend_tags,
event_edit_fields_keyboard,
format_event_details,
format_request_details,
gosb_select_keyboard,
moderation_menu_keyboard,
tags_toggle_keyboard,
tb_select_keyboard,
)
from core.schemas import EventCardSchema, TagSchema, UserSchema
from core.services import EventService, ReportService, RequestService, TagService, TerbankService
from core.utils import (
clear_context_keep_events_filters,
delete_prev_message,
delete_prev_message_by_peer,
logger,
)
TAGS_MEDIA_ID = "event_tags_toggle"
TAGS_TOGGLE_PREFIX = "toggle:"
TAGS_DONE_VALUE = "done"
# TODO убрать хардкод
# ─────────────────────────────────────────────────────────────
# 🔧 Хелперы для адаптивных сообщений (используют validation_cfg["..."])
# ─────────────────────────────────────────────────────────────
def _cfg(field: str, rule: str, default=None):
"""Короткий доступ: _cfg('title', 'min_length', 4) → validation_cfg["title.min_length"] или default"""
try:
return validation_cfg[f"{field}.{rule}"]
except KeyError:
return default
def _err(field: str, message: str, **kwargs):
"""Форматирует ошибку, подставляя лимиты из YAML: {min}, {max}, {min_len}, {max_len}"""
limits = {
"min": _cfg(field, "min"),
"max": _cfg(field, "max"),
"min_len": _cfg(field, "min_length"),
"max_len": _cfg(field, "max_length"),
}
limits = {k: v for k, v in limits.items() if v is not None}
return message.format(**kwargs, **limits)
def _validation_error_text(error: EventValidationError) -> str:
if isinstance(error, TitleOutOfRange):
return _err(
"title",
"⚠️ Название должно содержать от {min_len} до {max_len} символов. Попробуй ещё раз:",
)
if isinstance(error, DescriptionOutOfRange):
return _err(
"description",
"⚠️ Описание не должно быть длиннее {max_len} символов. Попробуй ещё раз:",
)
if isinstance(error, UnsupportedDateFormat):
return (
"⚠️ Я не смог распознать дату.\n"
"Пожалуйста, введи дату в формате `dd.mm.yyyy`, например: `20.02.2002`:"
)
if isinstance(error, UnsupportedTimeFormat):
return (
"⚠️ Я не смог распознать время.\n"
"Пожалуйста, введи время в формате `HH:MM`, например: `10:30`:"
)
if isinstance(error, (HoursInvalidFormat, HoursOutOfRange)):
return _err(
"hours",
"⚠️ Количество часов должно быть целым числом от {min} до {max}. Попробуй ещё раз:",
)
if isinstance(error, (ParticipationLimitInvalidFormat, ParticipationLimitOutOfRange)):
return _err(
"participation_limit",
"⚠️ Количество участников должно быть целым числом от {min} до {max}. Попробуй ещё раз:",
)
if isinstance(error, VerificationCodeOutOfRange):
return _err(
"verification_code",
"⚠️ Код должен быть длиной от {min_len} до {max_len} символов. Попробуй ещё раз:",
)
if isinstance(error, VerificationCodeInvalidFormat):
return (
"⚠️ Код должен содержать только русские или латинские буквы и цифры. Попробуй ещё раз:"
)
return "⚠️ Некорректное значение. Попробуй ещё раз:"
def _msg_err_create(message, error) -> None:
bot.messaging.send_message(
peer=message.peer,
text=_validation_error_text(error),
interactive_media_groups=back_to_moderation_keyboard(),
)
def _msg_err_edit(
message,
error,
event_id: str | None = None,
ctx_name: str | None = None,
) -> None:
if event_id is None or ctx_name is None:
raise Exception("event_id and ctx_name must be provided for _msg_err_edit helper function")
bot.messaging.send_message(
peer=message.peer,
text=_validation_error_text(error),
interactive_media_groups=build_back_to_event_editing_keyboard(
event_id=event_id,
ctx_name=ctx_name,
),
)
events_rt = Router()
class EventEditState(StatesGroup): # legacy
wait_value = State()
gosb = State()
tags = State()
class EventEnterCodeState(StatesGroup): # legacy
wait_code = State()
class EventViewState(StatesGroup): # legacy
wait_event_id = State()
class EventCreateState(StatesGroup): # legacy
name = State()
date = State()
time = State()
gosb = State()
project = State()
description = State()
hours = State()
tags = State()
code = State()
participation_limit = State()
@bot.di
def moderation_menu_handler(event: UpdateInteractiveMediaEvent, context: FSMContext): # legacy
clear_context_keep_events_filters(context)
delete_prev_message_by_peer(bot, event.peer)
bot.messaging.send_message(
peer=event.peer,
text=("Ты в доме модерации!\n\nПожалуйста, выбери, что хочешь сделать 👇"),
interactive_media_groups=moderation_menu_keyboard(),
)
@bot.di
def all_events_handler( # legacy
event: UpdateInteractiveMediaEvent,
context: FSMContext,
event_service: EventService,
):
delete_prev_message_by_peer(bot, event.peer)
clear_context_keep_events_filters(context)
send_event_cards_page(
event.peer,
offset=0,
event_service=event_service,
ctx_name="moderation",
context=context,
)
@bot.di
def my_events_handler( # legacy
event: UpdateInteractiveMediaEvent,
context: FSMContext,
event_service: EventService,
):
delete_prev_message_by_peer(bot, event.peer)
clear_context_keep_events_filters(context)
send_my_events_page(
event.peer,
offset=0,
event_service=event_service,
requester_messenger_id=event.peer.id,
ctx_name="my_events",
context=context,
)
@bot.di
def my_events_page_handler( # legacy
event: UpdateInteractiveMediaEvent,
context: FSMContext,
event_service: EventService,
):
delete_prev_message_by_peer(bot, event.peer)
clear_context_keep_events_filters(context)
try:
offset = int(event.data.value)
if offset < 0:
offset = 0
except Exception:
offset = 0
send_my_events_page(
event.peer,
offset=offset,
event_service=event_service,
requester_messenger_id=event.peer.id,
ctx_name="my_events",
context=context,
)
@bot.di
def my_events_open_handler( # legacy
event: UpdateInteractiveMediaEvent,
context: FSMContext,
event_service: EventService,
report_service: ReportService,
user: UserSchema | None,
):
delete_prev_message_by_peer(bot, event.peer)
context.set_state(None)
event_id, ctx_name = split_event_value(event.data.value)
send_event_card(
event.peer,
event_id=event_id,
event_service=event_service,
user=user,
ctx_name=ctx_name or "my_events",
report_service=report_service,
)
@bot.di
def all_events_page_handler( # legacy
event: UpdateInteractiveMediaEvent,
context: FSMContext,
event_service: EventService,
):
delete_prev_message_by_peer(bot, event.peer)
clear_context_keep_events_filters(context)
try:
offset = int(event.data.value)
if offset < 0:
offset = 0
except Exception:
offset = 0
send_event_cards_page(
event.peer,
offset=offset,
event_service=event_service,
ctx_name="moderation",
context=context,
)
@bot.di
def all_events_open_handler( # legacy
event: UpdateInteractiveMediaEvent,
context: FSMContext,
event_service: EventService,
report_service: ReportService,
user: UserSchema | None,
):
delete_prev_message_by_peer(bot, event.peer)
context.set_state(None)
event_id, ctx = split_event_value(event.data.value)
ctx_name = ctx or "moderation"
send_event_card(
event.peer,
event_id=event_id,
event_service=event_service,
user=user,
ctx_name=ctx_name,
report_service=report_service,
)
@bot.di
def create_event_start_handler(
event: UpdateInteractiveMediaEvent,
context: FSMContext,
):
"""старт мастера создания мероприятия"""
delete_prev_message_by_peer(bot, event.peer)
context.clear()
bot.messaging.send_message(
peer=event.peer,
text="Создание мероприятия.\n\nШаг 1/11: введи, пожалуйста, название мероприятия:",
interactive_media_groups=back_to_moderation_keyboard(),
)
context.set_state(EventCreateState.name)
@events_rt.message(state=EventCreateState.name)
@bot.di
def event_create_name_step(message: UpdateMessage, context: FSMContext):
delete_prev_message(bot, message)
try:
name = validate_title(message.message.text_message.text.strip())
except EventValidationError as e:
_msg_err_create(message, e)
return
context.update_data({"event_name": name})
bot.messaging.send_message(
peer=message.peer,
text="Шаг 2/11: введи, пожалуйста, дату мероприятия например, в формате `20.02.2002`:",
interactive_media_groups=back_to_moderation_keyboard(),
)
context.set_state(EventCreateState.date)
@events_rt.message(state=EventCreateState.date)
@bot.di
def event_create_date_step(message: UpdateMessage, context: FSMContext):
delete_prev_message(bot, message)
try:
date_obj = validate_date(message.message.text_message.text.strip())
except EventValidationError as e:
_msg_err_create(message, e)
return
if date_obj <= datetime.now().date():
bot.messaging.send_message(
peer=message.peer,
text="⚠️ Ты не можешь создать мероприятие раньше завтрашнего дня.\nПожалуйста, введи дату, например, в формате `20.02.2002`:",
interactive_media_groups=back_to_moderation_keyboard(),
)
return
context.update_data({"event_date": date_obj})
bot.messaging.send_message(
peer=message.peer,
text="Шаг 3/11: введи, пожалуйста, время мероприятия например, в формате `10:30`:",
interactive_media_groups=back_to_moderation_keyboard(),
)
context.set_state(EventCreateState.time)
@events_rt.message(state=EventCreateState.time)
@bot.di
def event_create_time_step(
message: UpdateMessage, context: FSMContext, terbank_service: TerbankService
):
delete_prev_message(bot, message)
try:
time_obj = validate_time(message.message.text_message.text.strip())
except EventValidationError as e:
_msg_err_create(message, e)
return
context.update_data({"event_time": time_obj})
send_tb_select_menu(
peer=message.peer, terbank_service=terbank_service, context=context, is_edit=False
)
def _tb_select_footer_buttons(
*,
is_edit: bool,
event_id: str | None,
ctx_name: str,
) -> list[Button]:
if is_edit:
return [
Button(
media_id="event_menu_edit",
value=f"{event_id}|{ctx_name}",
label="⬅️ К редактированию мероприятия",
)
]
return [
Button(
media_id="event_create_back_to_time",
value="1",
label="⬅️ Назад",
)
]
@bot.di
def event_create_back_to_time_handler(event: UpdateInteractiveMediaEvent, context: FSMContext):
delete_prev_message_by_peer(bot, event.peer)
bot.messaging.send_message(
peer=event.peer,
text="Шаг 3/11: введи, пожалуйста, время мероприятия например, в формате `10:30`:",
interactive_media_groups=back_to_moderation_keyboard(),
)
context.set_state(EventCreateState.time)
def send_tb_select_menu(
*,
peer,
terbank_service: TerbankService,
context: FSMContext,
is_edit: bool,
event_id: str | None = None,
ctx_name: str = "moderation",
default_tb_id: str | None = None,
default_gosb_id: str | None = None,
):
saved_event_id = event_id or context.get_data().get("edit_event_id")
if is_edit and not saved_event_id:
logger.error("error while editing gosb: edit_event_id is empty before tb select")
bot.messaging.send_message(
peer=peer,
text="⚠️ Сессия редактирования сброшена. Открой мероприятие заново.",
interactive_media_groups=back_to_moderation_keyboard(),
)
context.clear()
return
context.update_data(
{
"tb_id": default_tb_id or "",
"gosb_id": default_gosb_id or "",
"edit_current_tb_id": default_tb_id or "",
"edit_current_gosb_id": default_gosb_id or "",
"edit_event_id": saved_event_id,
"edit_event_ctx": ctx_name,
}
)
if is_edit:
context.set_state(EventEditState.gosb)
title = "Редактирование ГОСБ: сначала выбери тербанк:"
else:
context.set_state(EventCreateState.gosb)
title = "Шаг 4/11: выбери тербанк:"
tbs = terbank_service.get_all_tbs(peer.id)
bot.messaging.send_message(
peer=peer,
text=title,
interactive_media_groups=tb_select_keyboard(
terbanks=tbs,
default_value=default_tb_id,
footer_buttons=_tb_select_footer_buttons(
is_edit=is_edit,
event_id=saved_event_id,
ctx_name=ctx_name,
),
),
)
@bot.di
def tb_reselect_handler(
event: UpdateInteractiveMediaEvent,
context: FSMContext,
terbank_service: TerbankService,
):
delete_prev_message_by_peer(bot, event.peer)
data = context.get_data()
send_tb_select_menu(
peer=event.peer,
terbank_service=terbank_service,
context=context,
is_edit=_is_equal_state(context, EventEditState.gosb),
event_id=data.get("edit_event_id"),
ctx_name=data.get("edit_event_ctx", "moderation"),
)
@bot.di
def tb_update_selected(event: UpdateInteractiveMediaEvent, context: FSMContext):
# keeping what user have selected in "tb_id"
context.update_data({"tb_id": event.data.value})
@bot.di
def tb_approved(
event: UpdateInteractiveMediaEvent,
terbank_service: TerbankService,
context: FSMContext,
):
# tb was selected and approved. now selecting gosb:
delete_prev_message_by_peer(bot, event.peer)
selected_tb_id = context.get_data().get("tb_id")
if not isinstance(selected_tb_id, str) or not selected_tb_id:
logger.error(
"error while creating event: error while selecting tb: selected tb must be a string, got '%s'",
selected_tb_id,
)
bot.messaging.send_message(
peer=event.peer,
text="⚠️ Произошла ошибка. Попробуй позже.",
)
context.clear()
return
gosbs = terbank_service.get_gosbs_for_tb(tb_id=selected_tb_id, messenger_id=event.peer.id)
is_edit = _is_equal_state(context, EventEditState.gosb)
data = context.get_data()
default_gosb_id = data.get("edit_current_gosb_id")
bot.messaging.send_message(
peer=event.peer,
text="Редактирование ГОСБ: выбери новый ГОСБ" if is_edit else "Шаг 5/11: Выбери ГОСБ",
interactive_media_groups=gosb_select_keyboard(gosbs, default_value=default_gosb_id),
)
@bot.di
def gosb_update_selected(event: UpdateInteractiveMediaEvent, context: FSMContext):
# keeping what user have selected in "gosb_id"
context.update_data({"gosb_id": event.data.value})
@bot.di
def gosb_approved(
event: UpdateInteractiveMediaEvent,
context: FSMContext,
event_service: EventService,
):
# gosb was selected and approved.
delete_prev_message_by_peer(bot, event.peer)
selected_gosb_id = context.get_data().get("gosb_id")
if not isinstance(selected_gosb_id, str) or not selected_gosb_id:
logger.error(
"error while creating event: error while selecting gosb: selected gosb must be a string, got '%s'",
selected_gosb_id,
)
bot.messaging.send_message(
peer=event.peer,
text="⚠️ Произошла ошибка. Попробуй позже.",
)
context.clear()
return
data = context.get_data()
is_edit = _is_equal_state(context, EventEditState.gosb)
if is_edit:
event_id = str(data.get("edit_event_id") or "")
ctx_name = str(data.get("edit_event_ctx") or "moderation")
if not event_id:
logger.error("error while editing gosb: edit_event_id is empty")
bot.messaging.send_message(
peer=event.peer,
text="⚠️ Сессия редактирования сброшена. Открой мероприятие заново.",
interactive_media_groups=back_to_moderation_keyboard(),
)
context.clear()
return
updated = event_service.update_event(
event_id=event_id,
messenger_id=event.peer.id,
payload={"gosb_id": selected_gosb_id},
)
context.set_state(None)
_send_event_edit_menu(
event.peer,
event_id=event_id,
event_service=event_service,
note="✅ ГОСБ успешно изменён." if updated else "⚠️ Не удалось изменить ГОСБ.",
event_obj=event_service.get_event_by_id(event_id, event.peer.id),
show_organizer=True,
ctx_name=ctx_name,
)
else:
bot.messaging.send_message(
peer=event.peer,
text=(
"Шаг 6/11: Введи id проекта, если хочешь создать мероприятие в проекте\n"
"Введи «-» или нажми «пропустить», если хочешь создать событийное мероприятие - мероприятие вне проекта."
),
interactive_media_groups=back_to_moderation_and_skip_keyboard(),
)
context.set_state(EventCreateState.project)
@events_rt.message(state=EventCreateState.project)
@bot.di
def event_create_project_step(message: UpdateMessage, context: FSMContext):
delete_prev_message(bot, message)
raw = message.message.text_message.text.strip()
_handle_project_input(message.peer, raw, context)
@bot.di
def project_input_dash_handler(event: UpdateInteractiveMediaEvent, context: FSMContext):
delete_prev_message_by_peer(bot, event.peer)
_handle_project_input(event.peer, "-", context)
def _handle_project_input(peer, raw_text: str, context: FSMContext):
project_id = "" if raw_text == "-" else raw_text
context.update_data({"project_id": project_id})
bot.messaging.send_message(
peer=peer,
text="Шаг 7/11: Введи описание:",
interactive_media_groups=back_to_moderation_keyboard(),
)
context.set_state(EventCreateState.description)
@events_rt.message(state=EventCreateState.description)
@bot.di
def event_create_description_step(message: UpdateMessage, context: FSMContext):
delete_prev_message(bot, message)
try:
description = validate_description(message.message.text_message.text.strip())
except EventValidationError as e:
_msg_err_create(message, e)
return
context.update_data({"event_description": description})
bot.messaging.send_message(
peer=message.peer,
text="Шаг 8/11: Введи количество волонтёрских часов, начисляемых за мероприятие:",
interactive_media_groups=back_to_moderation_keyboard(),
)
context.set_state(EventCreateState.hours)
@events_rt.message(state=EventCreateState.hours)
@bot.di
def event_create_hours_step(
message: UpdateMessage,
context: FSMContext,
tag_service: TagService,
):
delete_prev_message(bot, message)
try:
hours = validate_hours(message.message.text_message.text.strip())
except EventValidationError as e:
_msg_err_create(message, e)
return
context.update_data({"hours": hours})
all_tags = tag_service.get_all_tags()
choices = choices_from_backend_tags(all_tags)
context.update_data(
{
"event_tag_keys": [],
"all_tags": [{"id": t.id, "title": t.title} for t in all_tags],
},
)
_send_tags_toggle_ui(
peer=message.peer,
title="Шаг 9/11: выбери направления, которым соответствует мероприятие, затем нажми «Готово»:",
choices=choices,
selected_keys=set(),
footer_buttons=[Button(media_id="leave", value="moderation", label="⬅️ В меню модерации")],
)
context.set_state(EventCreateState.tags)
@bot.di
def event_tags_toggle_handler(
event: UpdateInteractiveMediaEvent,
event_service: EventService,
context: FSMContext,
):
is_create = _is_equal_state(context, EventCreateState.tags)
is_edit = _is_equal_state(context, EventEditState.tags)
if not (is_create or is_edit):
return
raw = str(event.data.value or "")
data = context.get_data()
ctx_name = str(data.get("edit_event_ctx", "moderation"))
key_field = "event_tag_keys" if is_create else "edit_tag_keys"
selected = set(data.get(key_field) or [])
all_tags = data.get("all_tags", [])
if not all_tags:
logger.info("No tags were taken while editing event tags. Please check seeding file.")
if raw == TAGS_DONE_VALUE:
title_to_id = {t["title"]: t["id"] for t in all_tags if t.get("title") and t.get("id")}
app_tag_ids = [title_to_id[n] for n in selected if n in title_to_id]
delete_prev_message_by_peer(bot, event.peer)
if is_create:
context.update_data({"event_app_tag_ids": app_tag_ids})
bot.messaging.send_message(
peer=event.peer,
text=_err(
"verification_code",
"Шаг 10/11: введи код мероприятия:\nБез пробелов, от {min_len} до {max_len} символов. Например: WELCOME2025",
),
interactive_media_groups=back_to_moderation_keyboard(),
)
context.set_state(EventCreateState.code)
return
if is_edit:
current = set(data.get("edit_current_tag_keys") or [])
event_id = str(data.get("edit_event_id"))
if not event_id or event_id == "None":
logger.error(
"Error while editing event tags: "
f"no event was found by saved id {event_id!r}, "
"saved in data by the key 'edit_event_id'. "
"exiting editing session"
)
context.set_state(None)
bot.messaging.send_message(
peer=event.peer,
text="⚠️ Сессия редактирования сброшена. Открой мероприятие заново.",
interactive_media_groups=back_to_moderation_keyboard(),
)
return
to_add = selected - current
to_remove = current - selected
failed = False
# add
for title in to_add:
tag_id = title_to_id.get(title)
if not tag_id:
logger.error(
"Error while editing event tags: "
f"selected tag with title={title!r} "
f"have no entrance in all_tags={all_tags}. "
"skipping this tag."
)
elif not event_service.add_tag(
event_id=event_id, tag_id=tag_id, messenger_id=event.peer.id
):
failed = True
# remove
for title in to_remove:
tag_id = title_to_id.get(title)
if not tag_id:
logger.error(
"Error while editing event tags: "
f"selected tag with title={title!r} "
f"have no entrance in all_tags={all_tags}. "
"skipping this tag."
)
elif not event_service.remove_tag(
event_id=event_id, tag_id=tag_id, messenger_id=event.peer.id
):
failed = True
context.set_state(None)
_send_event_edit_menu(
event.peer,
event_id=event_id,
event_service=event_service,
note="⚠️ Ошибка изменения тегов! Некоторые теги могли не измениться."
if failed
else "✅ Теги успешно изменены.",
event_obj=event_service.get_event_by_id(event_id, event.peer.id),
show_organizer=True,
ctx_name=ctx_name,
)
return
if raw.startswith(TAGS_TOGGLE_PREFIX):
key = raw[len(TAGS_TOGGLE_PREFIX) :].strip()
if key in selected:
selected.remove(key)
else:
selected.add(key)
context.update_data({key_field: list(selected)})
delete_prev_message_by_peer(bot, event.peer)
if is_create:
footer = [Button(media_id="leave", value="moderation", label="⬅️ В меню модерации")]
title = "Шаг 9/11: выбери направления, которым соответствует мероприятие, затем нажми «Готово»:"
else:
event_id = data.get("edit_event_id") or ""
footer = [
Button(
media_id="event_menu_edit",
value=f"{event_id}|{ctx_name}",
label="⬅️ К редактированию мероприятия",
)
]
title = "Редактирование направления: выбери направления, которым соответствует мероприятие, затем нажми «Готово»:"
normalized_tags: list[TagSchema] = []
for tag in all_tags:
if isinstance(tag, TagSchema):
normalized_tags.append(tag)
elif isinstance(tag, dict):
normalized_tags.append(TagSchema.from_dict(tag))
choices = choices_from_backend_tags(normalized_tags)
_send_tags_toggle_ui(
peer=event.peer,
title=title,
choices=choices,
selected_keys=set(selected),
footer_buttons=footer,
)
return
@events_rt.message(state=EventCreateState.code)
@bot.di
def event_create_code_step(
message: UpdateMessage,
context: FSMContext,
event_service: EventService,
request_service: RequestService,
report_service: ReportService,
user: UserSchema,
):
delete_prev_message(bot, message)
try:
code = validate_verification_code(message.message.text_message.text.strip())
except EventValidationError as e:
_msg_err_create(message, e)
return
context.update_data({"event_verification_code": code})
bot.messaging.send_message(
peer=message.peer,
text=_err( # 🔹 адаптивный текст через _err
"participation_limit",
"Шаг 11/11: введи требуемое количество участников:\nПусть это будет число от {min} до {max}",
),
interactive_media_groups=back_to_moderation_keyboard(),
)
context.set_state(EventCreateState.participation_limit)
@events_rt.message(state=EventCreateState.participation_limit)
@bot.di
def event_create_participation_limit_step(
message: UpdateMessage,
context: FSMContext,
event_service: EventService,
request_service: RequestService,
report_service: ReportService,
user: UserSchema,
):
delete_prev_message(bot, message)
try:
limit = validate_participation_limit(message.message.text_message.text.strip())
except EventValidationError as e:
_msg_err_create(message, e)
return
context.update_data({"event_participation_limit": limit})
data = context.get_data()
try:
created = event_service.create_from_wizard(data, message.peer)
except Exception as e:
logger.error("Error while creating an event via wizard: %s", e)
bot.messaging.send_message(
peer=message.peer,
text="⚠️ Произошла ошибка.",
interactive_media_groups=back_to_moderation_keyboard(),
)
context.clear()
return
context.clear()
note = {
"REQUEST": "✅ Заявка на мероприятие успешно создана.",
"COMPLETELY": "✅ Мероприятие успешно создано.",
"FAILED": "⚠️ Произошла ошибка: не удалось создать заявку/мероприятие.",
}.get(
created.get("TYPE", ""),
"⚠️ Произошла ошибка: не получить статус заявки/мероприятия. Проверь результат в «Своих заявках» или в «Своих мероприятиях».",
)
result_type = created.get("TYPE")
event_id = str(created.get("event_id", ""))
request_id = str(created.get("request_id", ""))
if result_type == "COMPLETELY":
if not event_id:
logger.error("expected event_id, got none whlie creating event")
bot.messaging.send_message(
peer=message.peer,
text="Мероприятие было создано успешно, но у нас не получилось показать его карточку. Попробуй открыть её из меню мероприятий.",
interactive_media_groups=back_to_moderation_keyboard(),
)
else:
send_event_card(
message.peer,
event_id=event_id,
event_service=event_service,
report_service=report_service,
user=user,
ctx_name="my_events", # TODO fix context
note=note,
)
if result_type == "REQUEST":
if not request_id:
logger.error("expected reequest_id, got none whlie creating request")
bot.messaging.send_message(
peer=message.peer,
text="Заявка на мероприятие была создано успешно, но у нас не получилось показать её карточку. Попробуй открыть её из меню заявок.",
interactive_media_groups=back_to_moderation_keyboard(),
)
else:
request_card = request_service.get_request_by_id(request_id, message.peer.id)
if request_card is not None:
bot.messaging.send_message(
peer=message.peer,
text=f"{note}\n\n{format_request_details(request_card)}",
interactive_media_groups=build_request_actions_keyboard(
request_card,
ctx_name="moderation",
),
)
@bot.di
def event_view_by_id_start(event: UpdateInteractiveMediaEvent, context: FSMContext): # legacy
"""старт диалога - просим у модератора UUID мероприятия"""
delete_prev_message_by_peer(bot, event.peer)
bot.messaging.send_message(
peer=event.peer,
text="Введи UUID мероприятия, которое хочешь посмотреть.\n\n",
interactive_media_groups=back_to_moderation_keyboard(),
)
context.set_state(EventViewState.wait_event_id)
@events_rt.message(state=EventViewState.wait_event_id)
@bot.di
def event_view_by_id_step( # legacy
message: UpdateMessage,
context: FSMContext,
event_service: EventService,
user: UserSchema,
):
"""читаем UUID, тянем мероприятие из бэкенда и показываем информацию"""
delete_prev_message(bot, message)
raw_id = message.message.text_message.text.strip()
event = event_service.get_event_by_id(raw_id, message.peer.id)
if event is None:
bot.messaging.send_message(
peer=message.peer,
text="⚠️ Мероприятие с таким UUID не найдено.\nПопробуй ещё раз. Пример UUID: `932cf1cb-1ee7-4a45-bc96-41a232f3f538`",
interactive_media_groups=back_to_moderation_keyboard(),
)
return
context.update_data({"current_event_id": event.id})
context.set_state(None)
send_event_card(
message.peer,
event_id=event.id,
event_service=event_service,
user=user,
ctx_name="moderation_menu",
)
@bot.di
def event_menu_enter_code_start_handler(
event: UpdateInteractiveMediaEvent, context: FSMContext
): # legacy
delete_prev_message_by_peer(bot, event.peer)
event_id, ctx = split_event_value(event.data.value)
ctx_name = ctx or "moderation"
context.update_data({"current_event_id": event_id, "current_event_ctx": ctx_name})
context.set_state(EventEnterCodeState.wait_code)
bot.messaging.send_message(
peer=event.peer,
text="🎟️ Введи бонус-код мероприятия (без пробелов):",
interactive_media_groups=build_back_to_event_keyboard(event_id, ctx_name),
)
@events_rt.message(state=EventEnterCodeState.wait_code)
@bot.di
def event_menu_enter_code_step(
message: UpdateMessage,
context: FSMContext,
event_service: EventService,
report_service: ReportService,
user: UserSchema,
):
delete_prev_message(bot, message)
event_id = str(context.get_data().get("current_event_id"))
# если event_id is None, то клиент не найдёт event с id == "None", и всё ок
code = message.message.text_message.text.strip()
note: str
status = event_service.get_participation_status(event_id, message.peer.id)
if status is None:
note = "⚠️ Код не принят, сначала нужно записаться на мероприятие"
elif status.state == "confirmed":
note = "⚠️ Код не принят, баллы уже получены"
else:
ok = event_service.enter_code_by_event(event_id, message.peer.id, code)
if not ok:
note = "⚠️ Код не принят, проверь правильность кода"
else:
note = "✅ Код принят. Часы начислены."
data = context.get_data()
ctx_name = data.get("current_event_ctx") or "moderation"
context.set_state(None)
send_event_card(
message.peer,
event_id=event_id,
event_service=event_service,
report_service=report_service,
user=user,
ctx_name=ctx_name,
note=note,
)
@bot.di
def event_menu_delete_handler( # legacy
event: UpdateInteractiveMediaEvent,
context: FSMContext,
event_service: EventService,
report_service: ReportService,
user: UserSchema | None,
):
delete_prev_message_by_peer(bot, event.peer)
context.set_state(None)
event_id, ctx = split_event_value(event.data.value)
ctx_name = ctx or "moderation"
if ctx_name not in ("moderation", "moderation_menu"):
ctx_name = "moderation"
try:
ok = event_service.delete_event(
event_id,
requester_messenger_id=event.peer.id,
)
except Exception:
ok = False
if ok:
context.clear()
if ctx_name == "moderation":
send_event_cards_page(
event.peer,
offset=0,
event_service=event_service,
ctx_name="moderation",
note="Мероприятие удалено.",
)
else:
bot.messaging.send_message(
peer=event.peer,
text="Мероприятие удалено.\n\nТы в доме модерации",
interactive_media_groups=moderation_menu_keyboard(),
)
return
send_event_card(
event.peer,
event_id=event_id,
event_service=event_service,
report_service=report_service,
user=user,
ctx_name=ctx_name,
note="⚠️ Не удалось удалить мероприятие",
)
def _send_event_edit_menu(
peer,
*,
event_id: str,
event_service: EventService,
note: str | None = None,
event_obj=None, # temporary remove type since it was fixed in another branch
show_organizer: bool,
ctx_name: str,
):
ui = get_ui(ctx_name)
refreshed = event_obj or event_service.get_event_by_id(event_id, peer.id)
base = (
format_event_details(refreshed, show_code=True, show_organizer=show_organizer)
if refreshed
else "⚠️ Мероприятие не найдено"
)
prefix = f"{note}\n\n" if note else ""
bot.messaging.send_message(
peer=peer,
text=(f"{prefix}Редактирование мероприятия\n\n{base}\n\nВыбери поле для Редактирования:"),
interactive_media_groups=event_edit_fields_keyboard(
event_id,
leave_media_id=ui.list_open_media_id,
leave_value=f"{event_id}|{ui.name}",
leave_label="⬅️ К мероприятию",
),
)
@bot.di
def event_menu_edit_handler(
event: UpdateInteractiveMediaEvent,
context: FSMContext,
event_service: EventService,
):
delete_prev_message_by_peer(bot, event.peer)
context.set_state(None)
event_id, ctx_name = split_event_value(event.data.value)
ctx_name = "moderation" if ctx_name is None else ctx_name
context.update_data(
{
"edit_event_id": event_id,
"edit_event_ctx": ctx_name,
}
)
_send_event_edit_menu(
event.peer,
event_id=event_id,
event_service=event_service,
show_organizer=True,
ctx_name=ctx_name,
)
def _get_field_label(field: str) -> str:
return {
"title": "Название",
"date": "Дата",
"time": "Время",
"tags": "Теги",
"gosb_id": "ГОСБ",
"event_organizers": "Организаторы",
"participation_limit": "Максимальное количество участников",
"hours": "Часы",
"description": "Описание",
"code": "Код",
}.get(field, field)
def _get_current_value(event_card: EventCardSchema, field: str) -> str:
event_fields = {
# valid fields
"title",
"description",
"gosb_id",
"hours",
"participation_limit",
# TODO rename properly
"verification_code", # from code
"event_organizers", # from creator_id
}
match field:
case "date":
val = event_card.event.date_time.strftime("%d.%m.%Y")
case "time":
val = event_card.event.date_time.strftime("%H:%M")
case "code": # once 'code' is renamed, this will be deleted
val = event_card.event.verification_code
case "creator_id": # once 'creator_id' is renamed, this will be deleted
val = event_card.event.creator_id
case "gosb_id":
val = event_card.gosb.name
case _:
val = (
getattr(event_card.event, field, None)
if field in event_fields
else getattr(event_card, field, None)
)
return (
("\n" if len(str(val)) > 140 else "")
+ "_"
+ ("-" if val is None or val == "" else str(val)).replace("\n", "_\n_")
+ "_"
)
def _is_equal_state(context: FSMContext, state: State) -> bool:
current = context.get_state()
return current == state or str(current) == str(state)
def _send_tags_toggle_ui(
*,
peer,
title: str,
choices,
selected_keys: set[str],
footer_buttons: list[Button],
):
bot.messaging.send_message(
peer=peer,
text=title,
interactive_media_groups=tags_toggle_keyboard(
choices=choices,
selected_keys=selected_keys,
media_id=TAGS_MEDIA_ID,
toggle_value_prefix=TAGS_TOGGLE_PREFIX,
done_value=TAGS_DONE_VALUE,
done_label="✅ Готово",
extra_footer=footer_buttons,
),
)
@bot.di
def event_menu_edit_field_handler(
event: UpdateInteractiveMediaEvent,
context: FSMContext,
event_service: EventService,
tag_service: TagService,
terbank_service: TerbankService,
):
delete_prev_message_by_peer(bot, event.peer)
raw = str(event.data.value or "")
if "|" not in raw:
logger.error("Got unexpected event.data.value='%s' while trying to edit event", raw)
bot.messaging.send_message(peer=event.peer, text="⚠️ Некорректная команда редактирования.")
return
event_id, field = raw.split("|", 1)
field = field.strip()
data = context.get_data()
ctx_name = str(data.get("edit_event_ctx", "moderation"))
edited_event_card = event_service.get_event_by_id(event_id, event.peer.id)
if not edited_event_card:
bot.messaging.send_message(peer=event.peer, text="⚠️ Мероприятие не найдено.")
return
if field == "tags":
# редактируем теги через toggle UI
all_tags = tag_service.get_all_tags()
choices = choices_from_backend_tags(all_tags)
selected_titles: set[str] = set()
for t in edited_event_card.tags or []:
if isinstance(t, dict) and t.get("title"):
selected_titles.add(str(t["title"]))
else:
title = getattr(t, "title", None)
if title:
selected_titles.add(str(title))
context.update_data(
{
"edit_event_id": event_id,
"all_tags": [{"id": t.id, "title": t.title} for t in all_tags],
"edit_tag_keys": list(selected_titles),
"edit_current_tag_keys": list(selected_titles),
}
)
context.set_state(EventEditState.tags)
_send_tags_toggle_ui(
peer=event.peer,
title="Редактирование направления: выбери направления, которым соответствует мероприятие, затем нажми «Готово»:",
choices=choices,
selected_keys=set(selected_titles),
footer_buttons=[
Button(
media_id="event_menu_edit",
value=f"{event_id}|{ctx_name}",
label="⬅️ К редактированию мероприятия",
)
],
)
return
if field == "gosb_id":
# редактируем госб через UI
# вызываем клавиатуру выбора тербанка, дальше она сама вызовет
# выбор ГОСБ и определит, редактируем мы или создаем мероприятие
default_tb_id = edited_event_card.terbank.id
default_gosb_id = edited_event_card.gosb.id
context.update_data(
{
"edit_event_id": event_id,
"edit_event_ctx": ctx_name,
"edit_field": "gosb_id",
"edit_current_tb_id": default_tb_id,
"edit_current_gosb_id": default_gosb_id,
}
)
send_tb_select_menu(
peer=event.peer,
terbank_service=terbank_service,
context=context,
is_edit=True,
event_id=event_id,
ctx_name=ctx_name,
default_tb_id=default_tb_id,
default_gosb_id=default_gosb_id,
)
return
context.update_data({"edit_event_id": event_id, "edit_field": field})
context.set_state(EventEditState.wait_value)
field_label = _get_field_label(field)
field_current_value = _get_current_value(edited_event_card, field)
if field == "event_organizers":
bot.messaging.send_message(
peer=event.peer,
text="Пока не реализовано",
interactive_media_groups=build_back_to_event_editing_keyboard(
event_id=event_id,
ctx_name=ctx_name,
),
)
return
if field == "date":
hint = "*Введи дату в формате* `dd.mm.yyyy`, например: `20.02.2002`"
elif field == "time":
hint = "*Введи время в формате* `HH:MM`, например: `10:30`"
elif field == "participation_limit":
hint = _err("participation_limit", "*Введи число от {min} до {max}*")
elif field == "hours":
hint = _err("hours", "*Введи число от {min} до {max}*")
elif field == "title":
hint = _err("title", "*Введи от {min_len} до {max_len} символов*")
elif field == "code":
hint = _err("verification_code", "*От {min_len} до {max_len} символов, буквы и цифры*")
elif field == "description":
hint = _err("description", "*Максимум {max_len} символов*")
else:
hint = "*Введи новое значение.* Чтобы очистить поле - отправь: «-»"
bot.messaging.send_message(
peer=event.peer,
text=f" ✍️ *Редактирование*: _{field_label}_\n\n*Текущее значение*: {field_current_value}\n\n{hint}",
interactive_media_groups=build_back_to_event_editing_keyboard(
event_id=event_id,
ctx_name=ctx_name,
),
)
@events_rt.message(state=EventEditState.wait_value)
@bot.di
def event_edit_value_step(
message: UpdateMessage,
context: FSMContext,
event_service: EventService,
):
delete_prev_message(bot, message)
data = context.get_data()
event_id: str = str(data.get("edit_event_id", ""))
field: str = str(data.get("edit_field", ""))
ctx_name = str(data.get("edit_event_ctx", "moderation"))
if not event_id or not field:
logger.error("expected values for event_id and field, got '%s' and '%s'", event_id, field)
bot.messaging.send_message(
peer=message.peer,
text="⚠️ Сессия редактирования сброшена. Открой мероприятие заново.",
)
context.set_state(None)
return
raw = message.message.text_message.text.strip()
value: str | int = "" if raw == "-" else raw
if field == "event_organizers":
bot.messaging.send_message(
peer=message.peer,
text="Пока не реализовано.",
interactive_media_groups=build_back_to_event_editing_keyboard(
event_id=event_id,
ctx_name=ctx_name,
),
)
context.set_state(None)
return
if field == "hours":
try:
value = validate_hours(value)
except EventValidationError as e:
_msg_err_edit(message, e, event_id, ctx_name)
return
if field == "participation_limit":
try:
value = validate_participation_limit(value)
except EventValidationError as e:
_msg_err_edit(message, e, event_id, ctx_name)
return
if field == "code":
try:
value = validate_verification_code(str(value))
except EventValidationError as e:
_msg_err_edit(message, e, event_id, ctx_name)
return
if field == "title":
try:
value = validate_title(str(value))
except EventValidationError as e:
_msg_err_edit(message, e, event_id, ctx_name)
return
if field == "description":
try:
value = validate_description(str(value))
except EventValidationError as e:
_msg_err_edit(message, e, event_id, ctx_name)
return
if field == "date":
try:
date_obj = validate_date(str(value))
except EventValidationError as e:
_msg_err_edit(message, e, event_id, ctx_name)
return
if date_obj <= datetime.now().date():
bot.messaging.send_message(
peer=message.peer,
text="⚠️ Ты не можешь создать мероприятие раньше завтрашнего дня.\nПожалуйста, введи дату, например, в формате `20.02.2002`:",
interactive_media_groups=back_to_moderation_keyboard(),
)
return
current_event_card = event_service.get_event_by_id(event_id, message.peer.id)
if current_event_card is None:
logger.error(
"expected event card while editing date, got None for event_id=%s",
event_id,
)
bot.messaging.send_message(
peer=message.peer,
text="⚠️ Мероприятие не найдено. Открой мероприятие заново.",
)
context.set_state(None)
return
current_dt = current_event_card.event.date_time
new_dt = current_dt.replace(
year=date_obj.year,
month=date_obj.month,
day=date_obj.day,
)
field = "date_time"
value = new_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
if field == "time":
try:
time_obj = validate_time(str(value))
except EventValidationError as e:
_msg_err_edit(message, e, event_id, ctx_name)
return
current_event_card = event_service.get_event_by_id(event_id, message.peer.id)
if current_event_card is None:
logger.error(
"expected event card while editing time, got None for event_id=%s",
event_id,
)
bot.messaging.send_message(
peer=message.peer,
text="⚠️ Мероприятие не найдено. Открой мероприятие заново.",
)
context.set_state(None)
return
current_dt = current_event_card.event.date_time
new_dt = current_dt.replace(
hour=time_obj.hour,
minute=time_obj.minute,
second=0,
microsecond=0,
)
field = "date_time"
value = new_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
payload = {field: value}
updated = event_service.update_event(
event_id=event_id,
messenger_id=message.peer.id,
payload=payload, # type(value) in (int, str)
)
context.set_state(None)
_send_event_edit_menu(
message.peer,
event_id=event_id,
event_service=event_service,
note="⚠️ Не удалось обновить поле." if not updated else "✅ Поле обновлено.",
event_obj=event_service.get_event_by_id(event_id, message.peer.id),
show_organizer=True,
ctx_name=ctx_name,
)
--- services/bot/core/handlers/report.py ---
from __future__ import annotations
import re
from datetime import date, datetime, timedelta
from dialog_bot_sdk.entities.messaging import UpdateInteractiveMediaEvent, UpdateMessage
from core.bot_kit.fsm import FSMContext, State, StatesGroup
from core.bot_kit.router import Router
from core.config import bot
from core.handlers.events_ui import build_back_to_event_keyboard, send_event_card, split_event_value
from core.markups import back_to_reports_export_keyboard, reports_export_keyboard
from core.schemas import UserSchema
from core.services import EventService, FileService, ReportService
from core.utils import delete_prev_message, delete_prev_message_by_peer, logger
report_rt = Router()
class ReportCreateState(StatesGroup): # legacy
beneficiaries_count = State()
how_it_went = State()
class ReportExportState(StatesGroup): # legacy
wait_from = State()
wait_to = State()
def _fmt_period(date_from: date | None, date_to: date | None) -> str: # legacy
if date_from is None and date_to is None:
return "всё время"
if date_from is not None and date_to is None:
return f"с {date_from.strftime('%d.%m.%Y')} по сегодня"
if date_from is None and date_to is not None:
return f"по {date_to.strftime('%d.%m.%Y')}"
return f"с {date_from.strftime('%d.%m.%Y')} по {date_to.strftime('%d.%m.%Y')}"
def _get_button_id(event: UpdateInteractiveMediaEvent) -> str: # legacy
return str(getattr(event, "id", "") or "")
@bot.di
def reports_export_handler( # legacy
event: UpdateInteractiveMediaEvent,
context: FSMContext,
report_service: ReportService,
):
peer = event.peer
delete_prev_message_by_peer(bot, peer)
uid = f"reports_export_peer_{peer.id}"
btn_id = _get_button_id(event)
if btn_id not in ("rep_day", "rep_week", "rep_month", "rep_all", "rep_from", "rep_range"):
context.set_state(None)
bot.messaging.send_message(
peer=peer,
text="📥 Выгрузка отчётов\n\nВыбери период:",
interactive_media_groups=reports_export_keyboard(),
)
return
today = date.today()
if btn_id == "rep_day":
_export_and_send(
peer=peer,
report_service=report_service,
date_from=today - timedelta(days=1),
date_to=today,
uid=uid,
)
return
if btn_id == "rep_week":
_export_and_send(
peer=peer,
report_service=report_service,
date_from=today - timedelta(days=7),
date_to=today,
uid=uid,
)
return
if btn_id == "rep_month":
_export_and_send(
peer=peer,
report_service=report_service,
date_from=today - timedelta(days=30),
date_to=today,
uid=uid,
)
return
if btn_id == "rep_all":
_export_and_send(
peer=peer,
report_service=report_service,
date_from=None,
date_to=None,
uid=uid,
)
return
if btn_id == "rep_from":
context.set_state(ReportExportState.wait_from)
context.update_data({"report_export_mode": "from_today"})
bot.messaging.send_message(
peer=peer,
text="📌 Введи дату ОТ в формате `dd.mm.yyyy` (например: `01.02.2026`).",
interactive_media_groups=back_to_reports_export_keyboard(),
)
return
if btn_id == "rep_range":
context.set_state(ReportExportState.wait_from)
context.update_data({"report_export_mode": "range"})
bot.messaging.send_message(
peer=peer,
text="🗓️ Введи дату ОТ в формате `dd.mm.yyyy` (например: `01.02.2026`).",
interactive_media_groups=back_to_reports_export_keyboard(),
)
return
# неизвестная кнопка - показываем выбор периода (без падений)
context.set_state(None)
bot.messaging.send_message(
peer=peer,
text="📥 Выбери период выгрузки:",
interactive_media_groups=reports_export_keyboard(),
)
return
def _parse_user_date(raw: str) -> date | None: # legacy
raw = (raw or "").strip()
try:
return datetime.strptime(raw, "%d.%m.%Y").date()
except Exception:
return None
def _api_date(d: date) -> str: # legacy
return d.isoformat()
def _xlsx_has_data_rows(file_bytes: bytes) -> bool: # legacy
# требование: если отчётов нет - показать предупреждение.
# делаем через чтение xlsx, без изменения backend-контракта.
try:
from io import BytesIO
from openpyxl import load_workbook
wb = load_workbook(BytesIO(file_bytes), read_only=True, data_only=True)
ws = wb.active
for row in ws.iter_rows(min_row=2, values_only=True):
if any(v not in (None, "") for v in row):
return True
return False
except Exception as e:
logger.warning(f"_xlsx_has_data_rows failed: {e}")
return True
def _export_and_send( # legacy
*,
peer,
report_service: ReportService,
date_from: date | None,
date_to: date | None,
uid: str,
) -> None:
try:
period_text = _fmt_period(date_from, date_to)
df = _api_date(date_from) if date_from else None
dt = _api_date(date_to) if date_to else None
file_bytes, filename = report_service.export_reports_xlsx(date_from=df, date_to=dt)
has_rows = _xlsx_has_data_rows(file_bytes)
text = f"✅ Выгрузка готова: {period_text}"
if not has_rows:
text += "\n⚠️ За период отчётов не найдено"
ok = FileService.send_file(
peer,
file_bytes=file_bytes,
filename=filename,
uid=uid,
text=text,
reply_mid=None,
interactive_media_groups=back_to_reports_export_keyboard(),
)
if not ok:
logger.error(
f"_export_and_send: send_file returned False, uid={uid}, filename={filename}"
)
bot.messaging.send_message(
peer=peer,
text="⚠️ Не удалось отправить файл отчётов.",
interactive_media_groups=back_to_reports_export_keyboard(),
)
except Exception as e:
logger.error(f"_export_and_send failed, uid={uid}: {e}")
bot.messaging.send_message(
peer=peer,
text="⚠️ Ошибка экспорта и отправки.",
interactive_media_groups=back_to_reports_export_keyboard(),
)
@report_rt.message(state=ReportExportState.wait_from)
@bot.di
def report_export_wait_from_step( # legacy
message: UpdateMessage, context: FSMContext, report_service: ReportService
):
delete_prev_message(bot, message)
d = _parse_user_date(message.message.text_message.text or "")
if not d:
bot.messaging.send_message(
peer=message.peer,
text="⚠️ Не смог распознать дату. Введи в формате `dd.mm.yyyy`, например: `01.02.2026`.",
interactive_media_groups=back_to_reports_export_keyboard(),
)
return
mode = (context.get_data().get("report_export_mode") or "").strip()
context.update_data({"report_export_from": d.isoformat()})
if mode == "from_today":
context.set_state(None)
_export_and_send(
peer=message.peer,
report_service=report_service,
date_from=d,
date_to=date.today(),
uid=f"reports_export_peer_{message.peer.id}",
)
return
# mode == "range"
context.set_state(ReportExportState.wait_to)
bot.messaging.send_message(
peer=message.peer,
text="🗓️ Теперь введи дату ДО в формате `dd.mm.yyyy` (например: `02.02.2026`).",
interactive_media_groups=back_to_reports_export_keyboard(),
)
@report_rt.message(state=ReportExportState.wait_to)
@bot.di
def report_export_wait_to_step( # legacy
message: UpdateMessage, context: FSMContext, report_service: ReportService
):
delete_prev_message(bot, message)
d_to = _parse_user_date(message.message.text_message.text or "")
if not d_to:
bot.messaging.send_message(
peer=message.peer,
text="⚠️ Не смог распознать дату. Введи в формате `dd.mm.yyyy`, например: `02.02.2026`.",
interactive_media_groups=back_to_reports_export_keyboard(),
)
return
data = context.get_data()
raw_from = data.get("report_export_from")
d_from = date.fromisoformat(raw_from) if raw_from else None
if d_from and d_to < d_from:
bot.messaging.send_message(
peer=message.peer,
text="⚠️ Дата ДО не может быть раньше даты ОТ. Введи дату ДО ещё раз.",
interactive_media_groups=back_to_reports_export_keyboard(),
)
return
context.set_state(None)
_export_and_send(
peer=message.peer,
report_service=report_service,
date_from=d_from,
date_to=d_to,
uid=f"reports_export_peer_{message.peer.id}",
)
@bot.di
def report_create_start_handler(event: UpdateInteractiveMediaEvent, context: FSMContext):
delete_prev_message_by_peer(bot, event.peer)
context.set_state(None)
event_id, ctx = split_event_value(event.data.value)
ctx_name = ctx or "events"
context.update_data(
{
"report_event_id": event_id,
"report_event_ctx": ctx_name,
}
)
bot.messaging.send_message(
peer=event.peer,
text=(
"🧾 Создание отчёта\n\n"
"Шаг 1/2: введи количество благополучателей: целое неотрицательное число."
),
interactive_media_groups=build_back_to_event_keyboard(event_id, ctx_name),
)
context.set_state(ReportCreateState.beneficiaries_count)
@report_rt.message(state=ReportCreateState.beneficiaries_count)
@bot.di
def report_create_beneficiaries_step(message: UpdateMessage, context: FSMContext): # legacy
delete_prev_message(bot, message)
data = context.get_data()
event_id = data.get("report_event_id") or ""
ctx_name = data.get("report_event_ctx") or "events"
text = message.message.text_message.text.strip()
try:
if not re.fullmatch(r"^[0-9]{1,9}$", text):
raise ValueError()
val = int(text)
if val < 0 or val >= 2**31:
raise ValueError()
except ValueError:
bot.messaging.send_message(
peer=message.peer,
text="⚠️ Введи целое число, большее или равное нулю. Попробуй ещё раз:",
interactive_media_groups=build_back_to_event_keyboard(event_id, ctx_name),
)
return
context.update_data({"report_beneficiaries_count": val})
bot.messaging.send_message(
peer=message.peer,
text=("Шаг 2/2: напиши одним сообщением, как прошло мероприятие:"),
interactive_media_groups=build_back_to_event_keyboard(event_id, ctx_name),
)
context.set_state(ReportCreateState.how_it_went)
@report_rt.message(state=ReportCreateState.how_it_went)
@bot.di
def report_create_how_it_went_step(
message: UpdateMessage,
context: FSMContext,
report_service: ReportService,
event_service: EventService,
user: UserSchema,
):
delete_prev_message(bot, message)
data = context.get_data()
event_id = data.get("report_event_id") or ""
ctx_name = data.get("report_event_ctx") or "events"
how_it_went = (message.message.text_message.text or "").strip()
beneficiaries_count = data.get("report_beneficiaries_count")
if not isinstance(beneficiaries_count, int):
logger.error(
"beneficiaries_count must be type int, got '%s' of type '%s'. defaulting to zero.",
beneficiaries_count,
type(beneficiaries_count),
)
beneficiaries_count = 0
uid = f"report_create_peer_{message.peer.id}_event_{event_id}"
logger.info(f"report_create start uid={uid}, event_id={event_id}")
try:
report_service.create_report(
event_id=event_id,
requester_messenger_id=message.peer.id,
description=how_it_went,
beneficiaries=beneficiaries_count,
)
except Exception as e:
logger.error(f"report_create failed uid={uid}: {e}")
context.set_state(None)
bot.messaging.send_message(
peer=message.peer,
text="⚠️ Не удалось создать отчёт. Проверь права/наличие отчёта и попробуй ещё раз.",
interactive_media_groups=build_back_to_event_keyboard(event_id, ctx_name),
)
return
try:
file_bytes, filename = report_service.get_report(
event_id=event_id,
requester_messenger_id=message.peer.id,
)
except Exception as e:
logger.error(f"fetching after creating event failed uid={uid}: {e}")
context.set_state(None)
send_event_card(
message.peer,
event_id=event_id,
event_service=event_service,
report_service=report_service,
user=user,
ctx_name=ctx_name,
note="⚠️ Отчёт создан, но не удалось выгрузить файл.",
)
return
context.set_state(None)
FileService.send_file(
message.peer,
file_bytes=file_bytes,
filename=filename,
uid=uid,
text="✅ Отчёт создан. Файл выгружен.",
reply_mid=None,
)
send_event_card(
message.peer,
event_id=event_id,
event_service=event_service,
report_service=report_service,
user=user,
ctx_name=ctx_name,
note="✅ Отчёт создан.",
)
@bot.di
def report_view_handler(
event: UpdateInteractiveMediaEvent,
context: FSMContext,
report_service: ReportService,
):
delete_prev_message_by_peer(bot, event.peer)
context.set_state(None)
event_id, ctx = split_event_value(event.data.value)
ctx_name = ctx or "events"
uid = f"report_view_peer_{event.peer.id}_event_{event_id}"
logger.info(f"report_view start uid={uid}, event_id={event_id}")
try:
file_bytes, filename = report_service.get_report(
event_id=event_id,
requester_messenger_id=event.peer.id,
)
except Exception as e:
logger.error(f"report_view failed uid={uid}: {e}")
bot.messaging.send_message(
peer=event.peer,
text="⚠️ Не удалось получить отчёт (возможно, он ещё не создан).",
interactive_media_groups=build_back_to_event_keyboard(event_id, ctx_name),
)
return
ok = FileService.send_file(
event.peer,
file_bytes=file_bytes,
filename=filename,
uid=uid,
text="📄 Отчёт по мероприятию",
reply_mid=None,
interactive_media_groups=build_back_to_event_keyboard(event_id, ctx_name),
)
if not ok:
bot.messaging.send_message(
peer=event.peer,
text="⚠️ Не удалось отправить файл отчёта.",
interactive_media_groups=build_back_to_event_keyboard(event_id, ctx_name),
)
--- services/bot/core/handlers/validators.py ---
import re
from datetime import datetime
from typing import TypeVar
from core.config import validation_cfg
T = TypeVar("T")
class EventValidationError(ValueError): ...
class TitleOutOfRange(EventValidationError): ...
class DescriptionOutOfRange(EventValidationError): ...
class UnsupportedDateFormat(EventValidationError): ...
class UnsupportedTimeFormat(EventValidationError): ...
class HoursInvalidFormat(EventValidationError): ...
class HoursOutOfRange(EventValidationError): ...
class VerificationCodeInvalidFormat(EventValidationError): ...
class VerificationCodeOutOfRange(EventValidationError): ...
class ParticipationLimitInvalidFormat(EventValidationError): ...
class ParticipationLimitOutOfRange(EventValidationError): ...
def _get_rule(field: str, rule: str, default: T, excepted_type: type[T]) -> T:
value = validation_cfg.get(f"event.{field}.{rule}", default)
if not isinstance(value, excepted_type):
raise RuntimeError(
f"validation rule event.{field}.{rule} must be {excepted_type}, got {type(value)}"
)
return value
def validate_title(text: str):
min_len = _get_rule("title", "min_length", 4, int)
max_len = _get_rule("title", "max_length", 20, int)
if len(text) < min_len or len(text) > max_len:
raise TitleOutOfRange()
return text
def validate_description(text: str):
max_len = _get_rule("description", "max_length", 20, int)
if len(text) > max_len:
raise DescriptionOutOfRange()
return text
def validate_date(text: str):
"""Парсинг даты с поддержкой разделителей . - _ . Возвращает date."""
formats = []
for first_gap in ". -_":
for second_gap in ". -_":
formats.append(f"%d{first_gap}%m{second_gap}%Y")
date_obj = None
for fmt in formats:
try:
date_obj = datetime.strptime(text.strip(), fmt).date()
break
except ValueError:
continue
if date_obj is None:
raise UnsupportedDateFormat()
return date_obj
def validate_time(text: str):
time_obj = None
for fmt in ["%H:%M", "%H %M", "%H-%M", "%H_%M"]:
try:
time_obj = datetime.strptime(text.strip(), fmt).time()
break
except ValueError:
continue
if time_obj is None:
raise UnsupportedTimeFormat()
return time_obj
def validate_hours(text: str | int):
"""Валидация часов: 1-2 цифры, диапазон 1-18 (из YAML)."""
text = str(text).strip()
if not re.fullmatch(r"^[0-9]{1,2}$", text):
raise HoursInvalidFormat()
hours = int(text)
min_val = _get_rule("hours", "min", 1, int)
max_val = _get_rule("hours", "max", 18, int)
if hours < min_val or hours > max_val:
raise HoursOutOfRange()
return hours
def validate_verification_code(text: str):
if not re.fullmatch(r"^[A-Za-zА-Яа-я0-9]+$", text):
raise VerificationCodeInvalidFormat()
min_len = _get_rule("verification_code", "min_length", 4, int)
max_len = _get_rule("verification_code", "max_length", 20, int)
if len(text) < min_len or len(text) > max_len:
raise VerificationCodeOutOfRange()
return text
def validate_participation_limit(value: str | int):
try:
limit = int(value)
except (TypeError, ValueError) as e:
raise ParticipationLimitInvalidFormat from e
min_val = _get_rule("participation_limit", "min", 1, int)
max_val = _get_rule("participation_limit", "max", 1000, int)
if limit < min_val or limit > max_val:
raise ParticipationLimitOutOfRange()
return limit
Editor is loading...
Leave a Comment