"""Рендер шаблонов сообщений по тегам вида ``{tag}``. Единый источник правды для бота и админки. Не используется ``str.format``: любая посторонняя фигурная скобка в тексте сломала бы его. Подстановка идёт только по известным тегам из реестра, неизвестный тег — ошибка валидации. Пользовательские значения экранируются под Telegram HTML (ParseMode.HTML), кроме тегов, помеченных как RAW (списки, которые формируются уже безопасно). """ from __future__ import annotations import html import re from collections.abc import Iterable, Mapping from typing import Any _TAG_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}") #: Теги, чьи значения подставляются «как есть» (формируются helpers-функциями ниже). RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_count", "tops"}) #: Полный реестр поддерживаемых тегов: имя -> короткое описание. TAG_REGISTRY: dict[str, str] = { "username": "Имя пользователя (@username или имя)", "nick": "Игровой ник", "title": "Звание", "lvl": "Уровень", "exp": "Текущий опыт", "exp_max": "Опыт до следующего уровня", "money": "Баланс DE", "dt": "Дата начала активности", "msg_count": "Количество сообщений", "eggs": "Количество яичек", "h": "Часы до следующего действия", "m": "Минуты до следующего действия", "s": "Секунды до следующего действия", "exp_add": "Начислено опыта", "money_add": "Начислено DE", "usernames": "Список @упоминаний для рассылки", "users_list": "Нумерованный список игроков", "users_list_count": "Количество игроков в списке", "tops": "Топ игроков по сообщениям", "promo": "Промокод доната", # --- Служебные теги уведомлений в чат логов (ALERT_CHAT_ID) --- "chat_title": "Название чата", "version": "Версия бота", "uptime": "Время работы бота", } SUPPORTED_TAGS: frozenset[str] = frozenset(TAG_REGISTRY) #: Лимит длины сообщения Telegram. MAX_MESSAGE_LENGTH = 4096 class TemplateError(ValueError): """Ошибка в шаблоне: неизвестный тег или превышена длина.""" def escape(value: Any) -> str: """Экранировать значение под HTML-разметку Telegram.""" return html.escape(str(value), quote=False) def render(template: str, context: Mapping[str, Any] | None = None) -> str: """Подставить теги в шаблон. :raises TemplateError: если в шаблоне есть неизвестный тег. """ context = context or {} def _replace(match: re.Match[str]) -> str: name = match.group(1) if name not in SUPPORTED_TAGS: raise TemplateError(f"Неизвестный тег {{{name}}}") value = context.get(name) if value is None: return "" if name in RAW_TAGS: return str(value) return escape(value) return _TAG_RE.sub(_replace, template) def validate(template: str) -> list[str]: """Вернуть список неизвестных тегов в шаблоне (для валидации в админке).""" unknown = {m.group(1) for m in _TAG_RE.finditer(template)} return sorted(tag for tag in unknown if tag not in SUPPORTED_TAGS) # --- Хелперы формирования RAW-значений ------------------------------------- def format_usernames(rows: Iterable[Mapping[str, Any]]) -> str: """Строка вида ``@user1 @user2`` для рассылок.""" parts = [] for row in rows: username = row.get("username") if username: parts.append("@" + escape(username)) return " ".join(parts) def format_user_list(rows: Iterable[Mapping[str, Any]]) -> str: """Нумерованный список игроков: ``1: @user - nick``.""" lines = [] for index, row in enumerate(rows, start=1): username = row.get("username") nick = row.get("nick") user_text = "@" + escape(username) if username else "—" nick_text = escape(nick) if nick else "" lines.append(f"{index}: {user_text} - {nick_text}") return "\n".join(lines) def format_tops(rows: Iterable[Mapping[str, Any]]) -> str: """Список рейтинга: ``1) nick - N``.""" lines = [] for index, row in enumerate(rows, start=1): nick = row.get("nick") or "—" count = row.get("count", 0) lines.append(f"{index}) {escape(nick)} - {count}") return "\n".join(lines)