From 1e4e905f24be65287f51f131daf3f845a92ecf7c Mon Sep 17 00:00:00 2001
From: MpakobecEx
Date: Mon, 19 Oct 2026 03:37:58 +0500
Subject: [PATCH] =?UTF-8?q?=D0=91=D0=BE=D1=82=20=D0=B1=D0=B5=D1=80=D1=91?=
=?UTF-8?q?=D1=82=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D1=8B=20=D0=B8=D0=B7=20?=
=?UTF-8?q?=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B8,=20=D0=BD=D0=B5=20?=
=?UTF-8?q?=D0=B4=D0=BE=D0=B6=D0=B8=D0=B4=D0=B0=D1=8F=D1=81=D1=8C=20=D0=BF?=
=?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Шаблоны из message_templates читаются через кеш на 45 секунд (TEMPLATE_CACHE_TTL), поэтому правка в админке доезжает до следующего сообщения или рассылки сама. Если база недоступна или таблицы ещё нет, бот работает на текстах из кода, а неудачный запрос не повторяется на каждом сообщении — админка не может уронить бота. Расписания рассылок теперь ссылаются на ключи шаблонов, а не на строки, так что тексты рассылок тоже редактируются.
---
.env.example | 4 ++
README.md | 7 +-
admin/templates/messages/list.html | 6 +-
bot/handlers/commands.py | 11 +--
bot/handlers/economy.py | 11 +--
bot/handlers/member_events.py | 7 +-
bot/handlers/shop.py | 10 +--
bot/services/notifications.py | 26 +++----
common/config.py | 4 ++
common/repositories/templates_repo.py | 8 +++
common/texts/store.py | 98 +++++++++++++++++++++++++++
common/version.py | 2 +-
env/prod.env.example | 3 +
env/test.env.example | 3 +
tests/test_store.py | 93 +++++++++++++++++++++++++
tests/test_version.py | 2 +-
16 files changed, 257 insertions(+), 38 deletions(-)
create mode 100644 common/texts/store.py
create mode 100644 tests/test_store.py
diff --git a/.env.example b/.env.example
index 3c5fab6..977fa36 100644
--- a/.env.example
+++ b/.env.example
@@ -43,5 +43,9 @@ ANALYTICS_BATCH_SIZE=200
ANALYTICS_FLUSH_INTERVAL=5.0
ANALYTICS_BUFFER_MAX=5000
+# Шаблоны сообщений: сколько секунд бот держит тексты из БД в памяти.
+# Правка в админке применяется без перезапуска в течение этого времени.
+TEMPLATE_CACHE_TTL=45
+
# Админка (FastAPI, Блок 4). Порт на loopback хоста для docker compose.
ADMIN_HTTP_PORT=8000
diff --git a/README.md b/README.md
index facf34b..fb7c7f8 100644
--- a/README.md
+++ b/README.md
@@ -58,8 +58,9 @@ APP_ENV=prod python -m bot # prod
Раздел «Сообщения» читает и правит шаблоны из `bot.message_templates`, каждое изменение
пишется в `bot.message_template_history` (аудит и материал для отката). Стартовые тексты —
`sql/V15__seed_message_templates.sql` (генерируются из `common/texts`, тест сверяет их).
-Предпросмотр использует тот же `render()`, что и бот. Бот пока читает тексты из кода —
-переключение на БД в шаге 4.5.
+Бот читает тексты из БД через TTL-кеш (`TEMPLATE_CACHE_TTL`, по умолчанию 45 с), поэтому
+правка применяется без перезапуска; при недоступной БД бот использует тексты из кода.
+Предпросмотр использует тот же `render()`, что и бот.
## Аналитика (ClickHouse)
@@ -121,7 +122,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
## Версионирование
-- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.1`.
+- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.2`.
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
diff --git a/admin/templates/messages/list.html b/admin/templates/messages/list.html
index 29b7c26..e6bed58 100644
--- a/admin/templates/messages/list.html
+++ b/admin/templates/messages/list.html
@@ -13,9 +13,9 @@
{tag} на тестовых данных.
-
- Правки пока не влияют на бота: он читает тексты из кода. Подключение шаблонов из БД —
- следующий шаг Блока 4 (4.5).
+
+ Изменения применяются ботом без перезапуска — в течение TEMPLATE_CACHE_TTL
+ секунд (по умолчанию 45). Если БД недоступна, бот использует тексты из кода.
diff --git a/bot/handlers/commands.py b/bot/handlers/commands.py
index d663b5a..cb4b4e2 100644
--- a/bot/handlers/commands.py
+++ b/bot/handlers/commands.py
@@ -12,8 +12,9 @@ from aiogram.types import Message
from bot.services import access, stats, subscriptions
from bot.services import users as users_service
from common.config import get_settings
-from common.texts import economy, info, system
+from common.texts import system
from common.texts.render import format_tops, format_user_list, render
+from common.texts.store import get_text
logger = logging.getLogger(__name__)
router = Router(name="commands")
@@ -61,7 +62,7 @@ async def list_nicks_handler(message: Message) -> None:
"users_list": format_user_list([{"username": n.username, "nick": n.nick} for n in nicks]),
"users_list_count": len(nicks),
}
- await message.answer(render(economy.MSG_NICK_LIST, context))
+ await message.answer(render(await get_text("economy.nick_list"), context))
@router.message(Command("кто", ignore_case=True))
@@ -83,7 +84,7 @@ async def get_nick_handler(message: Message) -> None:
async def help_handler(message: Message) -> None:
if not await access.check_user(message):
return
- await message.answer(info.MSG_HELP)
+ await message.answer(await get_text("info.help"))
@router.message(Command("инфо", ignore_case=True))
@@ -108,7 +109,7 @@ async def info_handler(message: Message) -> None:
"msg_count": user_info.msg_count,
"eggs": user_info.eggs,
}
- await message.answer(render(info.MSG_INFO, context))
+ await message.answer(render(await get_text("info.profile"), context))
@router.message(Command("рейтинг", ignore_case=True))
@@ -117,7 +118,7 @@ async def top_handler(message: Message) -> None:
return
tops = await stats.top_users()
context = {"tops": format_tops([{"nick": t.nick, "count": t.count} for t in tops])}
- await message.answer(render(economy.MSG_TOP, context))
+ await message.answer(render(await get_text("economy.top"), context))
@router.message(Command("отписка", ignore_case=True))
diff --git a/bot/handlers/economy.py b/bot/handlers/economy.py
index 169c4cd..d6bbf32 100644
--- a/bot/handlers/economy.py
+++ b/bot/handlers/economy.py
@@ -12,8 +12,9 @@ from aiogram.types import Message
from bot.services import access, alerts
from bot.services import economy as economy_service
from common.config import get_settings
-from common.texts import economy, system
+from common.texts import system
from common.texts.render import render
+from common.texts.store import get_text
router = Router(name="economy")
@@ -33,10 +34,10 @@ async def purification_handler(message: Message) -> None:
result = await economy_service.purify(message.from_user.id)
if not result.success:
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
- await message.answer(render(economy.MSG_NO_PURIFICATION, context))
+ await message.answer(render(await get_text("economy.no_purification"), context))
else:
context = {"money_add": result.money_add, "exp_add": result.exp_add}
- await message.answer(render(economy.MSG_PURIFICATION, context))
+ await message.answer(render(await get_text("economy.purification"), context))
await alerts.notify_purification(
message.bot,
username=message.from_user.username,
@@ -58,9 +59,9 @@ async def eggs_handler(message: Message) -> None:
result = await economy_service.collect_eggs(message.from_user.id)
if not result.success:
context = {"h": result.hours, "m": result.minutes, "s": result.seconds}
- await message.answer(render(economy.MSG_NO_EGGS, context))
+ await message.answer(render(await get_text("economy.no_eggs"), context))
else:
- await message.answer(render(economy.MSG_EGGS, {"eggs": result.eggs}))
+ await message.answer(render(await get_text("economy.eggs"), {"eggs": result.eggs}))
@router.message()
diff --git a/bot/handlers/member_events.py b/bot/handlers/member_events.py
index 3357c06..a1ef09f 100644
--- a/bot/handlers/member_events.py
+++ b/bot/handlers/member_events.py
@@ -15,6 +15,7 @@ from common.analytics import EventType, record
from common.config import get_settings
from common.texts import system, welcome
from common.texts.render import render
+from common.texts.store import get_text
logger = logging.getLogger(__name__)
router = Router(name="member_events")
@@ -54,14 +55,14 @@ async def welcome_handler(event: ChatMemberUpdated) -> None:
await event.bot.send_photo(
chat_id=event.chat.id,
photo=FSInputFile("images/welcome_image.jpg"),
- caption=render(welcome.MSG_WELCOME_TEXT, {"username": user.username}),
+ caption=render(await get_text("welcome.text"), {"username": user.username}),
)
await event.bot.send_message(
chat_id=event.chat.id,
- text=welcome.MSG_WELCOME_TEXT2,
+ text=await get_text("welcome.text2"),
reply_markup=builder.as_markup(),
)
- await event.bot.send_message(chat_id=event.chat.id, text=welcome.MSG_WELCOME_TEXT3)
+ await event.bot.send_message(chat_id=event.chat.id, text=await get_text("welcome.text3"))
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION))
diff --git a/bot/handlers/shop.py b/bot/handlers/shop.py
index ebdefcf..a249be9 100644
--- a/bot/handlers/shop.py
+++ b/bot/handlers/shop.py
@@ -11,9 +11,9 @@ from aiogram.types import CallbackQuery, Message
from bot.keyboards import get_keyboard_donat, get_keyboard_main, get_keyboard_title
from bot.services import access
from bot.services import shop as shop_service
-from common.texts import shop as shop_texts
from common.texts import system
from common.texts.render import render
+from common.texts.store import get_text
logger = logging.getLogger(__name__)
router = Router(name="shop")
@@ -26,16 +26,16 @@ async def shop_handler(message: Message) -> None:
if message.chat.type != "private":
await message.answer(system.MSG_SHOP_PRIVATE_ONLY)
return
- await message.answer(shop_texts.MSG_SHOP_MAIN, reply_markup=get_keyboard_main())
+ await message.answer(await get_text("shop.main"), reply_markup=get_keyboard_main())
async def _render_chapter(chapter: str) -> tuple[str, object] | None:
if chapter == "main":
- return shop_texts.MSG_SHOP_MAIN, get_keyboard_main()
+ return await get_text("shop.main"), get_keyboard_main()
if chapter == "title":
- return shop_texts.MSG_SHOP_TITLE, await get_keyboard_title()
+ return await get_text("shop.title"), await get_keyboard_title()
if chapter == "donat":
- return shop_texts.MSG_SHOP_DONAT, await get_keyboard_donat()
+ return await get_text("shop.donat"), await get_keyboard_donat()
return None
diff --git a/bot/services/notifications.py b/bot/services/notifications.py
index 1645410..27851d1 100644
--- a/bot/services/notifications.py
+++ b/bot/services/notifications.py
@@ -1,8 +1,9 @@
"""Планировщик рассылок.
-Исполняется внутри процесса бота. Расписание пока задано в коде (данные, а не
-магические числа); в Блоке 4 переедет в таблицу ``schedules`` и будет
-редактироваться через админку.
+Исполняется внутри процесса бота. Время и дни недели пока заданы в коде
+(данные, а не магические числа); в Блоке 4 они переедут в таблицу ``schedules``
+и будут редактироваться через админку. Сами тексты уже берутся из БД
+(``bot.message_templates``) через ``common.texts.store``.
"""
from __future__ import annotations
@@ -17,7 +18,7 @@ from aiogram import Bot
from bot.services import users as users_service
from common.config import get_settings
from common.texts import format_usernames, render
-from common.texts import notifications as notify_texts
+from common.texts.store import get_text
logger = logging.getLogger(__name__)
@@ -34,7 +35,7 @@ class ScheduledMessage:
hour: int
minute: int
- template: str
+ template_key: str
weekdays: tuple[int, ...] | None = field(default=None)
def matches(self, now: datetime) -> bool:
@@ -44,12 +45,12 @@ class ScheduledMessage:
SCHEDULES: tuple[ScheduledMessage, ...] = (
- ScheduledMessage(16, 30, notify_texts.MSG_BLAGO_30),
- ScheduledMessage(16, 45, notify_texts.MSG_BLAGO_45),
- ScheduledMessage(16, 55, notify_texts.MSG_BLAGO_55),
- ScheduledMessage(17, 0, notify_texts.MSG_BLAGO),
- ScheduledMessage(15, 0, notify_texts.MSG_VKLAD, weekdays=(4,)), # пятница
- ScheduledMessage(14, 0, notify_texts.MSG_VKLAD, weekdays=(6,)), # воскресенье
+ ScheduledMessage(16, 30, "notify.blago_30"),
+ ScheduledMessage(16, 45, "notify.blago_45"),
+ ScheduledMessage(16, 55, "notify.blago_55"),
+ ScheduledMessage(17, 0, "notify.blago"),
+ ScheduledMessage(15, 0, "notify.vklad", weekdays=(4,)), # пятница
+ ScheduledMessage(14, 0, "notify.vklad", weekdays=(6,)), # воскресенье
)
@@ -64,7 +65,8 @@ async def _tick(bot: Bot, now: datetime) -> None:
for schedule in SCHEDULES:
if schedule.matches(now):
subscribers = await users_service.list_subscribers()
- await _send_broadcast(bot, schedule.template, subscribers)
+ template = await get_text(schedule.template_key)
+ await _send_broadcast(bot, template, subscribers)
async def run_notifications(bot: Bot) -> None:
diff --git a/common/config.py b/common/config.py
index 92e54c9..d23805a 100644
--- a/common/config.py
+++ b/common/config.py
@@ -66,6 +66,10 @@ class Settings(BaseSettings):
analytics_flush_interval: float = 5.0
analytics_buffer_max: int = 5000
+ #: Сколько секунд бот держит шаблоны сообщений в памяти (правки из админки
+ #: применяются без перезапуска). 0 — читать БД при каждом обращении.
+ template_cache_ttl: float = 45.0
+
@field_validator("chat_id", "alert_chat_id", mode="before")
@classmethod
def _empty_int_to_zero(cls, value: object) -> object:
diff --git a/common/repositories/templates_repo.py b/common/repositories/templates_repo.py
index 3b97a71..476bbff 100644
--- a/common/repositories/templates_repo.py
+++ b/common/repositories/templates_repo.py
@@ -11,6 +11,14 @@ from typing import Any
from common.db.pool import get_pool
+async def load_texts() -> dict[str, str]:
+ """Актуальные тексты включённых шаблонов (для кеша бота в ``common.texts.store``)."""
+ async with get_pool().connection() as conn:
+ cur = await conn.execute("SELECT key, text FROM bot.message_templates WHERE enabled ORDER BY key")
+ rows = await cur.fetchall()
+ return {row["key"]: row["text"] for row in rows}
+
+
async def list_templates() -> list[dict[str, Any]]:
"""Вернуть список шаблонов без текста (для таблицы в админке)."""
async with get_pool().connection() as conn:
diff --git a/common/texts/store.py b/common/texts/store.py
new file mode 100644
index 0000000..b761f78
--- /dev/null
+++ b/common/texts/store.py
@@ -0,0 +1,98 @@
+"""Шаблоны сообщений из БД с TTL-кешем (Блок 4.5).
+
+Бот читает тексты из ``bot.message_templates``: правка в админке применяется
+без перезапуска — в течение ``TEMPLATE_CACHE_TTL`` секунд. Если БД недоступна
+(или таблицы ещё нет), используются тексты по умолчанию из ``common.texts.TEMPLATES``.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+from collections.abc import Awaitable, Callable
+
+from common.config import get_settings
+from common.texts import TEMPLATES
+
+logger = logging.getLogger(__name__)
+
+#: Загрузчик «ключ -> текст» (по умолчанию — из Postgres).
+Loader = Callable[[], Awaitable[dict[str, str]]]
+
+
+async def _load_from_db() -> dict[str, str]:
+ # Импорт внутри функции: не тянем слой БД на этапе импорта текстов.
+ from common.repositories.templates_repo import load_texts
+
+ return await load_texts()
+
+
+class TemplateStore:
+ """Кеш шаблонов с временем жизни; при ошибке БД — тексты по умолчанию."""
+
+ def __init__(self, loader: Loader | None = None, ttl: float | None = None) -> None:
+ self._loader = loader or _load_from_db
+ self._ttl = ttl
+ self._texts: dict[str, str] = {}
+ self._loaded_at: float | None = None
+ self._lock = asyncio.Lock()
+
+ @property
+ def ttl(self) -> float:
+ """TTL кеша в секундах (из настроек, если не задан явно)."""
+ if self._ttl is None:
+ self._ttl = get_settings().template_cache_ttl
+ return max(self._ttl, 0.0)
+
+ async def get(self, key: str) -> str:
+ """Вернуть текст шаблона (из БД или из кода, если его там нет)."""
+ await self._ensure_fresh()
+ text = self._texts.get(key)
+ if text is None:
+ return TEMPLATES.get(key, "")
+ return text
+
+ def clear(self) -> None:
+ """Сбросить кеш: следующий ``get`` перечитает БД."""
+ self._loaded_at = None
+
+ async def _ensure_fresh(self) -> None:
+ if self._loaded_at is not None and time.monotonic() - self._loaded_at < self.ttl:
+ return
+
+ async with self._lock:
+ # Пока ждали лок, кеш мог обновить другой вызов.
+ if self._loaded_at is not None and time.monotonic() - self._loaded_at < self.ttl:
+ return
+
+ try:
+ texts = await self._loader()
+ except Exception:
+ logger.warning(
+ "Не удалось прочитать шаблоны из БД — использую тексты по умолчанию",
+ exc_info=True,
+ )
+ # Не долбим БД каждым сообщением: следующая попытка через TTL.
+ self._loaded_at = time.monotonic()
+ return
+
+ self._texts = texts
+ self._loaded_at = time.monotonic()
+ logger.debug("Шаблоны обновлены из БД: %s", len(texts))
+
+
+_store: TemplateStore | None = None
+
+
+def get_template_store() -> TemplateStore:
+ """Вернуть общий кеш шаблонов процесса."""
+ global _store
+ if _store is None:
+ _store = TemplateStore()
+ return _store
+
+
+async def get_text(key: str) -> str:
+ """Короткий доступ к шаблону: ``await get_text("info.help")``."""
+ return await get_template_store().get(key)
diff --git a/common/version.py b/common/version.py
index 4a60f38..984dc40 100644
--- a/common/version.py
+++ b/common/version.py
@@ -7,4 +7,4 @@
from __future__ import annotations
-__version__ = "0.4.1"
+__version__ = "0.4.2"
diff --git a/env/prod.env.example b/env/prod.env.example
index 52b8c13..a5f0333 100644
--- a/env/prod.env.example
+++ b/env/prod.env.example
@@ -43,3 +43,6 @@ CLICKHOUSE_HTTP_PORT=8123
# Админка (FastAPI). Порт на loopback хоста; у test и prod должен различаться.
# Наружу пока не публикуется (домен/TLS — Блок 2.4).
ADMIN_HTTP_PORT=8000
+
+# Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска).
+TEMPLATE_CACHE_TTL=45
diff --git a/env/test.env.example b/env/test.env.example
index ef897f6..88140e2 100644
--- a/env/test.env.example
+++ b/env/test.env.example
@@ -41,3 +41,6 @@ CLICKHOUSE_HTTP_PORT=8124
# Админка (FastAPI). Порт на loopback хоста; у test и prod должен различаться.
# Наружу пока не публикуется (домен/TLS — Блок 2.4).
ADMIN_HTTP_PORT=8001
+
+# Шаблоны сообщений: TTL кеша бота (правки из админки без перезапуска).
+TEMPLATE_CACHE_TTL=45
diff --git a/tests/test_store.py b/tests/test_store.py
new file mode 100644
index 0000000..911fcb7
--- /dev/null
+++ b/tests/test_store.py
@@ -0,0 +1,93 @@
+"""Тесты TTL-кеша шаблонов из БД (Блок 4.5): кеш, фолбэк и ошибки БД."""
+
+from __future__ import annotations
+
+from common.texts import TEMPLATES
+from common.texts.store import TemplateStore
+
+
+async def test_uses_db_values() -> None:
+ async def loader() -> dict[str, str]:
+ return {"info.help": "текст из БД"}
+
+ store = TemplateStore(loader=loader, ttl=60)
+
+ assert await store.get("info.help") == "текст из БД"
+
+
+async def test_falls_back_to_code_for_missing_key() -> None:
+ async def loader() -> dict[str, str]:
+ return {"info.help": "текст из БД"}
+
+ store = TemplateStore(loader=loader, ttl=60)
+
+ assert await store.get("economy.top") == TEMPLATES["economy.top"]
+
+
+async def test_falls_back_when_db_unavailable() -> None:
+ async def loader() -> dict[str, str]:
+ raise RuntimeError("БД недоступна")
+
+ store = TemplateStore(loader=loader, ttl=60)
+
+ assert await store.get("info.help") == TEMPLATES["info.help"]
+
+
+async def test_caches_within_ttl() -> None:
+ calls = 0
+
+ async def loader() -> dict[str, str]:
+ nonlocal calls
+ calls += 1
+ return {"info.help": f"v{calls}"}
+
+ store = TemplateStore(loader=loader, ttl=60)
+
+ assert await store.get("info.help") == "v1"
+ assert await store.get("info.help") == "v1"
+ assert calls == 1
+
+
+async def test_reloads_when_cache_expired() -> None:
+ calls = 0
+
+ async def loader() -> dict[str, str]:
+ nonlocal calls
+ calls += 1
+ return {"info.help": f"v{calls}"}
+
+ store = TemplateStore(loader=loader, ttl=0)
+
+ assert await store.get("info.help") == "v1"
+ assert await store.get("info.help") == "v2"
+
+
+async def test_clear_forces_reload() -> None:
+ calls = 0
+
+ async def loader() -> dict[str, str]:
+ nonlocal calls
+ calls += 1
+ return {"info.help": f"v{calls}"}
+
+ store = TemplateStore(loader=loader, ttl=600)
+ assert await store.get("info.help") == "v1"
+
+ store.clear()
+
+ assert await store.get("info.help") == "v2"
+
+
+async def test_failed_load_is_not_retried_on_every_call() -> None:
+ calls = 0
+
+ async def loader() -> dict[str, str]:
+ nonlocal calls
+ calls += 1
+ raise RuntimeError("БД недоступна")
+
+ store = TemplateStore(loader=loader, ttl=60)
+
+ assert await store.get("info.help") == TEMPLATES["info.help"]
+ assert await store.get("info.help") == TEMPLATES["info.help"]
+ assert calls == 1
diff --git a/tests/test_version.py b/tests/test_version.py
index ba9616f..5585db3 100644
--- a/tests/test_version.py
+++ b/tests/test_version.py
@@ -7,4 +7,4 @@ from common import __version__
def test_version_is_set():
assert __version__
- assert __version__ == "0.4.1"
+ assert __version__ == "0.4.2"