128 lines
5.4 KiB
Python
128 lines
5.4 KiB
Python
"""Рендер шаблонов сообщений по тегам вида ``{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", "eggs_line"})
|
||
|
||
#: Полный реестр поддерживаемых тегов: имя -> короткое описание.
|
||
TAG_REGISTRY: dict[str, str] = {
|
||
"username": "Имя пользователя (@username или имя)",
|
||
"actor": "Кто выполнил действие (для уведомлений)",
|
||
"nick": "Игровой ник",
|
||
"title": "Звание",
|
||
"lvl": "Уровень",
|
||
"exp": "Текущий опыт",
|
||
"exp_max": "Опыт до следующего уровня",
|
||
"money": "Баланс DE",
|
||
"dt": "Дата начала активности",
|
||
"msg_count": "Количество сообщений",
|
||
"eggs": "Количество яичек",
|
||
"eggs_line": "Готовая строка про яички (пусто вне пасхального события)",
|
||
"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)
|