Compare commits
5 Commits
1c85590f6f
...
3347dacbb5
| Author | SHA1 | Date |
|---|---|---|
|
|
3347dacbb5 | |
|
|
ae6faeee1e | |
|
|
84fc104717 | |
|
|
08c57f9b39 | |
|
|
cfa0e29bbf |
31
README.md
31
README.md
|
|
@ -55,12 +55,10 @@ APP_ENV=prod python -m bot # prod
|
|||
## Админка (Блок 4)
|
||||
|
||||
Отдельный сервис `admin/` (FastAPI + Jinja2/HTMX), запуск `python -m admin`.
|
||||
Раздел «Сообщения» читает и правит шаблоны из `bot.message_templates`, каждое изменение
|
||||
пишется в `bot.message_template_history` (аудит и материал для отката). Стартовые тексты —
|
||||
`sql/V15__seed_message_templates.sql` (генерируются из `common/texts`, тест сверяет их).
|
||||
Бот читает тексты из БД через TTL-кеш (`TEMPLATE_CACHE_TTL`, по умолчанию 45 с), поэтому
|
||||
правка применяется без перезапуска; при недоступной БД бот использует тексты из кода.
|
||||
Предпросмотр использует тот же `render()`, что и бот.
|
||||
Все тексты бота — **последовательности** (`bot.message_sequences`): миграция `V24`
|
||||
переносит каждый старый шаблон в последовательность из одного сообщения, а бот
|
||||
отправляет их через общий sender. Старые `message_templates` остаются фолбэком в коде.
|
||||
Правки применяются без перезапуска; предпросмотр использует тот же `render()`, что и бот.
|
||||
|
||||
**Вход** — через Telegram Login Widget: подпись `hash` проверяется токеном бота, доступ
|
||||
есть только у записей в `bot.admin_users` (роль проверяется в БД на каждом запросе).
|
||||
|
|
@ -68,6 +66,11 @@ APP_ENV=prod python -m bot # prod
|
|||
HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSRF. Для входа нужны
|
||||
`BOT_TOKEN`, `BOT_USERNAME` (без @) и HTTPS-адрес `ADMIN_PUBLIC_URL`.
|
||||
|
||||
Дополнительно — **вход через установленный Telegram**: кнопка «Открыть Telegram»
|
||||
создаёт одноразовый токен (5 минут) и открывает `t.me/<bot>?start=login_<token>`;
|
||||
бот подтверждает вход, сайт забирает токен и открывает сессию. Токен в БД хранится
|
||||
как SHA-256 и используется один раз.
|
||||
|
||||
Раздел «Расписания» — CRUD рассылок (`bot.schedules` + история): время, дни недели,
|
||||
таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если
|
||||
база недоступна — использует расписание из кода (совпадает с сидом `V16`).
|
||||
|
|
@ -79,6 +82,10 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR
|
|||
журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в
|
||||
`bot.user_changes`: кто, когда, было → стало и причина.
|
||||
|
||||
Раздел «Звания» — справочник магазина: название, цена и видимость. Изменения сразу
|
||||
видны боту (он читает `bot.titles`). Удалить можно только звание, которое ни у кого
|
||||
не стоит; используемое — скрывается (`visible = false`).
|
||||
|
||||
Раздел «Логи» — события из ClickHouse: фильтры по типу, пользователю (ID или username)
|
||||
и датам, пагинация по 50. Время показывается в `TIMEZONE`, payload раскрывается как JSON,
|
||||
ID пользователя ведёт в его карточку. ClickHouse читается отдельным ленивым клиентом;
|
||||
|
|
@ -87,8 +94,7 @@ ID пользователя ведёт в его карточку. ClickHouse ч
|
|||
Раздел «Админы» — выдача и отзыв доступа к панели по Telegram-ID, роли и вкл/выкл.
|
||||
В карточке пользователя есть исключение из чата (бан + разбан) с удалением данных и
|
||||
уведомлением «кто кого выгнал»; историю изменений показывает, кто менял ник — сам
|
||||
пользователь через `/ник` или админ. Новые шаблоны создаются в «Сообщениях»: ключ,
|
||||
описание, текст и предпросмотр.
|
||||
пользователь через `/ник` или админ.
|
||||
|
||||
Раздел «Последовательности» — набор сообщений по порядку (приветствие и любые другие):
|
||||
у каждого свой текст, картинка из общего volume `media`, вкл/выкл и несколько кнопок.
|
||||
|
|
@ -96,6 +102,13 @@ ID пользователя ведёт в его карточку. ClickHouse ч
|
|||
код, блок кода, цитата, спойлер и ссылка; есть предпросмотр и счётчик символов
|
||||
(1024 для подписи к фото, 4096 для обычного сообщения).
|
||||
|
||||
Интерфейс адаптирован под мобильные: бургер-меню, второстепенные колонки таблиц
|
||||
скрываются на узких экранах, формы складываются в одну колонку.
|
||||
|
||||
Раздел «Отправить» — ручное сообщение в основной чат от имени бота: текст с тем же
|
||||
редактором, картинка и URL-кнопки, предпросмотр и подтверждение. Каждая отправка
|
||||
(успешная или нет) пишется в `bot.admin_messages`.
|
||||
|
||||
## Аналитика (ClickHouse)
|
||||
|
||||
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
|
||||
|
|
@ -158,7 +171,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
|
|||
|
||||
## Версионирование
|
||||
|
||||
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.5.2`.
|
||||
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.6.1`.
|
||||
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||
|
||||
from admin.media import ensure_media
|
||||
from admin.middleware import AdminAuthMiddleware
|
||||
from admin.routes import admins, auth, health, logs, messages, pages, schedules, sequences, users
|
||||
from admin.routes import admins, auth, health, logs, pages, preview, schedules, send, sequences, titles, users
|
||||
from common import __version__
|
||||
from common.analytics import read as analytics_read
|
||||
from common.config import AdminSettings, get_admin_settings
|
||||
|
|
@ -89,10 +89,12 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -
|
|||
app.include_router(health.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(preview.router)
|
||||
app.include_router(sequences.router)
|
||||
app.include_router(titles.router)
|
||||
app.include_router(schedules.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(admins.router)
|
||||
app.include_router(logs.router)
|
||||
app.include_router(send.router)
|
||||
return app
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
"""Разбор URL-кнопок из textarea: по одной в строке ``Текст | https://…``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
MAX_BUTTONS = 8
|
||||
|
||||
|
||||
def parse_buttons(raw: str) -> tuple[list[tuple[str, str]], list[str]]:
|
||||
buttons: list[tuple[str, str]] = []
|
||||
errors: list[str] = []
|
||||
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if "|" not in line:
|
||||
errors.append(f"Кнопка без разделителя «|»: {line}")
|
||||
continue
|
||||
text, url = (part.strip() for part in line.split("|", 1))
|
||||
if not text:
|
||||
errors.append("У кнопки пустой текст")
|
||||
continue
|
||||
if not url.startswith(("https://", "http://")):
|
||||
errors.append(f"Ссылка кнопки должна начинаться с http(s): {url}")
|
||||
continue
|
||||
if len(text) > 64:
|
||||
errors.append("Текст кнопки длиннее 64 символов")
|
||||
continue
|
||||
buttons.append((text, url))
|
||||
|
||||
if len(buttons) > MAX_BUTTONS:
|
||||
errors.append(f"Не больше {MAX_BUTTONS} кнопок на сообщение")
|
||||
return buttons, errors
|
||||
|
|
@ -6,6 +6,8 @@ import logging
|
|||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
from common.config import AdminSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -16,6 +18,23 @@ BUILTIN_WELCOME = Path("images/welcome_image.jpg")
|
|||
#: Имя файла в volume, которое ждёт сид последовательности `welcome`.
|
||||
WELCOME_IMAGE_NAME = "welcome_image.jpg"
|
||||
|
||||
#: Допустимые типы и максимальный размер загружаемых картинок.
|
||||
ALLOWED_IMAGE_TYPES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||
MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
async def read_image(upload: UploadFile) -> tuple[bytes, str, str]:
|
||||
"""Прочитать картинку из формы. Возвращает (данные, mime, расширение)."""
|
||||
content_type = (upload.content_type or "").lower()
|
||||
extension = ALLOWED_IMAGE_TYPES.get(content_type)
|
||||
if extension is None:
|
||||
raise ValueError("Картинка должна быть JPEG, PNG или WebP")
|
||||
|
||||
data = await upload.read()
|
||||
if len(data) > MAX_IMAGE_BYTES:
|
||||
raise ValueError("Картинка больше 5 МБ")
|
||||
return data, content_type, extension
|
||||
|
||||
|
||||
def ensure_media(settings: AdminSettings) -> Path:
|
||||
"""Создать каталог и положить туда встроенное приветствие, если его нет."""
|
||||
|
|
|
|||
|
|
@ -15,7 +15,15 @@ from common.repositories import admins_repo
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Эндпоинты без авторизации.
|
||||
PUBLIC_PATHS = frozenset({"/healthz", "/login", "/auth/telegram"})
|
||||
PUBLIC_PATHS = frozenset(
|
||||
{
|
||||
"/healthz",
|
||||
"/login",
|
||||
"/auth/telegram",
|
||||
"/auth/deeplink/start",
|
||||
"/auth/deeplink/status",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminAuthMiddleware(BaseHTTPMiddleware):
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import logging
|
|||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from admin.security import check_csrf, safe_redirect_target, verify_telegram_auth
|
||||
from common import __version__
|
||||
from common.repositories import admins_repo
|
||||
from common.repositories import admin_login_tokens_repo, admins_repo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -45,11 +45,81 @@ async def login_page(request: Request, next: str = "/") -> Response:
|
|||
"bot_username": settings.bot_username,
|
||||
"public_url": settings.admin_public_url.rstrip("/"),
|
||||
"configured": bool(settings.bot_token and settings.bot_username),
|
||||
"deeplink": bool(settings.bot_username and settings.admin_public_url),
|
||||
"deeplink_pending": bool(request.cookies.get("debot_login_token")),
|
||||
"deeplink_error": request.query_params.get("error") == "deeplink",
|
||||
"next": target,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/auth/deeplink/start")
|
||||
async def deeplink_start(request: Request) -> Response:
|
||||
"""Создать токен и открыть установленный Telegram с deep-link."""
|
||||
settings = request.app.state.settings
|
||||
username = settings.bot_username.lstrip("@")
|
||||
if not username:
|
||||
return RedirectResponse(url="/login?error=deeplink", status_code=303)
|
||||
|
||||
token = await admin_login_tokens_repo.create_token()
|
||||
response = RedirectResponse(
|
||||
url=f"https://t.me/{username}?start=login_{token}",
|
||||
status_code=303,
|
||||
)
|
||||
response.set_cookie(
|
||||
"debot_login_token",
|
||||
token,
|
||||
max_age=300,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=settings.is_prod,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/auth/deeplink/status")
|
||||
async def deeplink_status(request: Request) -> JSONResponse:
|
||||
"""Проверить, подтвердил ли пользователь вход в боте, и открыть сессию."""
|
||||
token = request.cookies.get("debot_login_token")
|
||||
if not token:
|
||||
return JSONResponse({"status": "none"})
|
||||
|
||||
try:
|
||||
telegram_id = await admin_login_tokens_repo.consume_token(token)
|
||||
except Exception:
|
||||
logger.exception("Не удалось проверить deep-link токен")
|
||||
return JSONResponse({"status": "pending"})
|
||||
|
||||
if telegram_id is None:
|
||||
return JSONResponse({"status": "pending"})
|
||||
|
||||
try:
|
||||
admin = await admins_repo.get_admin(telegram_id)
|
||||
except Exception:
|
||||
logger.exception("Ошибка проверки администратора при deep-link входе")
|
||||
return JSONResponse({"status": "pending"})
|
||||
|
||||
if admin is None or not admin["enabled"]:
|
||||
response = JSONResponse({"status": "denied"})
|
||||
response.delete_cookie("debot_login_token")
|
||||
return response
|
||||
|
||||
request.session.clear()
|
||||
request.session["admin_id"] = telegram_id
|
||||
request.session["admin_username"] = admin["username"] or ""
|
||||
request.session["csrf"] = secrets.token_urlsafe(32)
|
||||
|
||||
try:
|
||||
await admins_repo.touch_login(telegram_id)
|
||||
except Exception:
|
||||
logger.warning("Не удалось обновить last_login_at", exc_info=True)
|
||||
|
||||
logger.info("Вход в админку по deep-link: %s", telegram_id)
|
||||
response = JSONResponse({"status": "ok"})
|
||||
response.delete_cookie("debot_login_token")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/auth/telegram")
|
||||
async def telegram_callback(request: Request) -> Response:
|
||||
"""Проверить подпись Telegram, роль в БД и открыть сессию."""
|
||||
|
|
|
|||
|
|
@ -1,228 +0,0 @@
|
|||
"""Раздел «Сообщения»: список шаблонов, редактор и предпросмотр (HTMX)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from admin.preview import SAMPLE_CONTEXT
|
||||
from admin.security import check_csrf
|
||||
from common import __version__
|
||||
from common.repositories import templates_repo
|
||||
from common.texts.render import MAX_MESSAGE_LENGTH, TAG_REGISTRY, TemplateError, render, validate
|
||||
|
||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||
|
||||
#: Формат ключа нового шаблона (латиница, цифры, точка, подчёркивание, дефис).
|
||||
KEY_RE = re.compile(r"^[a-z][a-z0-9_.-]{1,63}$")
|
||||
|
||||
#: Максимальная длина описания шаблона.
|
||||
MAX_DESCRIPTION_LENGTH = 200
|
||||
|
||||
|
||||
def _editor_context(
|
||||
request: Request,
|
||||
template: dict[str, object],
|
||||
*,
|
||||
text: str | None = None,
|
||||
errors: list[str] | None = None,
|
||||
saved: bool = False,
|
||||
history: list[dict[str, object]] | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Контекст страницы редактора (общий для GET и повторного показа после ошибки)."""
|
||||
return {
|
||||
"version": __version__,
|
||||
"template": template,
|
||||
"text": text if text is not None else template["text"],
|
||||
"errors": errors or [],
|
||||
"saved": saved,
|
||||
"history": history or [],
|
||||
"tags": TAG_REGISTRY,
|
||||
"max_length": MAX_MESSAGE_LENGTH,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def list_messages(request: Request) -> HTMLResponse:
|
||||
"""Список шаблонов: ключ, описание, длина и время последней правки."""
|
||||
templates = request.app.state.templates
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="messages/list.html",
|
||||
context={"version": __version__, "templates": await templates_repo.list_templates()},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/new", response_class=HTMLResponse)
|
||||
async def new_template(request: Request) -> HTMLResponse:
|
||||
"""Форма создания нового шаблона."""
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="messages/new.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"key": "",
|
||||
"description": "",
|
||||
"text": "",
|
||||
"enabled": True,
|
||||
"errors": [],
|
||||
"tags": TAG_REGISTRY,
|
||||
"max_length": MAX_MESSAGE_LENGTH,
|
||||
"max_description_length": MAX_DESCRIPTION_LENGTH,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_template(request: Request) -> Response:
|
||||
"""Создать шаблон (ключ проверяется на формат и занятость)."""
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
key = str(form.get("key", "")).strip()
|
||||
description = str(form.get("description", "")).strip()
|
||||
text = str(form.get("text", ""))
|
||||
enabled = form.get("enabled") is not None
|
||||
|
||||
errors: list[str] = []
|
||||
if not KEY_RE.match(key):
|
||||
errors.append("Ключ: латиница в нижнем регистре, цифры, точка, подчёркивание или дефис (2–64 символа)")
|
||||
if not text.strip():
|
||||
errors.append("Текст не может быть пустым")
|
||||
unknown = validate(text)
|
||||
if unknown:
|
||||
errors.append("Неизвестные теги: " + ", ".join(unknown))
|
||||
if len(text) > MAX_MESSAGE_LENGTH:
|
||||
errors.append(f"Длина {len(text)} превышает лимит Telegram ({MAX_MESSAGE_LENGTH})")
|
||||
if len(description) > MAX_DESCRIPTION_LENGTH:
|
||||
errors.append(f"Описание длиннее {MAX_DESCRIPTION_LENGTH} символов")
|
||||
|
||||
if errors:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="messages/new.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"key": key,
|
||||
"description": description,
|
||||
"text": text,
|
||||
"enabled": enabled,
|
||||
"errors": errors,
|
||||
"tags": TAG_REGISTRY,
|
||||
"max_length": MAX_MESSAGE_LENGTH,
|
||||
"max_description_length": MAX_DESCRIPTION_LENGTH,
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
created = await templates_repo.create_template(
|
||||
key,
|
||||
text,
|
||||
description,
|
||||
enabled=enabled,
|
||||
editor_id=int(request.state.admin["telegram_id"]),
|
||||
)
|
||||
if not created:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="messages/new.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"key": key,
|
||||
"description": description,
|
||||
"text": text,
|
||||
"enabled": enabled,
|
||||
"errors": ["Шаблон с таким ключом уже существует"],
|
||||
"tags": TAG_REGISTRY,
|
||||
"max_length": MAX_MESSAGE_LENGTH,
|
||||
"max_description_length": MAX_DESCRIPTION_LENGTH,
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
return RedirectResponse(url=f"/messages/{key}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/preview", response_class=HTMLResponse)
|
||||
async def preview_message(request: Request) -> HTMLResponse:
|
||||
"""Отрендерить текст с тестовыми значениями (HTMX-фрагмент).
|
||||
|
||||
Результат кладётся в ``iframe sandbox``: разметка Telegram видна «как в чате»,
|
||||
но скрипты из введённого текста не выполняются.
|
||||
"""
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
text = str(form.get("text", ""))
|
||||
|
||||
unknown = validate(text)
|
||||
if unknown:
|
||||
return HTMLResponse(
|
||||
'<p class="text-sm text-red-600">Неизвестные теги: ' + html.escape(", ".join(unknown)) + "</p>"
|
||||
)
|
||||
|
||||
try:
|
||||
rendered = render(text, SAMPLE_CONTEXT)
|
||||
except TemplateError as exc: # pragma: no cover - validate уже отсекает неизвестные теги
|
||||
return HTMLResponse(f'<p class="text-sm text-red-600">{html.escape(str(exc))}</p>')
|
||||
|
||||
if len(rendered) > MAX_MESSAGE_LENGTH:
|
||||
return HTMLResponse(
|
||||
f'<p class="text-sm text-red-600">Текст длиннее лимита Telegram: '
|
||||
f"{len(rendered)} из {MAX_MESSAGE_LENGTH} символов.</p>"
|
||||
)
|
||||
|
||||
return HTMLResponse(
|
||||
'<iframe sandbox class="h-64 w-full rounded border border-slate-200 bg-white" '
|
||||
f'srcdoc="{html.escape(rendered, quote=True)}"></iframe>'
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{key}")
|
||||
async def save_message(request: Request, key: str) -> Response:
|
||||
"""Сохранить текст шаблона; при ошибках вернуть редактор с подсветкой."""
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
text = str(form.get("text", ""))
|
||||
|
||||
template = await templates_repo.get_template(key)
|
||||
if template is None:
|
||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||
|
||||
errors: list[str] = []
|
||||
unknown = validate(text)
|
||||
if unknown:
|
||||
errors.append("Неизвестные теги: " + ", ".join(unknown))
|
||||
if not text.strip():
|
||||
errors.append("Текст не может быть пустым")
|
||||
if len(text) > MAX_MESSAGE_LENGTH:
|
||||
errors.append(f"Длина {len(text)} превышает лимит Telegram ({MAX_MESSAGE_LENGTH})")
|
||||
|
||||
if errors:
|
||||
history = await templates_repo.template_history(key)
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="messages/edit.html",
|
||||
context=_editor_context(request, template, text=text, errors=errors, history=history),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
editor_id = int(request.state.admin["telegram_id"])
|
||||
await templates_repo.update_template(key, text, editor_id=editor_id)
|
||||
return RedirectResponse(url=f"/messages/{key}?saved=1", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{key}", response_class=HTMLResponse)
|
||||
async def edit_message(request: Request, key: str, saved: bool = False) -> HTMLResponse:
|
||||
"""Редактор шаблона: текст, подсказка по тегам, предпросмотр и история."""
|
||||
template = await templates_repo.get_template(key)
|
||||
if template is None:
|
||||
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||
|
||||
history = await templates_repo.template_history(key)
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="messages/edit.html",
|
||||
context=_editor_context(request, template, saved=saved, history=history),
|
||||
)
|
||||
|
|
@ -11,11 +11,6 @@ router = APIRouter(tags=["pages"])
|
|||
|
||||
#: Разделы админки по плану Блока 4 (url — None, пока раздел не готов).
|
||||
SECTIONS: tuple[dict[str, str | None], ...] = (
|
||||
{
|
||||
"title": "Сообщения",
|
||||
"description": "Тексты бота и предпросмотр с тегами {tag}",
|
||||
"url": "/messages",
|
||||
},
|
||||
{
|
||||
"title": "Последовательности",
|
||||
"description": "Приветствие и другие наборы сообщений по порядку",
|
||||
|
|
@ -31,6 +26,11 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
|
|||
"description": "Ники, звания, баланс и подписка",
|
||||
"url": "/users",
|
||||
},
|
||||
{
|
||||
"title": "Звания",
|
||||
"description": "Справочник магазина званий",
|
||||
"url": "/titles",
|
||||
},
|
||||
{
|
||||
"title": "Админы",
|
||||
"description": "Доступ к веб-панели",
|
||||
|
|
@ -41,6 +41,11 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
|
|||
"description": "События из ClickHouse с фильтрами",
|
||||
"url": "/logs",
|
||||
},
|
||||
{
|
||||
"title": "Отправить",
|
||||
"description": "Сообщение в чат от имени бота",
|
||||
"url": "/send",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
"""Предпросмотр шаблонов и сообщений (HTMX)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from admin.preview import SAMPLE_CONTEXT
|
||||
from admin.security import check_csrf
|
||||
from common.texts.render import MAX_MESSAGE_LENGTH, TemplateError, render, validate
|
||||
|
||||
router = APIRouter(tags=["preview"])
|
||||
|
||||
|
||||
@router.post("/preview", response_class=HTMLResponse)
|
||||
async def preview_message(request: Request) -> HTMLResponse:
|
||||
"""Отрендерить текст с тестовыми значениями; результат — в sandbox-iframe."""
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
text = str(form.get("text", ""))
|
||||
|
||||
unknown = validate(text)
|
||||
if unknown:
|
||||
return HTMLResponse(
|
||||
'<p class="text-sm text-red-600">Неизвестные теги: ' + html.escape(", ".join(unknown)) + "</p>"
|
||||
)
|
||||
|
||||
try:
|
||||
rendered = render(text, SAMPLE_CONTEXT)
|
||||
except TemplateError as exc: # pragma: no cover - validate уже отсекает неизвестные теги
|
||||
return HTMLResponse(f'<p class="text-sm text-red-600">{html.escape(str(exc))}</p>')
|
||||
|
||||
if len(rendered) > MAX_MESSAGE_LENGTH:
|
||||
return HTMLResponse(
|
||||
f'<p class="text-sm text-red-600">Текст длиннее лимита Telegram: '
|
||||
f"{len(rendered)} из {MAX_MESSAGE_LENGTH} символов.</p>"
|
||||
)
|
||||
|
||||
return HTMLResponse(
|
||||
'<iframe sandbox class="h-64 w-full rounded border border-slate-200 bg-white" '
|
||||
f'srcdoc="{html.escape(rendered, quote=True)}"></iframe>'
|
||||
)
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
"""Ручная отправка сообщения в основной чат от имени бота."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
from admin import telegram
|
||||
from admin.buttons import MAX_BUTTONS, parse_buttons
|
||||
from admin.media import read_image
|
||||
from admin.security import check_csrf
|
||||
from common import __version__
|
||||
from common.repositories import admin_messages_repo
|
||||
from common.texts.render import MAX_MESSAGE_LENGTH, TAG_REGISTRY, validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/send", tags=["send"])
|
||||
|
||||
#: Лимит подписи к фото.
|
||||
MAX_CAPTION = 1024
|
||||
|
||||
|
||||
async def _recent() -> list[dict[str, Any]]:
|
||||
try:
|
||||
return await admin_messages_repo.recent(limit=5)
|
||||
except Exception:
|
||||
logger.warning("Не удалось прочитать журнал отправок", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
async def _render(
|
||||
request: Request,
|
||||
*,
|
||||
text: str = "",
|
||||
buttons_text: str = "",
|
||||
errors: list[str] | None = None,
|
||||
saved: bool = False,
|
||||
status_code: int = 200,
|
||||
) -> HTMLResponse:
|
||||
settings = request.app.state.settings
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="send/form.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"text": text,
|
||||
"buttons_text": buttons_text,
|
||||
"errors": errors or [],
|
||||
"saved": saved,
|
||||
"chat_id": settings.chat_id,
|
||||
"max_message": MAX_MESSAGE_LENGTH,
|
||||
"max_caption": MAX_CAPTION,
|
||||
"max_buttons": MAX_BUTTONS,
|
||||
"tags": TAG_REGISTRY,
|
||||
"recent": await _recent(),
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def send_form(request: Request, saved: bool = False) -> HTMLResponse:
|
||||
return await _render(request, saved=saved)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def send_message(request: Request) -> Response:
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
text = str(form.get("text", ""))
|
||||
raw_buttons = str(form.get("buttons", ""))
|
||||
buttons, errors = parse_buttons(raw_buttons)
|
||||
|
||||
unknown = validate(text)
|
||||
if unknown:
|
||||
errors.append("Неизвестные теги: " + ", ".join(unknown))
|
||||
|
||||
settings = request.app.state.settings
|
||||
photo: tuple[bytes, str, str] | None = None
|
||||
upload = form.get("image")
|
||||
if isinstance(upload, UploadFile) and upload.filename:
|
||||
try:
|
||||
data, mime, extension = await read_image(upload)
|
||||
photo = (data, mime, f"upload{extension}")
|
||||
except ValueError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
limit = MAX_CAPTION if photo else MAX_MESSAGE_LENGTH
|
||||
if len(text) > limit:
|
||||
errors.append(f"С фото — не больше {MAX_CAPTION} символов, без фото — {MAX_MESSAGE_LENGTH}")
|
||||
if not text.strip() and photo is None:
|
||||
errors.append("Нужен текст или картинка")
|
||||
if not settings.chat_id:
|
||||
errors.append("CHAT_ID не задан")
|
||||
|
||||
if errors:
|
||||
return await _render(
|
||||
request,
|
||||
text=text,
|
||||
buttons_text=raw_buttons,
|
||||
errors=errors,
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
success = True
|
||||
error_text: str | None = None
|
||||
try:
|
||||
await telegram.send_to_chat(
|
||||
settings,
|
||||
text=text,
|
||||
buttons=buttons,
|
||||
photo=photo,
|
||||
)
|
||||
except telegram.TelegramError as exc:
|
||||
success = False
|
||||
error_text = str(exc)
|
||||
except Exception:
|
||||
success = False
|
||||
error_text = "Не удалось отправить сообщение"
|
||||
logger.exception("Ошибка отправки сообщения из админки")
|
||||
|
||||
try:
|
||||
await admin_messages_repo.log_message(
|
||||
editor_id=int(request.state.admin["telegram_id"]),
|
||||
chat_id=settings.chat_id,
|
||||
text=text,
|
||||
buttons=buttons,
|
||||
has_image=photo is not None,
|
||||
success=success,
|
||||
error=error_text,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Не удалось записать журнал отправки", exc_info=True)
|
||||
|
||||
if not success:
|
||||
return await _render(
|
||||
request,
|
||||
text=text,
|
||||
buttons_text=raw_buttons,
|
||||
errors=[f"Telegram: {error_text}"],
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
return RedirectResponse(url="/send?saved=true", status_code=303)
|
||||
|
|
@ -11,7 +11,8 @@ from fastapi import APIRouter, HTTPException, Request, Response
|
|||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
from admin.media import media_path
|
||||
from admin.buttons import MAX_BUTTONS, parse_buttons
|
||||
from admin.media import media_path, read_image
|
||||
from admin.security import check_csrf
|
||||
from common import __version__
|
||||
from common.repositories import sequences_repo
|
||||
|
|
@ -26,12 +27,6 @@ KEY_RE = re.compile(r"^[a-z][a-z0-9_.-]{1,31}$")
|
|||
|
||||
#: Лимит подписи к фото.
|
||||
MAX_CAPTION = 1024
|
||||
#: Сколько кнопок можно повесить на одно сообщение.
|
||||
MAX_BUTTONS = 8
|
||||
#: Допустимые типы картинок.
|
||||
ALLOWED_IMAGES = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||
#: Максимальный размер картинки.
|
||||
MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
def _editor_id(request: Request) -> int:
|
||||
|
|
@ -44,52 +39,10 @@ def _prepare_items(items: list[dict[str, Any]]) -> None:
|
|||
item["buttons_text"] = "\n".join(f"{button['text']} | {button['url']}" for button in item["buttons"])
|
||||
|
||||
|
||||
def parse_buttons(raw: str) -> tuple[list[tuple[str, str]], list[str]]:
|
||||
"""Разобрать кнопки: по одной в строке, формат ``Текст | https://…``."""
|
||||
buttons: list[tuple[str, str]] = []
|
||||
errors: list[str] = []
|
||||
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if "|" not in line:
|
||||
errors.append(f"Кнопка без разделителя «|»: {line}")
|
||||
continue
|
||||
text, url = (part.strip() for part in line.split("|", 1))
|
||||
if not text:
|
||||
errors.append("У кнопки пустой текст")
|
||||
continue
|
||||
if not url.startswith(("https://", "http://")):
|
||||
errors.append(f"Ссылка кнопки должна начинаться с http(s): {url}")
|
||||
continue
|
||||
if len(text) > 64:
|
||||
errors.append("Текст кнопки длиннее 64 символов")
|
||||
continue
|
||||
buttons.append((text, url))
|
||||
|
||||
if len(buttons) > MAX_BUTTONS:
|
||||
errors.append(f"Не больше {MAX_BUTTONS} кнопок на сообщение")
|
||||
return buttons, errors
|
||||
|
||||
|
||||
def _image_error(upload: UploadFile) -> str | None:
|
||||
content_type = (upload.content_type or "").lower()
|
||||
if content_type not in ALLOWED_IMAGES:
|
||||
return "Картинка должна быть JPEG, PNG или WebP"
|
||||
return None
|
||||
|
||||
|
||||
async def _save_image(settings: Any, upload: UploadFile, key: str, item_id: int) -> str:
|
||||
error = _image_error(upload)
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
data, _, extension = await read_image(upload)
|
||||
|
||||
data = await upload.read()
|
||||
if len(data) > MAX_IMAGE_BYTES:
|
||||
raise ValueError("Картинка больше 5 МБ")
|
||||
|
||||
name = f"{key}_{item_id}_{uuid.uuid4().hex}{ALLOWED_IMAGES[(upload.content_type or '').lower()]}"
|
||||
name = f"{key}_{item_id}_{uuid.uuid4().hex}{extension}"
|
||||
path = media_path(settings, name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
"""Раздел «Звания»: справочник магазина званий."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from admin.security import check_csrf
|
||||
from common import __version__
|
||||
from common.repositories import titles_repo
|
||||
|
||||
router = APIRouter(prefix="/titles", tags=["titles"])
|
||||
|
||||
MAX_TITLE_LENGTH = 100
|
||||
|
||||
|
||||
def _parse(title: str, price: str, visible: bool) -> tuple[dict[str, object] | None, list[str]]:
|
||||
errors: list[str] = []
|
||||
title = title.strip()
|
||||
if not title:
|
||||
errors.append("Название не может быть пустым")
|
||||
elif len(title) > MAX_TITLE_LENGTH:
|
||||
errors.append(f"Название длиннее {MAX_TITLE_LENGTH} символов")
|
||||
|
||||
try:
|
||||
price_value = int(price)
|
||||
except (TypeError, ValueError):
|
||||
price_value = -1
|
||||
errors.append("Цена должна быть целым числом")
|
||||
if price_value < 0 and "Цена должна быть целым числом" not in errors:
|
||||
errors.append("Цена не может быть отрицательной")
|
||||
|
||||
if errors:
|
||||
return None, errors
|
||||
return {"title": title, "price": price_value, "visible": visible}, []
|
||||
|
||||
|
||||
async def _render(
|
||||
request: Request,
|
||||
*,
|
||||
errors: list[str] | None = None,
|
||||
form: dict[str, str] | None = None,
|
||||
saved: str | None = None,
|
||||
status_code: int = 200,
|
||||
) -> HTMLResponse:
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="titles/list.html",
|
||||
context={
|
||||
"version": __version__,
|
||||
"titles": await titles_repo.list_titles(),
|
||||
"errors": errors or [],
|
||||
"form": form or {"title": "", "price": ""},
|
||||
"saved": saved,
|
||||
"max_title_length": MAX_TITLE_LENGTH,
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def list_titles(request: Request, saved: str | None = None) -> HTMLResponse:
|
||||
return await _render(request, saved=saved)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_title(request: Request) -> Response:
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
raw_title = str(form.get("title", ""))
|
||||
raw_price = str(form.get("price", ""))
|
||||
data, errors = _parse(raw_title, raw_price, True)
|
||||
if errors:
|
||||
return await _render(
|
||||
request,
|
||||
errors=errors,
|
||||
form={"title": raw_title, "price": raw_price},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
await titles_repo.create_title(str(data["title"]), int(data["price"]), True)
|
||||
return RedirectResponse(url="/titles?saved=created", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{title_id}")
|
||||
async def update_title(request: Request, title_id: int) -> Response:
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
data, errors = _parse(
|
||||
str(form.get("title", "")),
|
||||
str(form.get("price", "")),
|
||||
form.get("visible") is not None,
|
||||
)
|
||||
if errors:
|
||||
return await _render(request, errors=errors, status_code=400)
|
||||
|
||||
if not await titles_repo.update_title(
|
||||
title_id,
|
||||
str(data["title"]),
|
||||
int(data["price"]),
|
||||
bool(data["visible"]),
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Звание не найдено")
|
||||
return RedirectResponse(url="/titles?saved=updated", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{title_id}/delete")
|
||||
async def delete_title(request: Request, title_id: int) -> Response:
|
||||
form = await request.form()
|
||||
check_csrf(request, str(form.get("csrf_token", "")))
|
||||
|
||||
used = await titles_repo.usage_count(title_id)
|
||||
if used:
|
||||
return await _render(
|
||||
request,
|
||||
errors=[f"Звание используется {used} игроками — скройте его вместо удаления"],
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not await titles_repo.delete_title(title_id):
|
||||
raise HTTPException(status_code=404, detail="Звание не найдено")
|
||||
return RedirectResponse(url="/titles?saved=deleted", status_code=303)
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -22,6 +23,16 @@ class TelegramError(RuntimeError):
|
|||
"""Telegram отклонил запрос или недоступен."""
|
||||
|
||||
|
||||
def _check(response: httpx.Response) -> Any:
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise TelegramError("Telegram вернул некорректный ответ") from exc
|
||||
if not data.get("ok"):
|
||||
raise TelegramError(str(data.get("description") or "Telegram отклонил запрос"))
|
||||
return data.get("result")
|
||||
|
||||
|
||||
async def _call(settings: AdminSettings, method: str, payload: dict[str, Any]) -> Any:
|
||||
if not settings.bot_token:
|
||||
raise TelegramError("BOT_TOKEN не задан")
|
||||
|
|
@ -32,15 +43,25 @@ async def _call(settings: AdminSettings, method: str, payload: dict[str, Any]) -
|
|||
response = await client.post(url, json=payload)
|
||||
except httpx.HTTPError as exc:
|
||||
raise TelegramError(f"Telegram недоступен: {exc}") from exc
|
||||
return _check(response)
|
||||
|
||||
|
||||
async def _call_multipart(
|
||||
settings: AdminSettings,
|
||||
method: str,
|
||||
data: dict[str, str],
|
||||
files: dict[str, tuple[str, bytes, str]],
|
||||
) -> Any:
|
||||
if not settings.bot_token:
|
||||
raise TelegramError("BOT_TOKEN не задан")
|
||||
|
||||
url = _API.format(token=settings.bot_token, method=method)
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise TelegramError("Telegram вернул некорректный ответ") from exc
|
||||
|
||||
if not data.get("ok"):
|
||||
raise TelegramError(str(data.get("description") or "Telegram отклонил запрос"))
|
||||
return data.get("result")
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, data=data, files=files)
|
||||
except httpx.HTTPError as exc:
|
||||
raise TelegramError(f"Telegram недоступен: {exc}") from exc
|
||||
return _check(response)
|
||||
|
||||
|
||||
async def kick_member(settings: AdminSettings, user_id: int) -> None:
|
||||
|
|
@ -81,3 +102,57 @@ async def send_alert(settings: AdminSettings, text: str) -> None:
|
|||
)
|
||||
except TelegramError:
|
||||
logger.warning("Не удалось отправить уведомление в чат логов", exc_info=True)
|
||||
|
||||
|
||||
async def send_to_chat(
|
||||
settings: AdminSettings,
|
||||
*,
|
||||
text: str,
|
||||
buttons: list[tuple[str, str]],
|
||||
photo: tuple[bytes, str, str] | None = None,
|
||||
) -> None:
|
||||
"""Отправить сообщение в основной чат от имени бота.
|
||||
|
||||
``photo`` — (данные, mime, имя файла). При фото длинный текст уходит
|
||||
отдельным сообщением (лимит подписи 1024).
|
||||
"""
|
||||
if not settings.chat_id:
|
||||
raise TelegramError("CHAT_ID не задан")
|
||||
|
||||
rows = [[{"text": title, "url": url}] for title, url in buttons]
|
||||
markup = {"inline_keyboard": rows} if rows else None
|
||||
|
||||
if photo is None:
|
||||
await _call(
|
||||
settings,
|
||||
"sendMessage",
|
||||
{
|
||||
"chat_id": settings.chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": markup,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
data, mime, filename = photo
|
||||
fields: dict[str, str] = {"chat_id": str(settings.chat_id), "parse_mode": "HTML"}
|
||||
caption = text if text and len(text) <= 1024 else ""
|
||||
if caption:
|
||||
fields["caption"] = caption
|
||||
if markup:
|
||||
fields["reply_markup"] = json.dumps(markup)
|
||||
|
||||
await _call_multipart(settings, "sendPhoto", fields, {"photo": (filename, data, mime)})
|
||||
|
||||
if text and not caption:
|
||||
await _call(
|
||||
settings,
|
||||
"sendMessage",
|
||||
{
|
||||
"chat_id": settings.chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": markup,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@
|
|||
<th class="px-4 py-3 font-medium">Username</th>
|
||||
<th class="px-4 py-3 font-medium">Роль</th>
|
||||
<th class="px-4 py-3 font-medium">Доступ</th>
|
||||
<th class="px-4 py-3 font-medium">Последний вход</th>
|
||||
<th class="hidden px-4 py-3 font-medium lg:table-cell">Последний вход</th>
|
||||
<th class="px-4 py-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -116,7 +116,7 @@
|
|||
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключен</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
<td class="hidden px-4 py-3 text-slate-600 lg:table-cell">
|
||||
{{ admin.last_login_at.strftime("%d.%m.%Y %H:%M") if admin.last_login_at else "—" }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@
|
|||
<h1 class="text-2xl font-semibold">Вход в админку</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">Доступ только администраторам клана.</p>
|
||||
|
||||
{% if deeplink_error %}
|
||||
<p class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
Deep-link вход не настроен: проверьте <span class="font-mono">BOT_USERNAME</span>.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if configured %}
|
||||
<div class="mt-8 flex justify-center">
|
||||
<script
|
||||
|
|
@ -18,11 +24,51 @@
|
|||
data-request-access="write"
|
||||
></script>
|
||||
</div>
|
||||
{% else %}
|
||||
{% endif %}
|
||||
|
||||
{% if deeplink %}
|
||||
<div class="mt-6 border-t border-slate-100 pt-5">
|
||||
<a
|
||||
href="/auth/deeplink/start"
|
||||
class="inline-block rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
>
|
||||
Открыть Telegram
|
||||
</a>
|
||||
<p class="mt-2 text-xs text-slate-500">Подтвердите вход в установленном приложении Telegram.</p>
|
||||
<p id="deeplink-status" class="mt-2 text-sm text-sky-700">
|
||||
{% if deeplink_pending %}Ожидаем подтверждение в Telegram…{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not configured and not deeplink %}
|
||||
<p class="mt-6 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
Вход не настроен: задайте <span class="font-mono">BOT_TOKEN</span>,
|
||||
<span class="font-mono">BOT_USERNAME</span> и <span class="font-mono">ADMIN_PUBLIC_URL</span>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if deeplink_pending %}
|
||||
<script>
|
||||
(function () {
|
||||
var status = document.getElementById("deeplink-status");
|
||||
var timer = setInterval(function () {
|
||||
fetch("/auth/deeplink/status", { credentials: "same-origin" })
|
||||
.then(function (response) { return response.json(); })
|
||||
.then(function (data) {
|
||||
if (data.status === "ok") {
|
||||
clearInterval(timer);
|
||||
window.location.href = "/";
|
||||
} else if (data.status === "denied") {
|
||||
clearInterval(timer);
|
||||
if (status) status.textContent = "У вас нет доступа к админке.";
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
}, 2000);
|
||||
setTimeout(function () { clearInterval(timer); }, 300000);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -9,25 +9,39 @@
|
|||
</head>
|
||||
<body class="min-h-screen bg-slate-100 text-slate-900">
|
||||
<header class="border-b border-slate-200 bg-white">
|
||||
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
|
||||
<a href="/" class="text-lg font-semibold">DispelEvilBot</a>
|
||||
<nav class="flex items-center gap-4 text-sm">
|
||||
<a href="/" class="text-slate-600 hover:text-slate-900">Главная</a>
|
||||
<a href="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a>
|
||||
<a href="/sequences" class="text-slate-600 hover:text-slate-900">Последовательности</a>
|
||||
<a href="/schedules" class="text-slate-600 hover:text-slate-900">Расписания</a>
|
||||
<a href="/users" class="text-slate-600 hover:text-slate-900">Пользователи</a>
|
||||
<a href="/admins" class="text-slate-600 hover:text-slate-900">Админы</a>
|
||||
<a href="/logs" class="text-slate-600 hover:text-slate-900">Логи</a>
|
||||
{% if request.session.get('admin_id') %}
|
||||
<span class="text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span>
|
||||
<form method="post" action="/logout">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<button type="submit" class="text-slate-600 hover:text-slate-900">Выйти</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<span class="text-slate-500">админка · v{{ version }}</span>
|
||||
</nav>
|
||||
<div class="mx-auto max-w-5xl px-4 py-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<a href="/" class="text-lg font-semibold">DispelEvilBot</a>
|
||||
<button
|
||||
id="nav-toggle"
|
||||
type="button"
|
||||
class="rounded-lg border border-slate-300 px-3 py-1.5 text-sm text-slate-600 sm:hidden"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Меню
|
||||
</button>
|
||||
<nav
|
||||
id="nav-menu"
|
||||
class="hidden w-full flex-col gap-2 border-t border-slate-100 pt-2 text-sm sm:flex sm:w-auto sm:flex-row sm:items-center sm:gap-4 sm:border-0 sm:pt-0"
|
||||
>
|
||||
<a href="/" class="py-1 text-slate-600 hover:text-slate-900">Главная</a>
|
||||
<a href="/sequences" class="py-1 text-slate-600 hover:text-slate-900">Последовательности</a>
|
||||
<a href="/schedules" class="py-1 text-slate-600 hover:text-slate-900">Расписания</a>
|
||||
<a href="/users" class="py-1 text-slate-600 hover:text-slate-900">Пользователи</a>
|
||||
<a href="/titles" class="py-1 text-slate-600 hover:text-slate-900">Звания</a>
|
||||
<a href="/admins" class="py-1 text-slate-600 hover:text-slate-900">Админы</a>
|
||||
<a href="/logs" class="py-1 text-slate-600 hover:text-slate-900">Логи</a>
|
||||
<a href="/send" class="py-1 text-slate-600 hover:text-slate-900">Отправить</a>
|
||||
{% if request.session.get('admin_id') %}
|
||||
<span class="py-1 text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span>
|
||||
<form method="post" action="/logout" class="py-1">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<button type="submit" class="text-slate-600 hover:text-slate-900">Выйти</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<span class="py-1 text-slate-500">админка · v{{ version }}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -39,6 +53,16 @@
|
|||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var navToggle = document.getElementById("nav-toggle");
|
||||
var navMenu = document.getElementById("nav-menu");
|
||||
if (navToggle && navMenu) {
|
||||
navToggle.addEventListener("click", function () {
|
||||
var hidden = navMenu.classList.toggle("hidden");
|
||||
navMenu.classList.toggle("flex", !hidden);
|
||||
navToggle.setAttribute("aria-expanded", String(!hidden));
|
||||
});
|
||||
}
|
||||
|
||||
var tags = {
|
||||
b: ["<b>", "</b>"],
|
||||
i: ["<i>", "</i>"],
|
||||
|
|
|
|||
|
|
@ -72,8 +72,8 @@
|
|||
<th class="px-4 py-3 font-medium">Время</th>
|
||||
<th class="px-4 py-3 font-medium">Событие</th>
|
||||
<th class="px-4 py-3 font-medium">Пользователь</th>
|
||||
<th class="px-4 py-3 font-medium">Чат</th>
|
||||
<th class="px-4 py-3 font-medium">Версия</th>
|
||||
<th class="hidden px-4 py-3 font-medium lg:table-cell">Чат</th>
|
||||
<th class="hidden px-4 py-3 font-medium xl:table-cell">Версия</th>
|
||||
<th class="px-4 py-3 font-medium">Payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -93,8 +93,8 @@
|
|||
<span class="text-slate-400">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ event.chat_id or "—" }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-500">{{ event.bot_version }}</td>
|
||||
<td class="hidden px-4 py-3 font-mono text-xs text-slate-500 lg:table-cell">{{ event.chat_id or "—" }}</td>
|
||||
<td class="hidden px-4 py-3 text-xs text-slate-500 xl:table-cell">{{ event.bot_version }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if event.payload_pretty %}
|
||||
<details>
|
||||
|
|
|
|||
|
|
@ -1,107 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ template.key }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500"><a href="/messages" class="hover:underline">← Сообщения</a></p>
|
||||
<h1 class="mt-1 font-mono text-xl font-semibold">{{ template.key }}</h1>
|
||||
<p class="text-sm text-slate-600">{{ template.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if saved %}
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
Текст сохранён.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/messages/{{ template.key }}" class="mt-6 space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Текст шаблона</label>
|
||||
<div class="mt-1">
|
||||
{% with editor_id="text", editor_name="text", editor_value=text, editor_max=max_length, editor_rows=18 %}
|
||||
{% include "partials/editor.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка.
|
||||
</p>
|
||||
{% if template.key == 'welcome.text' %}
|
||||
<p class="mt-2 rounded border border-sky-200 bg-sky-50 p-2 text-xs text-sky-800">
|
||||
Первая строка этого шаблона уходит подписью к фото, остальное — одним сообщением
|
||||
вместе с <span class="font-mono">welcome.text2</span> и <span class="font-mono">welcome.text3</span>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
hx-post="/messages/preview"
|
||||
hx-include="closest form"
|
||||
hx-target="#preview"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Предпросмотр
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-medium">Предпросмотр</h2>
|
||||
<p class="text-sm text-slate-600">Тестовые значения: @test_user, Ветеран, 3 уровень, 1500 DE.</p>
|
||||
<div id="preview" class="mt-2">
|
||||
<p class="text-sm text-slate-400">Нажмите «Предпросмотр», чтобы увидеть текст с подставленными тегами.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="mt-8 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<summary class="cursor-pointer text-sm font-medium text-slate-700">Доступные теги ({{ tags | length }})</summary>
|
||||
<dl class="mt-3 grid gap-x-6 gap-y-1 text-sm sm:grid-cols-2">
|
||||
{% for name, description in tags.items() %}
|
||||
<div class="flex gap-2">
|
||||
<dt class="font-mono text-sky-700">{ {{ name }} }</dt>
|
||||
<dd class="text-slate-600">{{ description }}</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</details>
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-medium">История изменений</h2>
|
||||
{% if history %}
|
||||
<ul class="mt-3 space-y-2">
|
||||
{% for item in history %}
|
||||
<li class="rounded-lg border border-slate-200 bg-white p-3 text-sm">
|
||||
<div class="flex items-center justify-between text-slate-500">
|
||||
<span>{{ item.changed_at.strftime("%d.%m.%Y %H:%M") }}</span>
|
||||
<span>редактор: {{ item.editor_id or "—" }}</span>
|
||||
</div>
|
||||
<details class="mt-2">
|
||||
<summary class="cursor-pointer text-slate-600">старое → новое</summary>
|
||||
<div class="mt-2 grid gap-3 sm:grid-cols-2">
|
||||
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs whitespace-pre-wrap">{{ item.old_text or "" }}</pre>
|
||||
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs whitespace-pre-wrap">{{ item.new_text }}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="mt-2 text-sm text-slate-500">Изменений ещё не было.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Сообщения{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Сообщения</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-slate-500">{{ templates | length }} шаблонов</span>
|
||||
<a href="/messages/new" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Новый шаблон
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-slate-600">
|
||||
Тексты хранятся в Postgres. В редакторе можно проверить подстановку тегов
|
||||
<span class="font-mono">{tag}</span> на тестовых данных.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
Изменения применяются ботом без перезапуска — в течение <span class="font-mono">TEMPLATE_CACHE_TTL</span>
|
||||
секунд (по умолчанию 45). Если БД недоступна, бот использует тексты из кода.
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Ключ</th>
|
||||
<th class="px-4 py-3 font-medium">Описание</th>
|
||||
<th class="px-4 py-3 font-medium">Длина</th>
|
||||
<th class="px-4 py-3 font-medium">Изменён</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for template in templates %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3">
|
||||
<a href="/messages/{{ template.key }}" class="font-mono text-sky-700 hover:underline">{{ template.key }}</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ template.description }}</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ template.text_length }}</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ template.updated_at.strftime("%d.%m.%Y %H:%M") }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Новый шаблон{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="text-sm text-slate-500"><a href="/messages" class="hover:underline">← Сообщения</a></p>
|
||||
<h1 class="mt-1 text-2xl font-semibold">Новый шаблон</h1>
|
||||
|
||||
{% if errors %}
|
||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/messages" class="mt-6 max-w-3xl space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="key" class="text-sm font-medium text-slate-700">Ключ</label>
|
||||
<input
|
||||
id="key"
|
||||
name="key"
|
||||
value="{{ key }}"
|
||||
placeholder="например, shop.rules"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 font-mono text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-500">Латиница, цифры, точка, подчёркивание, дефис. По ключу шаблон выбирается в расписаниях.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="description" class="text-sm font-medium text-slate-700">Описание</label>
|
||||
<input
|
||||
id="description"
|
||||
name="description"
|
||||
value="{{ description }}"
|
||||
maxlength="{{ max_description_length }}"
|
||||
placeholder="для себя и админов"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Текст шаблона</label>
|
||||
<div class="mt-1">
|
||||
{% with editor_id="text", editor_name="text", editor_value=text, editor_max=max_length, editor_rows=16 %}
|
||||
{% include "partials/editor.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="enabled" {% if enabled %}checked{% endif %} />
|
||||
Включён (бот читает текст)
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Создать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
hx-post="/messages/preview"
|
||||
hx-include="closest form"
|
||||
hx-target="#preview"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Предпросмотр
|
||||
</button>
|
||||
<a href="/messages" class="text-sm text-slate-600 hover:underline">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-8 max-w-3xl">
|
||||
<h2 class="text-lg font-medium">Предпросмотр</h2>
|
||||
<div id="preview" class="mt-2">
|
||||
<p class="text-sm text-slate-400">Нажмите «Предпросмотр», чтобы увидеть текст с тестовыми тегами.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="mt-8 max-w-3xl rounded-lg border border-slate-200 bg-white p-4">
|
||||
<summary class="cursor-pointer text-sm font-medium text-slate-700">Доступные теги ({{ tags | length }})</summary>
|
||||
<dl class="mt-3 grid gap-x-6 gap-y-1 text-sm sm:grid-cols-2">
|
||||
{% for name, description in tags.items() %}
|
||||
<div class="flex gap-2">
|
||||
<dt class="font-mono text-sky-700">{ {{ name }} }</dt>
|
||||
<dd class="text-slate-600">{{ description }}</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</details>
|
||||
{% endblock %}
|
||||
|
|
@ -20,9 +20,9 @@
|
|||
<thead class="bg-slate-50 text-left text-slate-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Время</th>
|
||||
<th class="px-4 py-3 font-medium">Дни</th>
|
||||
<th class="hidden px-4 py-3 font-medium sm:table-cell">Дни</th>
|
||||
<th class="px-4 py-3 font-medium">Шаблон</th>
|
||||
<th class="px-4 py-3 font-medium">Таймзона</th>
|
||||
<th class="hidden px-4 py-3 font-medium lg:table-cell">Таймзона</th>
|
||||
<th class="px-4 py-3 font-medium">Статус</th>
|
||||
<th class="px-4 py-3 font-medium"></th>
|
||||
</tr>
|
||||
|
|
@ -31,12 +31,12 @@
|
|||
{% for schedule in schedules %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-mono">{{ "%02d:%02d" | format(schedule.hour, schedule.minute) }}</td>
|
||||
<td class="px-4 py-3">{{ format_weekdays(schedule.weekdays) }}</td>
|
||||
<td class="hidden px-4 py-3 sm:table-cell">{{ format_weekdays(schedule.weekdays) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-mono text-sky-700">{{ schedule.template_key }}</div>
|
||||
<div class="text-xs text-slate-500">{{ schedule.description or schedule.template_description }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ schedule.timezone }}</td>
|
||||
<td class="hidden px-4 py-3 text-slate-600 lg:table-cell">{{ schedule.timezone }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if schedule.enabled %}
|
||||
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Отправить сообщение{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="text-2xl font-semibold">Отправить сообщение</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
Сообщение уйдёт в основной чат <span class="font-mono">{{ chat_id }}</span> от имени бота.
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
Сообщение отправлено.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<form
|
||||
method="post"
|
||||
action="/send"
|
||||
enctype="multipart/form-data"
|
||||
class="mt-6 max-w-3xl space-y-4"
|
||||
onsubmit="return confirm('Отправить сообщение в чат?');"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-slate-700">Текст</label>
|
||||
<div class="mt-1">
|
||||
{% with editor_id="send-text", editor_name="text", editor_value=text, editor_max=max_message, editor_rows=12 %}
|
||||
{% include "partials/editor.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">Кнопки — по одной в строке: <span class="font-mono">Текст | https://…</span></label>
|
||||
<textarea
|
||||
name="buttons"
|
||||
rows="3"
|
||||
placeholder="Канал | https://t.me/…"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 p-2 font-mono text-xs focus:border-sky-500 focus:outline-none"
|
||||
>{{ buttons_text }}</textarea>
|
||||
<p class="mt-1 text-xs text-slate-500">До {{ max_buttons }} кнопок.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">Картинка (JPEG/PNG/WebP, до 5 МБ)</label>
|
||||
<input type="file" name="image" accept="image/jpeg,image/png,image/webp"
|
||||
class="mt-2 block w-full text-xs text-slate-600" />
|
||||
<p class="mt-1 text-xs text-slate-500">С фото текст уходит подписью — не больше {{ max_caption }} символов.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Отправить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
hx-post="/preview"
|
||||
hx-include="closest form"
|
||||
hx-target="#preview"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Предпросмотр
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-8 max-w-3xl">
|
||||
<h2 class="text-lg font-medium">Предпросмотр</h2>
|
||||
<div id="preview" class="mt-2">
|
||||
<p class="text-sm text-slate-400">Нажмите «Предпросмотр», чтобы увидеть текст с тестовыми тегами.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10">
|
||||
<h2 class="text-lg font-medium">Последние отправки</h2>
|
||||
{% if recent %}
|
||||
<div class="mt-3 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Когда</th>
|
||||
<th class="px-4 py-3 font-medium">Текст</th>
|
||||
<th class="px-4 py-3 font-medium">Фото</th>
|
||||
<th class="px-4 py-3 font-medium">Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for item in recent %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 text-slate-600">{{ item.sent_at.strftime("%d.%m.%Y %H:%M") }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ item.preview }}</td>
|
||||
<td class="px-4 py-3">{{ "да" if item.has_image else "—" }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if item.success %}
|
||||
<span class="text-emerald-700">отправлено</span>
|
||||
{% else %}
|
||||
<span class="text-red-600">{{ item.error or "ошибка" }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="mt-2 text-sm text-slate-500">Отправок ещё не было.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Звания{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="text-2xl font-semibold">Звания</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">Справочник магазина: название, цена и видимость.</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
{% if saved == 'created' %}Звание добавлено.
|
||||
{% elif saved == 'updated' %}Звание обновлено.
|
||||
{% elif saved == 'deleted' %}Звание удалено.
|
||||
{% else %}Изменения сохранены.{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/titles" class="mt-6 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<h2 class="text-lg font-medium">Новое звание</h2>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-4">
|
||||
<div class="sm:col-span-2">
|
||||
<label for="title" class="text-xs text-slate-500">Название</label>
|
||||
<input id="title" name="title" value="{{ form.title }}" maxlength="{{ max_title_length }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="price" class="text-xs text-slate-500">Цена, DE</label>
|
||||
<input id="price" name="price" type="number" min="0" value="{{ form.price }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 space-y-3">
|
||||
{% for title in titles %}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<form method="post" action="/titles/{{ title.id_title }}" class="grid gap-3 sm:grid-cols-5">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<div class="sm:col-span-2">
|
||||
<label class="text-xs text-slate-500">Название</label>
|
||||
<input name="title" value="{{ title.title }}" maxlength="{{ max_title_length }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">Цена, DE</label>
|
||||
<input name="price" type="number" min="0" value="{{ title.price }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
|
||||
</div>
|
||||
<label class="flex items-end gap-2 pb-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="visible" {% if title.visible %}checked{% endif %} /> видно
|
||||
</label>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-2 flex items-center justify-between text-xs text-slate-500">
|
||||
<span>id {{ title.id_title }} · используется: {{ title.used }}</span>
|
||||
<form method="post" action="/titles/{{ title.id_title }}/delete" onsubmit="return confirm('Удалить звание?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<button type="submit" class="text-red-600 hover:underline">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate-500">Званий пока нет — добавьте первое.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -35,9 +35,9 @@
|
|||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Ник</th>
|
||||
<th class="px-4 py-3 font-medium">Telegram</th>
|
||||
<th class="px-4 py-3 font-medium">Уровень</th>
|
||||
<th class="hidden px-4 py-3 font-medium sm:table-cell">Уровень</th>
|
||||
<th class="px-4 py-3 font-medium">Баланс</th>
|
||||
<th class="px-4 py-3 font-medium">Сообщения</th>
|
||||
<th class="hidden px-4 py-3 font-medium md:table-cell">Сообщения</th>
|
||||
<th class="px-4 py-3 font-medium">Подписка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -54,9 +54,9 @@
|
|||
{% if user.username %}<div>@{{ user.username }}</div>{% endif %}
|
||||
<div class="font-mono text-xs text-slate-500">{{ user.id_user }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">{{ user.lvl }}</td>
|
||||
<td class="hidden px-4 py-3 sm:table-cell">{{ user.lvl }}</td>
|
||||
<td class="px-4 py-3">{{ user.balance }}</td>
|
||||
<td class="px-4 py-3">{{ user.msg_count }}</td>
|
||||
<td class="hidden px-4 py-3 md:table-cell">{{ user.msg_count }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if user.subscribed %}
|
||||
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">да</span>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def create_dispatcher() -> Dispatcher:
|
|||
Порядок важен: сначала события участников и команды, затем магазин,
|
||||
и в конце — экономика (в ней есть catch-all обработчик сообщений).
|
||||
"""
|
||||
from bot.handlers import commands, economy, member_events, shop
|
||||
from bot.handlers import commands, economy, login, member_events, shop
|
||||
from bot.handlers.errors import register_errors
|
||||
from bot.middlewares import AnalyticsMiddleware
|
||||
|
||||
|
|
@ -33,6 +33,7 @@ def create_dispatcher() -> Dispatcher:
|
|||
# Внешний middleware: ловит все сообщения до фильтров (события `message`/`command`).
|
||||
dp.message.outer_middleware(AnalyticsMiddleware())
|
||||
dp.include_router(member_events.router)
|
||||
dp.include_router(login.router)
|
||||
dp.include_router(commands.router)
|
||||
dp.include_router(shop.router)
|
||||
dp.include_router(economy.router)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
#: Ожидаемый набор команд в меню (порядок = порядок в Telegram).
|
||||
BOT_COMMANDS: tuple[BotCommand, ...] = (
|
||||
BotCommand(command="start", description="Начать"),
|
||||
BotCommand(command="help", description="Помощь"),
|
||||
BotCommand(command="info", description="Инфо"),
|
||||
BotCommand(command="nick", description="Сменить ник"),
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ from aiogram.types import Message
|
|||
|
||||
from bot.services import access, stats, subscriptions
|
||||
from bot.services import users as users_service
|
||||
from bot.services.sequences import send_template
|
||||
from common.config import get_settings
|
||||
from common.eggs import is_egg_event_active
|
||||
from common.texts import system
|
||||
from common.texts.render import escape, format_tops, format_user_list, render
|
||||
from common.texts.store import get_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = Router(name="commands")
|
||||
|
|
@ -63,7 +63,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(await get_text("economy.nick_list"), context))
|
||||
await send_template(message.bot, message.chat.id, "economy.nick_list", context)
|
||||
|
||||
|
||||
@router.message(Command("кто", "who", ignore_case=True))
|
||||
|
|
@ -85,7 +85,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(await get_text("info.help"))
|
||||
await send_template(message.bot, message.chat.id, "info.help")
|
||||
|
||||
|
||||
@router.message(Command("инфо", "info", ignore_case=True))
|
||||
|
|
@ -113,7 +113,7 @@ async def info_handler(message: Message) -> None:
|
|||
f"<b>🥚 Количество яичек:</b> {escape(user_info.eggs)}" if is_egg_event_active(get_settings()) else ""
|
||||
),
|
||||
}
|
||||
await message.answer(render(await get_text("info.profile"), context))
|
||||
await send_template(message.bot, message.chat.id, "info.profile", context)
|
||||
|
||||
|
||||
@router.message(Command("рейтинг", "top", ignore_case=True))
|
||||
|
|
@ -122,7 +122,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(await get_text("economy.top"), context))
|
||||
await send_template(message.bot, message.chat.id, "economy.top", context)
|
||||
|
||||
|
||||
@router.message(Command("отписка", "unsub", ignore_case=True))
|
||||
|
|
|
|||
|
|
@ -11,11 +11,10 @@ from aiogram.types import Message
|
|||
|
||||
from bot.services import access, alerts
|
||||
from bot.services import economy as economy_service
|
||||
from bot.services.sequences import send_template
|
||||
from common.config import get_settings
|
||||
from common.eggs import egg_event_period, is_egg_event_active
|
||||
from common.texts import system
|
||||
from common.texts.render import render
|
||||
from common.texts.store import get_text
|
||||
|
||||
router = Router(name="economy")
|
||||
|
||||
|
|
@ -31,10 +30,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(await get_text("economy.no_purification"), context))
|
||||
await send_template(message.bot, message.chat.id, "economy.no_purification", context)
|
||||
else:
|
||||
context = {"money_add": result.money_add, "exp_add": result.exp_add}
|
||||
await message.answer(render(await get_text("economy.purification"), context))
|
||||
await send_template(message.bot, message.chat.id, "economy.purification", context)
|
||||
await alerts.notify_purification(
|
||||
message.bot,
|
||||
username=message.from_user.username,
|
||||
|
|
@ -58,9 +57,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(await get_text("economy.no_eggs"), context))
|
||||
await send_template(message.bot, message.chat.id, "economy.no_eggs", context)
|
||||
else:
|
||||
await message.answer(render(await get_text("economy.eggs"), {"eggs": result.eggs}))
|
||||
await send_template(message.bot, message.chat.id, "economy.eggs", {"eggs": result.eggs})
|
||||
|
||||
|
||||
@router.message()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
"""Вход в админку через deep-link из установленного Telegram."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||
|
||||
from common.config import get_settings
|
||||
from common.repositories import admin_login_tokens_repo, admins_repo
|
||||
from common.texts import system
|
||||
|
||||
router = Router(name="login")
|
||||
|
||||
|
||||
@router.message(Command("start"))
|
||||
async def start_handler(message: Message) -> None:
|
||||
"""Обработать /start; payload ``login_<token>`` подтверждает вход в админку."""
|
||||
if message.chat.type != "private" or message.from_user is None:
|
||||
return
|
||||
|
||||
parts = (message.text or "").split(maxsplit=1)
|
||||
payload = parts[1].strip() if len(parts) > 1 else ""
|
||||
if not payload.startswith("login_"):
|
||||
await message.answer(system.MSG_START)
|
||||
return
|
||||
|
||||
admin = await admins_repo.get_admin(message.from_user.id)
|
||||
if admin is None or not admin["enabled"]:
|
||||
await message.answer(system.MSG_LOGIN_DENIED)
|
||||
return
|
||||
|
||||
if not await admin_login_tokens_repo.confirm_token(payload[6:], message.from_user.id):
|
||||
await message.answer(system.MSG_LOGIN_INVALID)
|
||||
return
|
||||
|
||||
settings = get_settings()
|
||||
markup = None
|
||||
if settings.admin_public_url.startswith(("http://", "https://")):
|
||||
markup = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="Вернуться в админку",
|
||||
url=settings.admin_public_url.rstrip("/") + "/login",
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
await message.answer(system.MSG_LOGIN_CONFIRMED, reply_markup=markup)
|
||||
|
|
@ -11,10 +11,10 @@ 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 bot.services.sequences import send_template, template_text
|
||||
from common.config import get_settings
|
||||
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")
|
||||
|
|
@ -27,16 +27,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(await get_text("shop.main"), reply_markup=get_keyboard_main())
|
||||
await send_template(message.bot, message.chat.id, "shop.main", reply_markup=get_keyboard_main())
|
||||
|
||||
|
||||
async def _render_chapter(chapter: str) -> tuple[str, object] | None:
|
||||
if chapter == "main":
|
||||
return await get_text("shop.main"), get_keyboard_main()
|
||||
return await template_text("shop.main"), get_keyboard_main()
|
||||
if chapter == "title":
|
||||
return await get_text("shop.title"), await get_keyboard_title()
|
||||
return await template_text("shop.title"), await get_keyboard_title()
|
||||
if chapter == "donat":
|
||||
return await get_text("shop.donat"), await get_keyboard_donat()
|
||||
return await template_text("shop.donat"), await get_keyboard_donat()
|
||||
return None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ from zoneinfo import ZoneInfo
|
|||
from aiogram import Bot
|
||||
|
||||
from bot.services import users as users_service
|
||||
from bot.services.sequences import send_template
|
||||
from common.config import get_settings
|
||||
from common.repositories import schedules_repo
|
||||
from common.texts import format_usernames, render
|
||||
from common.texts.store import get_text
|
||||
from common.texts import format_usernames
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -99,23 +99,23 @@ async def _load_schedules() -> tuple[ScheduledMessage, ...]:
|
|||
return _last_good
|
||||
|
||||
|
||||
async def _send_broadcast(bot: Bot, template: str, subscribers: list[dict]) -> None:
|
||||
async def _send_broadcast(bot: Bot, template_key: str, subscribers: list[dict]) -> None:
|
||||
chunks = [subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)] or [[]]
|
||||
for chunk in chunks:
|
||||
text = render(template, {"usernames": format_usernames(chunk)})
|
||||
await bot.send_message(chat_id=get_settings().chat_id, text=text)
|
||||
await send_template(
|
||||
bot,
|
||||
get_settings().chat_id,
|
||||
template_key,
|
||||
{"usernames": format_usernames(chunk)},
|
||||
)
|
||||
|
||||
|
||||
async def _tick(bot: Bot, now: datetime) -> None:
|
||||
for schedule in await _load_schedules():
|
||||
if not schedule.matches(now):
|
||||
continue
|
||||
template = await get_text(schedule.template_key)
|
||||
if not template:
|
||||
logger.warning("Шаблон %s пуст или не найден — рассылка пропущена", schedule.template_key)
|
||||
continue
|
||||
subscribers = await users_service.list_subscribers()
|
||||
await _send_broadcast(bot, template, subscribers)
|
||||
await _send_broadcast(bot, schedule.template_key, subscribers)
|
||||
|
||||
|
||||
async def run_notifications(bot: Bot) -> None:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from aiogram.types import FSInputFile, InlineKeyboardButton, InlineKeyboardMarku
|
|||
from common.config import get_settings
|
||||
from common.repositories import sequences_repo
|
||||
from common.texts.render import render
|
||||
from common.texts.store import get_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -46,7 +47,14 @@ def _image_path(item: dict[str, Any]) -> Path | None:
|
|||
return None
|
||||
|
||||
|
||||
async def send_sequence(bot: Bot, chat_id: int, key: str, context: dict[str, Any]) -> bool:
|
||||
async def send_sequence(
|
||||
bot: Bot,
|
||||
chat_id: int,
|
||||
key: str,
|
||||
context: dict[str, Any],
|
||||
*,
|
||||
reply_markup: Any = None,
|
||||
) -> bool:
|
||||
"""Отправить последовательность. False — если она не настроена/пуста."""
|
||||
try:
|
||||
items = await sequences_repo.get_items(key)
|
||||
|
|
@ -58,10 +66,11 @@ async def send_sequence(bot: Bot, chat_id: int, key: str, context: dict[str, Any
|
|||
if not enabled:
|
||||
return False
|
||||
|
||||
for item in enabled:
|
||||
for index, item in enumerate(enabled):
|
||||
text = render(str(item.get("text") or ""), context) if item.get("text") else ""
|
||||
path = _image_path(item)
|
||||
markup = _markup(item.get("buttons") or [])
|
||||
is_last = index == len(enabled) - 1
|
||||
markup = reply_markup if is_last and reply_markup is not None else _markup(item.get("buttons") or [])
|
||||
|
||||
if path is not None:
|
||||
if text and len(text) > CAPTION_LIMIT:
|
||||
|
|
@ -78,3 +87,34 @@ async def send_sequence(bot: Bot, chat_id: int, key: str, context: dict[str, Any
|
|||
await bot.send_message(chat_id=chat_id, text=text, reply_markup=markup)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def template_text(key: str, context: dict[str, Any] | None = None) -> str:
|
||||
"""Текст первого включённого сообщения последовательности (для edit_message_text)."""
|
||||
try:
|
||||
items = await sequences_repo.get_items(key)
|
||||
except Exception:
|
||||
logger.warning("Не удалось прочитать последовательность %s", key, exc_info=True)
|
||||
items = []
|
||||
|
||||
for item in items:
|
||||
if item.get("enabled") and item.get("text"):
|
||||
return render(str(item["text"]), context or {})
|
||||
return render(await get_text(key), context or {})
|
||||
|
||||
|
||||
async def send_template(
|
||||
bot: Bot,
|
||||
chat_id: int,
|
||||
key: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
*,
|
||||
reply_markup: Any = None,
|
||||
) -> bool:
|
||||
"""Отправить последовательность, а при её отсутствии — старый шаблон из кода."""
|
||||
if await send_sequence(bot, chat_id, key, context or {}, reply_markup=reply_markup):
|
||||
return True
|
||||
|
||||
text = render(await get_text(key), context or {})
|
||||
await bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup)
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ class Settings(BaseSettings):
|
|||
#: Общий каталог картинок (Docker volume), который видят бот и админка.
|
||||
media_dir: str = "/app/media"
|
||||
|
||||
#: Публичный адрес админки (deep-link вход, ссылки из бота).
|
||||
admin_public_url: str = "http://localhost:8000"
|
||||
|
||||
#: ClickHouse (аналитика событий). CLICKHOUSE_ENABLED=false — полностью выключить.
|
||||
clickhouse_enabled: bool = True
|
||||
clickhouse_host: str = "localhost"
|
||||
|
|
@ -144,7 +147,7 @@ class AdminSettings(Settings):
|
|||
admin_port: int = 8000
|
||||
|
||||
#: Публичный адрес админки (для Telegram Login Widget; нужен HTTPS).
|
||||
admin_public_url: str = "http://localhost:8000"
|
||||
#: Наследуется из базовых настроек: ADMIN_PUBLIC_URL.
|
||||
#: Username бота без @ — из него виджет рисует кнопку входа.
|
||||
bot_username: str = ""
|
||||
#: Секрет подписи cookie-сессии. В проде обязателен, иначе сессии слетают
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
"""Одноразовые токены входа в админку через Telegram deep-link."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from common.db.pool import get_pool
|
||||
|
||||
#: Время жизни токена.
|
||||
TTL_MINUTES = 5
|
||||
|
||||
|
||||
def _hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
async def create_token() -> str:
|
||||
"""Создать токен, вернуть его в открытом виде (в БД — только хеш)."""
|
||||
token = secrets.token_urlsafe(32)
|
||||
async with get_pool().connection() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.admin_login_tokens (token_hash, expires_at)
|
||||
VALUES (%s, now() + make_interval(mins => %s))
|
||||
""",
|
||||
(_hash(token), TTL_MINUTES),
|
||||
)
|
||||
await conn.execute("DELETE FROM bot.admin_login_tokens WHERE expires_at < now() - interval '1 day'")
|
||||
return token
|
||||
|
||||
|
||||
async def confirm_token(token: str, telegram_id: int) -> bool:
|
||||
"""Отметить токен подтверждённым ботом. False — если истёк/уже подтверждён."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
UPDATE bot.admin_login_tokens
|
||||
SET confirmed_at = now(), telegram_id = %s
|
||||
WHERE token_hash = %s
|
||||
AND confirmed_at IS NULL
|
||||
AND used_at IS NULL
|
||||
AND expires_at > now()
|
||||
""",
|
||||
(telegram_id, _hash(token)),
|
||||
)
|
||||
return bool(cur.rowcount)
|
||||
|
||||
|
||||
async def consume_token(token: str) -> int | None:
|
||||
"""Забрать подтверждённый токен один раз. Возвращает Telegram-ID или None."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT telegram_id
|
||||
FROM bot.admin_login_tokens
|
||||
WHERE token_hash = %s
|
||||
AND confirmed_at IS NOT NULL
|
||||
AND used_at IS NULL
|
||||
AND expires_at > now()
|
||||
FOR UPDATE
|
||||
""",
|
||||
(_hash(token),),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
if row is None or row["telegram_id"] is None:
|
||||
return None
|
||||
|
||||
await conn.execute(
|
||||
"UPDATE bot.admin_login_tokens SET used_at = now() WHERE token_hash = %s",
|
||||
(_hash(token),),
|
||||
)
|
||||
return int(row["telegram_id"])
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
"""Журнал ручных отправок сообщений из админки."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from common.db.pool import get_pool
|
||||
|
||||
|
||||
async def log_message(
|
||||
*,
|
||||
editor_id: int | None,
|
||||
chat_id: int,
|
||||
text: str,
|
||||
buttons: list[tuple[str, str]],
|
||||
has_image: bool,
|
||||
success: bool,
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
async with get_pool().connection() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO bot.admin_messages (editor_id, chat_id, text, buttons, has_image, success, error)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
editor_id,
|
||||
chat_id,
|
||||
text,
|
||||
Jsonb([{"text": button_text, "url": url} for button_text, url in buttons]),
|
||||
has_image,
|
||||
success,
|
||||
error,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def recent(*, limit: int = 10) -> list[dict[str, Any]]:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT id, editor_id, chat_id, left(text, 120) AS preview, buttons,
|
||||
has_image, success, error, sent_at
|
||||
FROM bot.admin_messages
|
||||
ORDER BY sent_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"""Справочник званий: чтение, создание, изменение, удаление."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from common.db.pool import get_pool
|
||||
|
||||
|
||||
async def list_titles() -> list[dict[str, Any]]:
|
||||
"""Все звания с количеством игроков, у которых они стоят."""
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"""
|
||||
SELECT t.id_title, t.title, t.price, t.visible,
|
||||
(SELECT count(*) FROM bot.de_users AS d WHERE d.id_title = t.id_title) AS used
|
||||
FROM bot.titles AS t
|
||||
ORDER BY t.price DESC, t.id_title
|
||||
"""
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
|
||||
async def create_title(title: str, price: int, visible: bool) -> int:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"INSERT INTO bot.titles (title, price, visible) VALUES (%s, %s, %s) RETURNING id_title",
|
||||
(title, price, visible),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
return int(row["id_title"])
|
||||
|
||||
|
||||
async def update_title(title_id: int, title: str, price: int, visible: bool) -> bool:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"UPDATE bot.titles SET title = %s, price = %s, visible = %s WHERE id_title = %s",
|
||||
(title, price, visible, title_id),
|
||||
)
|
||||
return bool(cur.rowcount)
|
||||
|
||||
|
||||
async def delete_title(title_id: int) -> bool:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute("DELETE FROM bot.titles WHERE id_title = %s", (title_id,))
|
||||
return bool(cur.rowcount)
|
||||
|
||||
|
||||
async def usage_count(title_id: int) -> int:
|
||||
async with get_pool().connection() as conn:
|
||||
cur = await conn.execute(
|
||||
"SELECT count(*) AS total FROM bot.de_users WHERE id_title = %s",
|
||||
(title_id,),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
return int(row["total"]) if row else 0
|
||||
|
|
@ -27,6 +27,11 @@ MSG_DONAT_PROMO = "Назови этот код админам!\n`{promo}`"
|
|||
MSG_EGGS_TOO_EARLY = "Слишком рано..."
|
||||
MSG_EGGS_EVENT_OVER = "Событие с яичками уже завершилось — увидимся на следующую Пасху."
|
||||
|
||||
MSG_START = "Привет! Это бот Dispel Evil."
|
||||
MSG_LOGIN_CONFIRMED = "Вход в админку подтверждён. Вернитесь на сайт."
|
||||
MSG_LOGIN_DENIED = "У вас нет доступа к админке."
|
||||
MSG_LOGIN_INVALID = "Ссылка для входа устарела. Начните вход заново на сайте."
|
||||
|
||||
MSG_BOT_ADDED = "Я пришёл! Требую админские права!"
|
||||
|
||||
MSG_BOT_FOREIGN_CHAT = "Создатель запретил ходить в другие чатики :с"
|
||||
|
|
|
|||
|
|
@ -7,4 +7,4 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.5.2"
|
||||
__version__ = "0.6.1"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
-- Журнал сообщений, отправленных администратором из веб-панели в чат.
|
||||
CREATE TABLE IF NOT EXISTS bot.admin_messages (
|
||||
id bigserial PRIMARY KEY,
|
||||
editor_id bigint,
|
||||
chat_id bigint NOT NULL,
|
||||
text text NOT NULL DEFAULT '',
|
||||
buttons jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
has_image boolean NOT NULL DEFAULT false,
|
||||
success boolean NOT NULL,
|
||||
error text,
|
||||
sent_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_messages_sent_at
|
||||
ON bot.admin_messages (sent_at DESC);
|
||||
|
||||
COMMENT ON TABLE bot.admin_messages IS 'Аудит ручных отправок сообщений из админки';
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
-- Одноразовые токены для входа в админку через установленный Telegram (deep-link).
|
||||
CREATE TABLE IF NOT EXISTS bot.admin_login_tokens (
|
||||
token_hash text PRIMARY KEY,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
confirmed_at timestamptz,
|
||||
used_at timestamptz,
|
||||
telegram_id bigint
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_login_tokens_expires
|
||||
ON bot.admin_login_tokens (expires_at);
|
||||
|
||||
COMMENT ON TABLE bot.admin_login_tokens IS 'Одноразовые токены deep-link входа в админку (хранится SHA-256)';
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
-- Переводим все текущие шаблоны сообщений в последовательности (по одному сообщению).
|
||||
-- Старые строки message_templates оставляем как фолбэк для кода.
|
||||
INSERT INTO bot.message_sequences (key, title, description)
|
||||
SELECT t.key,
|
||||
coalesce(nullif(t.description, ''), t.key),
|
||||
t.description
|
||||
FROM bot.message_templates AS t
|
||||
WHERE NOT EXISTS (SELECT 1 FROM bot.message_sequences AS s WHERE s.key = t.key)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO bot.message_sequence_items (sequence_key, position, text, enabled)
|
||||
SELECT t.key, 0, t.text, t.enabled
|
||||
FROM bot.message_templates AS t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM bot.message_sequence_items AS i
|
||||
WHERE i.sequence_key = t.key AND i.position = 0
|
||||
)
|
||||
ON CONFLICT (sequence_key, position) DO NOTHING;
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
"""Тесты админки: каркас, раздел «Сообщения» (без БД — репозиторий подменяется)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common.repositories import templates_repo
|
||||
|
||||
TEMPLATE: dict[str, Any] = {
|
||||
"key": "info.help",
|
||||
"text": "Справка по командам",
|
||||
"description": "Справка (/помощь)",
|
||||
"enabled": True,
|
||||
"updated_at": datetime(2026, 1, 1, 12, 0, tzinfo=UTC),
|
||||
"updated_by": None,
|
||||
}
|
||||
|
||||
|
||||
def _patch_template(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_get(key: str) -> dict[str, Any] | None:
|
||||
return TEMPLATE if key == TEMPLATE["key"] else None
|
||||
|
||||
async def fake_history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(templates_repo, "get_template", fake_get)
|
||||
monkeypatch.setattr(templates_repo, "template_history", fake_history)
|
||||
|
||||
|
||||
def test_healthz(client_factory) -> None:
|
||||
response = client_factory(authenticated=False).get("/healthz")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["app_env"] == "dev"
|
||||
|
||||
|
||||
def test_index_renders_sections(client_factory) -> None:
|
||||
response = client_factory().get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Панель администрирования" in response.text
|
||||
assert "Расписания" in response.text
|
||||
|
||||
|
||||
def test_messages_list(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_list() -> list[dict[str, Any]]:
|
||||
return [{**TEMPLATE, "text_length": len(TEMPLATE["text"])}]
|
||||
|
||||
monkeypatch.setattr(templates_repo, "list_templates", fake_list)
|
||||
|
||||
response = client_factory().get("/messages")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "info.help" in response.text
|
||||
assert "Справка (/помощь)" in response.text
|
||||
|
||||
|
||||
def test_message_editor_and_history(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_template(monkeypatch)
|
||||
|
||||
response = client_factory().get("/messages/info.help")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Справка по командам" in response.text
|
||||
assert "Доступные теги" in response.text
|
||||
|
||||
|
||||
def test_message_editor_404(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_get(key: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(templates_repo, "get_template", fake_get)
|
||||
|
||||
assert client_factory().get("/messages/unknown").status_code == 404
|
||||
|
||||
|
||||
def test_preview_renders_tags(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
|
||||
_patch_template(monkeypatch)
|
||||
client: TestClient = client_factory()
|
||||
token = csrf_token(client.get("/messages/info.help").text)
|
||||
|
||||
response = client.post("/messages/preview", data={"text": "Привет, {nick}!", "csrf_token": token})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "ТестовыйНик" in response.text
|
||||
assert "<iframe" in response.text
|
||||
|
||||
|
||||
def test_preview_reports_unknown_tag(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
|
||||
_patch_template(monkeypatch)
|
||||
client: TestClient = client_factory()
|
||||
token = csrf_token(client.get("/messages/info.help").text)
|
||||
|
||||
response = client.post("/messages/preview", data={"text": "Привет, {unknown_tag}!", "csrf_token": token})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Неизвестные теги" in response.text
|
||||
|
||||
|
||||
def test_preview_requires_csrf(client_factory, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_template(monkeypatch)
|
||||
|
||||
response = client_factory().post("/messages/preview", data={"text": "Привет"})
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_save_rejects_unknown_tag(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
|
||||
_patch_template(monkeypatch)
|
||||
client: TestClient = client_factory()
|
||||
token = csrf_token(client.get("/messages/info.help").text)
|
||||
|
||||
response = client.post(
|
||||
"/messages/info.help",
|
||||
data={"text": "текст {unknown_tag}", "csrf_token": token},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Неизвестные теги" in response.text
|
||||
|
||||
|
||||
def test_save_updates_template(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
|
||||
saved: dict[str, Any] = {}
|
||||
|
||||
async def fake_update(key: str, text: str, *, editor_id: int | None = None) -> dict[str, Any]:
|
||||
saved.update(key=key, text=text, editor_id=editor_id)
|
||||
return {"key": key, "text": text, "old_text": TEMPLATE["text"], "changed": True}
|
||||
|
||||
_patch_template(monkeypatch)
|
||||
monkeypatch.setattr(templates_repo, "update_template", fake_update)
|
||||
|
||||
client: TestClient = client_factory()
|
||||
token = csrf_token(client.get("/messages/info.help").text)
|
||||
response = client.post("/messages/info.help", data={"text": "Новая справка", "csrf_token": token})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Текст сохранён" in response.text
|
||||
assert saved == {"key": "info.help", "text": "Новая справка", "editor_id": 1}
|
||||
|
||||
|
||||
def test_new_template_form(client_factory) -> None:
|
||||
response = client_factory().get("/messages/new")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Новый шаблон" in response.text
|
||||
assert "shop.rules" in response.text
|
||||
|
||||
|
||||
def test_create_template(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
|
||||
created: dict[str, Any] = {}
|
||||
|
||||
async def fake_create(
|
||||
key: str,
|
||||
text: str,
|
||||
description: str,
|
||||
*,
|
||||
enabled: bool = True,
|
||||
editor_id: int | None = None,
|
||||
) -> bool:
|
||||
created.update(key=key, text=text, description=description, enabled=enabled, editor_id=editor_id)
|
||||
return True
|
||||
|
||||
async def fake_get(key: str) -> dict[str, Any] | None:
|
||||
return {
|
||||
"key": key,
|
||||
"text": "Правила магазина",
|
||||
"description": "Правила",
|
||||
"enabled": True,
|
||||
"updated_at": datetime(2026, 1, 1, tzinfo=UTC),
|
||||
"updated_by": 1,
|
||||
}
|
||||
|
||||
async def fake_history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(templates_repo, "create_template", fake_create)
|
||||
monkeypatch.setattr(templates_repo, "get_template", fake_get)
|
||||
monkeypatch.setattr(templates_repo, "template_history", fake_history)
|
||||
|
||||
client: TestClient = client_factory()
|
||||
token = csrf_token(client.get("/messages/new").text)
|
||||
response = client.post(
|
||||
"/messages",
|
||||
data={
|
||||
"key": "shop.rules",
|
||||
"description": "Правила",
|
||||
"text": "Правила магазина",
|
||||
"enabled": "on",
|
||||
"csrf_token": token,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert created == {
|
||||
"key": "shop.rules",
|
||||
"text": "Правила магазина",
|
||||
"description": "Правила",
|
||||
"enabled": True,
|
||||
"editor_id": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_create_template_duplicate(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
|
||||
async def fake_create(*args: Any, **kwargs: Any) -> bool:
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(templates_repo, "create_template", fake_create)
|
||||
|
||||
client: TestClient = client_factory()
|
||||
token = csrf_token(client.get("/messages/new").text)
|
||||
response = client.post(
|
||||
"/messages",
|
||||
data={"key": "shop.rules", "text": "Текст", "csrf_token": token},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "уже существует" in response.text
|
||||
|
||||
|
||||
def test_create_template_rejects_bad_key(client_factory, csrf_token) -> None:
|
||||
client: TestClient = client_factory()
|
||||
token = csrf_token(client.get("/messages/new").text)
|
||||
|
||||
response = client.post(
|
||||
"/messages",
|
||||
data={"key": "Плохой ключ", "text": "Текст", "csrf_token": token},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Ключ:" in response.text
|
||||
|
||||
|
||||
def test_create_template_rejects_unknown_tag(client_factory, csrf_token) -> None:
|
||||
client: TestClient = client_factory()
|
||||
token = csrf_token(client.get("/messages/new").text)
|
||||
|
||||
response = client.post(
|
||||
"/messages",
|
||||
data={"key": "shop.rules", "text": "Текст {nope}", "csrf_token": token},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Неизвестные теги" in response.text
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
"""Тесты ручной отправки сообщения из админки."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from admin import telegram
|
||||
from common.repositories import admin_messages_repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_send(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||
state: dict[str, Any] = {}
|
||||
|
||||
async def recent(*, limit: int = 10) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
async def log_message(**kwargs: Any) -> None:
|
||||
state["audit"] = kwargs
|
||||
|
||||
async def send_to_chat(settings: Any, *, text: str, buttons: list, photo: Any = None) -> None:
|
||||
state["send"] = {"text": text, "buttons": buttons, "photo": photo}
|
||||
|
||||
monkeypatch.setattr(admin_messages_repo, "recent", recent)
|
||||
monkeypatch.setattr(admin_messages_repo, "log_message", log_message)
|
||||
monkeypatch.setattr(telegram, "send_to_chat", send_to_chat)
|
||||
return state
|
||||
|
||||
|
||||
def _client(client_factory, settings_factory) -> TestClient:
|
||||
return client_factory(settings=settings_factory(chat_id=-100123), follow_redirects=False)
|
||||
|
||||
|
||||
def test_send_form(fake_send: dict[str, Any], client_factory, settings_factory) -> None:
|
||||
response = _client(client_factory, settings_factory).get("/send")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'data-tag="b"' in response.text
|
||||
assert "-100123" in response.text
|
||||
|
||||
|
||||
def test_send_text(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None:
|
||||
client = _client(client_factory, settings_factory)
|
||||
token = csrf_token(client.get("/send").text)
|
||||
|
||||
response = client.post(
|
||||
"/send",
|
||||
data={"text": "Привет, клан!", "buttons": "Канал | https://t.me/x", "csrf_token": token},
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/send?saved=true"
|
||||
assert fake_send["send"]["text"] == "Привет, клан!"
|
||||
assert fake_send["send"]["buttons"] == [("Канал", "https://t.me/x")]
|
||||
assert fake_send["send"]["photo"] is None
|
||||
assert fake_send["audit"]["success"] is True
|
||||
|
||||
|
||||
def test_send_rejects_bad_buttons(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None:
|
||||
client = _client(client_factory, settings_factory)
|
||||
token = csrf_token(client.get("/send").text)
|
||||
|
||||
response = client.post("/send", data={"text": "Текст", "buttons": "без ссылки", "csrf_token": token})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Кнопка без разделителя" in response.text
|
||||
assert "send" not in fake_send
|
||||
|
||||
|
||||
def test_send_requires_text_or_image(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None:
|
||||
client = _client(client_factory, settings_factory)
|
||||
token = csrf_token(client.get("/send").text)
|
||||
|
||||
response = client.post("/send", data={"text": " ", "csrf_token": token})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Нужен текст или картинка" in response.text
|
||||
|
||||
|
||||
def test_send_with_image(fake_send: dict[str, Any], client_factory, settings_factory, csrf_token) -> None:
|
||||
client = _client(client_factory, settings_factory)
|
||||
token = csrf_token(client.get("/send").text)
|
||||
|
||||
response = client.post(
|
||||
"/send",
|
||||
data={"text": "С картинкой", "csrf_token": token},
|
||||
files={"image": ("pic.png", b"fake-png", "image/png")},
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert fake_send["send"]["photo"] is not None
|
||||
assert fake_send["send"]["photo"][1] == "image/png"
|
||||
assert fake_send["audit"]["has_image"] is True
|
||||
|
||||
|
||||
def test_send_telegram_error(
|
||||
fake_send: dict[str, Any],
|
||||
client_factory,
|
||||
settings_factory,
|
||||
csrf_token,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def send_to_chat(*args: Any, **kwargs: Any) -> None:
|
||||
raise telegram.TelegramError("chat not found")
|
||||
|
||||
monkeypatch.setattr(telegram, "send_to_chat", send_to_chat)
|
||||
client = _client(client_factory, settings_factory)
|
||||
token = csrf_token(client.get("/send").text)
|
||||
|
||||
response = client.post("/send", data={"text": "Текст", "csrf_token": token})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "chat not found" in response.text
|
||||
assert fake_send["audit"]["success"] is False
|
||||
assert fake_send["audit"]["error"] == "chat not found"
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
"""Тесты справочника званий."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common.repositories import titles_repo
|
||||
|
||||
TITLE: dict[str, Any] = {
|
||||
"id_title": 1,
|
||||
"title": "Ветеран",
|
||||
"price": 500,
|
||||
"visible": True,
|
||||
"used": 2,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_titles(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||
state: dict[str, Any] = {"used": 2}
|
||||
|
||||
async def list_titles() -> list[dict[str, Any]]:
|
||||
return [TITLE]
|
||||
|
||||
async def create_title(title: str, price: int, visible: bool) -> int:
|
||||
state["created"] = (title, price, visible)
|
||||
return 2
|
||||
|
||||
async def update_title(title_id: int, title: str, price: int, visible: bool) -> bool:
|
||||
state["updated"] = (title_id, title, price, visible)
|
||||
return True
|
||||
|
||||
async def delete_title(title_id: int) -> bool:
|
||||
state["deleted"] = title_id
|
||||
return True
|
||||
|
||||
async def usage_count(title_id: int) -> int:
|
||||
return state["used"]
|
||||
|
||||
monkeypatch.setattr(titles_repo, "list_titles", list_titles)
|
||||
monkeypatch.setattr(titles_repo, "create_title", create_title)
|
||||
monkeypatch.setattr(titles_repo, "update_title", update_title)
|
||||
monkeypatch.setattr(titles_repo, "delete_title", delete_title)
|
||||
monkeypatch.setattr(titles_repo, "usage_count", usage_count)
|
||||
return state
|
||||
|
||||
|
||||
def _client(client_factory) -> TestClient:
|
||||
return client_factory(follow_redirects=False)
|
||||
|
||||
|
||||
def test_titles_list(fake_titles: dict[str, Any], client_factory) -> None:
|
||||
response = _client(client_factory).get("/titles")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Ветеран" in response.text
|
||||
assert "используется: 2" in response.text
|
||||
|
||||
|
||||
def test_create_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None:
|
||||
client = _client(client_factory)
|
||||
token = csrf_token(client.get("/titles").text)
|
||||
|
||||
response = client.post("/titles", data={"title": "Новичок", "price": "100", "csrf_token": token})
|
||||
|
||||
assert response.status_code == 303
|
||||
assert fake_titles["created"] == ("Новичок", 100, True)
|
||||
|
||||
|
||||
def test_update_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None:
|
||||
client = _client(client_factory)
|
||||
token = csrf_token(client.get("/titles").text)
|
||||
|
||||
response = client.post(
|
||||
"/titles/1",
|
||||
data={"title": "Ветеран+", "price": "700", "visible": "on", "csrf_token": token},
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert fake_titles["updated"] == (1, "Ветеран+", 700, True)
|
||||
|
||||
|
||||
def test_delete_used_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None:
|
||||
client = _client(client_factory)
|
||||
token = csrf_token(client.get("/titles").text)
|
||||
|
||||
response = client.post("/titles/1/delete", data={"csrf_token": token})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "используется 2 игроками" in response.text
|
||||
assert "deleted" not in fake_titles
|
||||
|
||||
|
||||
def test_delete_unused_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None:
|
||||
fake_titles["used"] = 0
|
||||
client = _client(client_factory)
|
||||
token = csrf_token(client.get("/titles").text)
|
||||
|
||||
response = client.post("/titles/1/delete", data={"csrf_token": token})
|
||||
|
||||
assert response.status_code == 303
|
||||
assert fake_titles["deleted"] == 1
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
"""Тесты deep-link входа в админку через Telegram."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common.repositories import admin_login_tokens_repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_tokens(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||
state: dict[str, Any] = {"confirmed": None}
|
||||
|
||||
async def create_token() -> str:
|
||||
return "test-token"
|
||||
|
||||
async def consume_token(token: str) -> int | None:
|
||||
return state["confirmed"]
|
||||
|
||||
monkeypatch.setattr(admin_login_tokens_repo, "create_token", create_token)
|
||||
monkeypatch.setattr(admin_login_tokens_repo, "consume_token", consume_token)
|
||||
return state
|
||||
|
||||
|
||||
def _client(client_factory, settings_factory) -> TestClient:
|
||||
return client_factory(
|
||||
authenticated=False,
|
||||
follow_redirects=False,
|
||||
settings=settings_factory(
|
||||
bot_username="debot_test_bot",
|
||||
admin_public_url="https://debot-test.mpakosrv.ru",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_login_page_has_deeplink(fake_tokens: dict[str, Any], client_factory, settings_factory) -> None:
|
||||
response = _client(client_factory, settings_factory).get("/login")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Открыть Telegram" in response.text
|
||||
assert "/auth/deeplink/start" in response.text
|
||||
|
||||
|
||||
def test_deeplink_start_redirects_to_telegram(
|
||||
fake_tokens: dict[str, Any],
|
||||
client_factory,
|
||||
settings_factory,
|
||||
) -> None:
|
||||
client = _client(client_factory, settings_factory)
|
||||
|
||||
response = client.get("/auth/deeplink/start")
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "https://t.me/debot_test_bot?start=login_test-token"
|
||||
assert "debot_login_token" in response.headers["set-cookie"]
|
||||
|
||||
|
||||
def test_deeplink_status_without_cookie(fake_tokens: dict[str, Any], client_factory, settings_factory) -> None:
|
||||
response = _client(client_factory, settings_factory).get("/auth/deeplink/status")
|
||||
|
||||
assert response.json() == {"status": "none"}
|
||||
|
||||
|
||||
def test_deeplink_status_pending(fake_tokens: dict[str, Any], client_factory, settings_factory) -> None:
|
||||
client = _client(client_factory, settings_factory)
|
||||
client.get("/auth/deeplink/start")
|
||||
|
||||
response = client.get("/auth/deeplink/status")
|
||||
|
||||
assert response.json() == {"status": "pending"}
|
||||
|
||||
|
||||
def test_deeplink_status_confirms_session(
|
||||
fake_tokens: dict[str, Any],
|
||||
client_factory,
|
||||
settings_factory,
|
||||
) -> None:
|
||||
fake_tokens["confirmed"] = 1
|
||||
client = _client(client_factory, settings_factory)
|
||||
client.get("/auth/deeplink/start")
|
||||
|
||||
response = client.get("/auth/deeplink/status")
|
||||
|
||||
assert response.json() == {"status": "ok"}
|
||||
assert client.get("/").status_code == 200
|
||||
|
||||
|
||||
def test_deeplink_status_denied_for_unknown_admin(
|
||||
fake_tokens: dict[str, Any],
|
||||
client_factory,
|
||||
settings_factory,
|
||||
) -> None:
|
||||
fake_tokens["confirmed"] = 2
|
||||
client = _client(client_factory, settings_factory)
|
||||
client.get("/auth/deeplink/start")
|
||||
|
||||
response = client.get("/auth/deeplink/status")
|
||||
|
||||
assert response.json() == {"status": "denied"}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
"""Тесты общего предпросмотра сообщений."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _client(client_factory) -> TestClient:
|
||||
return client_factory()
|
||||
|
||||
|
||||
def test_preview_renders_tags(client_factory, csrf_token) -> None:
|
||||
client = _client(client_factory)
|
||||
token = csrf_token(client.get("/send").text)
|
||||
|
||||
response = client.post("/preview", data={"text": "Привет, {nick}!", "csrf_token": token})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "ТестовыйНик" in response.text
|
||||
assert "<iframe" in response.text
|
||||
|
||||
|
||||
def test_preview_reports_unknown_tag(client_factory, csrf_token) -> None:
|
||||
client = _client(client_factory)
|
||||
token = csrf_token(client.get("/send").text)
|
||||
|
||||
response = client.post("/preview", data={"text": "Привет, {unknown_tag}!", "csrf_token": token})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Неизвестные теги" in response.text
|
||||
|
||||
|
||||
def test_preview_requires_csrf(client_factory) -> None:
|
||||
response = _client(client_factory).post("/preview", data={"text": "Привет"})
|
||||
|
||||
assert response.status_code == 400
|
||||
|
|
@ -7,4 +7,4 @@ from common import __version__
|
|||
|
||||
def test_version_is_set():
|
||||
assert __version__
|
||||
assert __version__ == "0.5.2"
|
||||
assert __version__ == "0.6.1"
|
||||
|
|
|
|||
Loading…
Reference in New Issue