DispelEvilBot/common/texts/render.py

124 lines
5.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Рендер шаблонов сообщений по тегам вида ``{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": "Промокод доната",
}
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)