diff --git a/config.example.yaml b/config.example.yaml
index 36324e0..64cc7db 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -64,6 +64,9 @@ messengers:
tag: "TG" # переопределение тега (по умолчанию из kind)
token: ${TG_BOT_TOKEN}
# privacy mode у бота ДОЛЖЕН быть отключён (BotFather /setprivacy → Disable)
+ # commands: # опционально; без блока — командный роутер не включается
+ # owner_id: 123456789 # Telegram user ID владельца (роль OWNER)
+ # alias_max_chars: 16 # максимальная длина псевдонима (по умолчанию 16)
rooms:
# Вариант 1: 1 LoRa + N мессенджеров
diff --git a/docs/gen_pages.py b/docs/gen_pages.py
index 7f7babb..2570e5b 100644
--- a/docs/gen_pages.py
+++ b/docs/gen_pages.py
@@ -128,6 +128,11 @@ def main() -> None:
yaml_example=ROOMS_EXAMPLE,
root_model=schema.RoomConfig,
)
+ emit_commands_page(
+ path="reference/commands.md",
+ title="Команды Telegram-бота",
+ )
+ emit_specs_index(path="contributing/design-specs.md")
def emit_section_page(
@@ -332,4 +337,58 @@ def _unwrap_annotated(t: Any) -> Any:
return t
+from pathlib import Path as _Path
+
+
+def emit_commands_page(*, path: str, title: str) -> None:
+ from lora_bridge.transports.telegram.commands import ALL_COMMAND_METAS
+
+ parts = [
+ f"# {title}",
+ "",
+ "Список команд Telegram-бота. Генерируется автоматически из реестра.",
+ "",
+ "| Команда | Мин. роль | Описание |",
+ "|---------|-----------|----------|",
+ ]
+ for meta in ALL_COMMAND_METAS:
+ parts.append(f"| `/{meta.name}` | {meta.min_role.name.lower()} | {meta.description} |")
+
+ with mkdocs_gen_files.open(path, "w") as f:
+ f.write("\n".join(parts))
+
+
+def emit_specs_index(*, path: str) -> None:
+ specs_dir = _Path(__file__).resolve().parent / "superpowers" / "specs"
+ if not specs_dir.exists():
+ return
+
+ rows: list[tuple[str, str, str]] = []
+ for spec_file in sorted(specs_dir.glob("*.md")):
+ date = spec_file.name[:10]
+ title = spec_file.name
+ try:
+ first_line = spec_file.read_text(encoding="utf-8").splitlines()[0]
+ if first_line.startswith("# "):
+ title = first_line[2:].strip()
+ except (IndexError, OSError):
+ pass
+ rel_path = f"../../superpowers/specs/{spec_file.name}"
+ rows.append((date, title, rel_path))
+
+ parts = [
+ "# Дизайн-спеки",
+ "",
+ "Зафиксированные дизайн-решения. Генерируется автоматически — новый спек появляется здесь после добавления файла в `docs/superpowers/specs/`.",
+ "",
+ "| Дата | Документ |",
+ "|------|----------|",
+ ]
+ for date, title, _ in rows:
+ parts.append(f"| {date} | {title} |")
+
+ with mkdocs_gen_files.open(path, "w") as f:
+ f.write("\n".join(parts))
+
+
main()
diff --git a/docs/superpowers/specs/2026-06-28-telegram-roles-moderation-design.md b/docs/superpowers/specs/2026-06-28-telegram-roles-moderation-design.md
new file mode 100644
index 0000000..c18c003
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-28-telegram-roles-moderation-design.md
@@ -0,0 +1,380 @@
+# Telegram: ролевая система и модерация
+
+**Дата**: 2026-06-28
+**Ветка**: claude/telegram-transport
+**Слой**: `lora_bridge/transports/telegram/`
+
+---
+
+## 1. Контекст
+
+Telegram-адаптер уже имеет каркас команд (`commands/framework.py` + `commands/handlers.py`).
+Этот дизайн добавляет: ролевую систему, персистентные настройки пользователей и команды
+модерации — как **опциональный** блок, включаемый через конфиг.
+
+---
+
+## 2. Ролевая система
+
+```python
+class Role(IntEnum):
+ USER = 0
+ MODERATOR = 1
+ ADMIN = 2
+ OWNER = 3
+```
+
+**Правила выдачи/отзыва ролей:**
+
+- actor может выдать роль только строго ниже своей
+- actor может отозвать роль только строго ниже своей
+- Admin не может revoke другого Admin или Owner
+- Никто не может revoke собственную роль (защита от самоблокировки)
+- Owner не хранится в БД — только в конфиге (не может быть случайно revoke-нут)
+
+---
+
+## 3. База данных
+
+Таблицы живут в том же SQLite-файле что `LORA_BRIDGE_DB` (разные таблицы, один файл).
+
+```sql
+CREATE TABLE IF NOT EXISTS roles (
+ tg_id INTEGER PRIMARY KEY,
+ role TEXT NOT NULL -- 'admin' | 'moderator'
+);
+
+CREATE TABLE IF NOT EXISTS user_settings (
+ tg_id INTEGER PRIMARY KEY,
+ alias TEXT, -- NULL = не задан
+ transliter INTEGER DEFAULT 0, -- 0/1
+ disabled INTEGER DEFAULT 0, -- 0/1
+ banned_name TEXT -- display_name на момент бана; NULL если банили по ID
+);
+```
+
+---
+
+## 4. Конфиг
+
+`commands` — опциональный вложенный блок в `TelegramMessengerConfig`.
+Отсутствие блока = команды полностью выключены (роутер не регистрируется).
+
+```yaml
+messengers:
+ - id: telegram-main
+ kind: telegram
+ token: ${TG_BOT_TOKEN}
+ commands: # опционально; без него — бот команды не обрабатывает
+ owner_id: 123456789 # Telegram user ID владельца; обязателен внутри блока
+ alias_max_chars: 16 # макс длина alias (дефолт 16)
+```
+
+Pydantic-модели:
+
+```python
+class TelegramCommandsConfig(BaseModel):
+ owner_id: int
+ alias_max_chars: int = 16
+
+class TelegramMessengerConfig(BaseMessengerConfig):
+ kind: Literal["telegram"]
+ token: str
+ commands: Optional[TelegramCommandsConfig] = None
+```
+
+---
+
+## 5. Модульная структура
+
+```
+transports/telegram/
+ moderation/
+ __init__.py
+ roles.py # Role enum, can_grant(), can_revoke(), get_role(store, owner_id, tg_id)
+ store.py # ModerationStore: async SQLite CRUD для roles + user_settings
+ commands/
+ framework.py # CommandSpec(+min_role), build_command_router, render_help, command_menu
+ handlers.py # базовые команды: ping, help
+ moderation.py # make_moderation_commands(store, cfg) → list[CommandSpec]
+```
+
+`make_moderation_commands` — фабрика: замыкание над `store` и `cfg`, возвращает
+`list[CommandSpec]` с уже встроенными зависимостями. Каркас не знает о конкретных командах.
+
+---
+
+## 6. Расширение CommandSpec и каркаса
+
+```python
+@dataclass(frozen=True)
+class CommandSpec:
+ name: str
+ description: str
+ handler: CommandHandler
+ min_role: Role = Role.USER
+```
+
+`build_command_router(transport_id, commands, store, owner_id)`:
+
+- для каждого `CommandSpec` оборачивает handler в permission-check (роль вызывающего < `min_role` → "Недостаточно прав.", выход)
+- регистрирует сеть неизвестных команд последней (инвариант: команды не текут в pipeline)
+
+`render_help(commands, caller_role)` — фильтрует по `spec.min_role <= caller_role`.
+
+`command_menu(commands, role)` — фильтрует аналогично, для `set_my_commands`.
+
+---
+
+## 7. Меню Telegram (per-user)
+
+- При старте: `set_my_commands(user_commands, scope=BotCommandScopeDefault)` — USER-уровень для всех
+- При старте: для каждого non-USER из БД — `set_my_commands(role_commands, scope=BotCommandScopeChatMember(chat_id, user_id))`
+- При grant/revoke: один вызов `set_my_commands` / `delete_my_commands` для затронутого пользователя в текущем чате
+
+---
+
+## 8. Команды
+
+| Команда | min_role | Аргументы | Описание |
+|---------|----------|-----------|----------|
+| `/ping` | USER | — | проверка живости |
+| `/help` | USER | — | список команд по роли |
+| `/set-alias [alias]` | USER | без арг. = сброс себе | alias себе; max `alias_max_chars` символов |
+| `/set-alias @user\|id alias` | MODERATOR | — | alias другому |
+| `/set-transliter` | USER | — | тогл транслитерации себе |
+| `/set-transliter @user\|id` | MODERATOR | — | тогл транслитерации другому |
+| `/ban` | MODERATOR | reply или @user\|id | запретить бриджинг TG→LoRa; реакция 🚫 |
+| `/unban` | MODERATOR | reply или @user\|id | снять бан |
+| `/banlist` | MODERATOR | — | список забаненных с mention-ами |
+| `/role grant\|revoke admin\|moderator @user\|id` | ADMIN | — | управление ролями |
+
+**Идентификация пользователя в командах** (для `/ban`, `/unban`, `/set-alias`, `/set-transliter`):
+1. Reply на сообщение — берём `from_user` из реплаемого сообщения
+2. Иначе — первый аргумент: `@username` или числовой ID
+
+**Alias constraints**: только при `/set-alias`; длина ≤ `alias_max_chars` (дефолт 16);
+если превышена — команда отвечает ошибкой с указанием лимита.
+
+---
+
+## 9. Интеграция с `on_message`
+
+```python
+async def on_message(self, message: TgMessage) -> None:
+ user_id = message.from_user.id if message.from_user else None
+ if user_id and await self._store.is_disabled(user_id):
+ await self._reactions.report_disabled(message) # реакция 🚫
+ return
+ settings = await self._store.get_user_settings(user_id) if user_id else None
+ await self._hub.publish(self.normalize(message, settings))
+```
+
+`normalize(message, settings)`:
+- если `settings.alias` задан — подставляет как `display_name`
+- если `settings.transliter` — транслитерирует `message.text` кириллица→латиница
+
+---
+
+## 10. `/banlist` формат
+
+```
+Забаненные пользователи:
+• Vasya Pupkin (alias: Вася)
+• 222
+```
+
+Текст ссылки: `banned_name` если не NULL, иначе строка tg_id.
+Alias показывается если задан. Сообщение отправляется с `parse_mode="HTML"`.
+
+---
+
+## 11. Транслитерация
+
+Реализуется как чистая функция `transliterate(text: str) -> str` в `moderation/`.
+Отдельный маппинг кириллица→латиница без внешних зависимостей (стандартная таблица замен).
+
+---
+
+## 12. Что НЕ входит в этот дизайн
+
+- Модерация LoRa→TG направления (LoRa-пользователи не имеют TG identity)
+- Временные баны с TTL — отдельная фича
+
+---
+
+## 13. Audit log
+
+### Таблица
+
+```sql
+CREATE TABLE IF NOT EXISTS audit_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ts INTEGER NOT NULL, -- unix timestamp (int, не float)
+ actor_id INTEGER NOT NULL,
+ actor_name TEXT, -- alias если есть, иначе display_name на момент действия
+ action TEXT NOT NULL, -- см. ниже
+ target_id INTEGER, -- NULL для действий без цели
+ target_name TEXT, -- display_name / alias цели на момент действия
+ detail TEXT -- доп. контекст: "role: admin", "alias: Вася"
+);
+```
+
+Логируемые действия (`action`):
+
+| action | Команда | detail |
+|--------|---------|--------|
+| `ban` | `/ban` | — |
+| `unban` | `/unban` | — |
+| `grant` | `/role grant` | `"role: admin"` / `"role: moderator"` |
+| `revoke` | `/role revoke` | `"role: admin"` / `"role: moderator"` |
+| `set_alias` | `/set-alias` | `"alias: Вася"` или `"alias: сброшен"` |
+
+`/set-transliter` не логируется.
+
+### Команда `/audit`
+
+| Параметр | Значение |
+|----------|----------|
+| min_role | MODERATOR |
+| Аргументы | — |
+| Размер страницы | 10 записей (фиксировано) |
+
+Формат вывода одной страницы (HTML, parse_mode="HTML"):
+
+```
+Журнал действий (стр. 2/5):
+
+2026-06-28 14:32 Admin ban → Vasya
+2026-06-28 13:10 Admin grant → Ivan [role: moderator]
+```
+
+Inline-кнопки под сообщением:
+
+```
+[ ← ] [ 2 / 5 ] [ → ]
+```
+
+- `[ ← ]` и `[ → ]` — callback `audit:prev:N` / `audit:next:N`; неактивная сторона показывается как пустая кнопка (или скрывается)
+- `[ 2 / 5 ]` — кнопка-пустышка (callback игнорируется), только индикатор
+- При нажатии: `edit_message_text` + `edit_message_reply_markup` — сообщение обновляется на месте
+
+### Инфраструктура callback-хендлеров
+
+`build_command_router` расширяется: принимает опциональный список `CallbackSpec`
+(аналог `CommandSpec` для `callback_query`). Callback-хендлеры регистрируются
+в том же роутере через `router.callback_query.register`.
+
+```python
+@dataclass(frozen=True)
+class CallbackSpec:
+ prefix: str # фильтр: callback_data.startswith(prefix)
+ handler: CallbackHandler
+ min_role: Role = Role.USER
+```
+
+Permission-check для callback — аналогичен командам (роль по `callback.from_user.id`).
+
+---
+
+## 14. Автогенерация команд в mkdocs
+
+### Проблема
+
+`CommandSpec` содержит callable-хендлер — он не может быть импортирован при сборке
+документации без runtime-зависимостей (SQLite, бот-токен).
+
+### Решение: разделение метаданных и хендлеров
+
+Вводится `CommandMeta` — чистый dataclass без callable:
+
+```python
+@dataclass(frozen=True)
+class CommandMeta:
+ name: str
+ description: str
+ min_role: Role = Role.USER
+```
+
+`CommandSpec` включает `CommandMeta` (или наследует):
+
+```python
+@dataclass(frozen=True)
+class CommandSpec(CommandMeta):
+ handler: CommandHandler = field(repr=False)
+```
+
+Каждый модуль команд экспортирует статический список метаданных:
+
+- `commands/handlers.py` → `BASIC_COMMAND_METAS: list[CommandMeta]`
+- `commands/moderation.py` → `MODERATION_COMMAND_METAS: list[CommandMeta]`
+
+Агрегат в `commands/__init__.py`:
+
+```python
+ALL_COMMAND_METAS: list[CommandMeta] = BASIC_COMMAND_METAS + MODERATION_COMMAND_METAS
+```
+
+### Страница docs/gen_pages.py
+
+В `main()` добавляется вызов `emit_commands_page`:
+
+```python
+emit_commands_page(
+ path="reference/commands.md",
+ title="Команды Telegram-бота",
+)
+```
+
+Генерируемая страница — таблица:
+
+| Команда | Мин. роль | Описание |
+|---------|-----------|----------|
+| `/ping` | user | проверка живости |
+| `/ban` | moderator | запретить бриджинг TG→LoRa |
+| … | … | … |
+
+Страница добавляется в `nav` секцию `mkdocs.yml` вручную (один раз):
+
+```yaml
+nav:
+ - Справочник команд: reference/commands.md
+```
+
+`gen_pages.py` импортирует только `ALL_COMMAND_METAS` из `commands/__init__.py`
+(без store, без бота) — никаких runtime-зависимостей при сборке доки.
+
+### Авто-индекс design-спеков
+
+`gen_pages.py` получает ещё один вызов в `main()`:
+
+```python
+emit_specs_index(path="contributing/design-specs.md")
+```
+
+`emit_specs_index` сканирует `docs/superpowers/specs/*.md` через `pathlib.Path`,
+сортирует по имени файла (имена начинаются с даты → хронологический порядок)
+и генерирует виртуальную страницу-индекс:
+
+```markdown
+# Дизайн-спеки
+
+| Дата | Название |
+|------|----------|
+| 2026-06-28 | [Telegram: ролевая система и модерация](../../superpowers/specs/2026-06-28-...) |
+```
+
+Название берётся из первого заголовка `# ...` файла (первая строка, начинающаяся с `# `).
+Дата — первые 10 символов имени файла.
+
+В `mkdocs.yml` одна статическая запись (добавляется вручную один раз):
+
+```yaml
+nav:
+ - Для разработчиков:
+ - Дизайн-спеки: contributing/design-specs.md
+```
+
+Сами спек-файлы mkdocs сервит автоматически (любой файл в `docs_dir` доступен по URL).
+Индекс только агрегирует ссылки — новый спек появляется в индексе автоматически при
+следующей сборке доки, без правки `mkdocs.yml`.
diff --git a/lora_bridge/config/schema/__init__.py b/lora_bridge/config/schema/__init__.py
index 60c9d4c..d56b2d3 100644
--- a/lora_bridge/config/schema/__init__.py
+++ b/lora_bridge/config/schema/__init__.py
@@ -15,7 +15,12 @@
RoomServerEndpoint,
)
from .ids import EndpointName, MessengerId, NodeId
-from .messengers import BaseMessengerConfig, MessengerConfig, TelegramMessengerConfig
+from .messengers import (
+ BaseMessengerConfig,
+ MessengerConfig,
+ TelegramCommandsConfig,
+ TelegramMessengerConfig,
+)
from .nodes import LoraNode, MeshCoreNode
from .policies import EgressRate, LabelPolicy, NodePolicies, ReconnectBackoff
from .rooms import LoraRef, LoraSubscriber, MessengerSubscriber, RoomConfig, Subscriber
@@ -48,6 +53,7 @@
"SerialConnection",
"Subscriber",
"TcpConnection",
+ "TelegramCommandsConfig",
"TelegramMessengerConfig",
"UsbConnection",
]
diff --git a/lora_bridge/config/schema/messengers.py b/lora_bridge/config/schema/messengers.py
index 75453c3..fcb7698 100644
--- a/lora_bridge/config/schema/messengers.py
+++ b/lora_bridge/config/schema/messengers.py
@@ -34,6 +34,16 @@ class BaseMessengerConfig(BaseModel):
)
+class TelegramCommandsConfig(BaseModel):
+ """Опциональный блок команд Telegram-бота. Отсутствие = команды выключены."""
+
+ owner_id: int = Field(description="Telegram user ID владельца бота (роль OWNER).")
+ alias_max_chars: int = Field(
+ default=16,
+ description="Максимальная длина псевдонима пользователя.",
+ )
+
+
class TelegramMessengerConfig(BaseMessengerConfig):
"""Конфиг Telegram-бота.
@@ -46,6 +56,10 @@ class TelegramMessengerConfig(BaseMessengerConfig):
description="Тег дискриминатора — должно быть ``telegram``."
)
token: str = Field(description="Telegram Bot API token, выданный BotFather.")
+ commands: Optional[TelegramCommandsConfig] = Field(
+ default=None,
+ description="Блок команд; отсутствие или null отключает командный роутер.",
+ )
MessengerConfig = Annotated[
diff --git a/lora_bridge/transports/telegram/commands/__init__.py b/lora_bridge/transports/telegram/commands/__init__.py
index e31a138..f03cccc 100644
--- a/lora_bridge/transports/telegram/commands/__init__.py
+++ b/lora_bridge/transports/telegram/commands/__init__.py
@@ -7,13 +7,29 @@
from __future__ import annotations
-from .framework import CommandSpec, build_command_router, command_menu, render_help
-from .handlers import COMMANDS
+from .framework import (
+ CallbackSpec,
+ CommandMeta,
+ CommandSpec,
+ build_command_router,
+ command_menu,
+ render_help,
+)
+from .handlers import BASIC_COMMAND_METAS, make_basic_commands
+from .moderation import MODERATION_COMMAND_METAS, make_audit_callbacks, make_moderation_commands
+
+ALL_COMMAND_METAS: list[CommandMeta] = BASIC_COMMAND_METAS + MODERATION_COMMAND_METAS
__all__ = [
- "COMMANDS",
+ "ALL_COMMAND_METAS",
+ "BASIC_COMMAND_METAS",
+ "CallbackSpec",
+ "CommandMeta",
"CommandSpec",
"build_command_router",
"command_menu",
+ "make_audit_callbacks",
+ "make_basic_commands",
+ "make_moderation_commands",
"render_help",
]
diff --git a/lora_bridge/transports/telegram/commands/framework.py b/lora_bridge/transports/telegram/commands/framework.py
index d515af0..848dd6e 100644
--- a/lora_bridge/transports/telegram/commands/framework.py
+++ b/lora_bridge/transports/telegram/commands/framework.py
@@ -23,54 +23,115 @@
import logging
import re
from collections.abc import Awaitable, Callable
-from dataclasses import dataclass
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import BotCommand
+from aiogram.types import CallbackQuery
from aiogram.types import Message as TgMessage
+if TYPE_CHECKING:
+ from ..moderation.roles import Role
+ from ..moderation.store import ModerationStore
+
log = logging.getLogger(__name__)
# Любая команда по грамматике aiogram (имя из [A-Za-z0-9_] после '/').
_ANY_COMMAND = re.compile(r"[A-Za-z0-9_]+")
UNKNOWN_COMMAND_REPLY = "Неизвестная команда."
+INSUFFICIENT_RIGHTS_REPLY = "Недостаточно прав."
CommandHandler = Callable[[TgMessage], Awaitable[None]]
+CallbackHandler = Callable[[CallbackQuery], Awaitable[None]]
@dataclass(frozen=True)
-class CommandSpec:
- """Одна транспорт-локальная команда: строка реестра ``COMMANDS`` (в ``handlers``).
-
- ``name`` — имя без ведущего ``/`` (грамматика aiogram); ``description`` идёт и в
- ``/help``, и в меню Telegram; ``handler`` — корутина-обработчик aiogram.
- """
+class CommandMeta:
+ """Метаданные команды без хендлера — для документации и /help."""
name: str
description: str
- handler: CommandHandler
+ min_role: "Role"
+
+
+@dataclass(frozen=True)
+class CommandSpec(CommandMeta):
+ """Полная спецификация команды: метаданные + хендлер."""
+
+ handler: CommandHandler = field(repr=False)
+
+
+@dataclass(frozen=True)
+class CallbackSpec:
+ """Спецификация callback_query хендлера (пагинация и т.п.)."""
+
+ prefix: str
+ handler: CallbackHandler
+ min_role: "Role"
-def render_help(commands: list[CommandSpec]) -> str:
- """Текст ``/help`` из переданного реестра — описания берутся из ``CommandSpec``."""
+def render_help(commands: list[CommandMeta]) -> str:
+ """Текст ``/help`` из переданного (уже отфильтрованного) реестра."""
lines = [f"/{spec.name} — {spec.description}" for spec in commands]
return "Доступные команды:\n" + "\n".join(lines)
-def command_menu(commands: list[CommandSpec]) -> list[BotCommand]:
- """Меню для ``Bot.set_my_commands`` — из того же реестра, без дрейфа."""
- return [BotCommand(command=spec.name, description=spec.description) for spec in commands]
+def command_menu(commands: list[CommandMeta], role: "Role") -> list[BotCommand]:
+ """Меню для ``Bot.set_my_commands`` — фильтрует по роли вызывающего."""
+ visible = [c for c in commands if c.min_role <= role]
+ return [BotCommand(command=spec.name, description=spec.description) for spec in visible]
-def build_command_router(transport_id: str, commands: list[CommandSpec]) -> Router:
+def build_command_router(
+ transport_id: str,
+ commands: list[CommandSpec],
+ store: "ModerationStore | None" = None,
+ owner_id: int = 0,
+ callbacks: list[CallbackSpec] | None = None,
+) -> Router:
"""Роутер транспорт-локальных команд. Включать ДО bridge-хэндлера ``on_message``."""
router = Router(name=f"telegram-commands:{transport_id}")
- # Известные команды из реестра — в объявленном порядке.
for spec in commands:
- router.message.register(spec.handler, Command(spec.name))
+ _spec = spec
+
+ if store is not None and _spec.min_role.value > 0:
+ async def _checked(message: TgMessage, __spec: CommandSpec = _spec) -> None:
+ uid = message.from_user.id if message.from_user else 0
+ role = await store.get_role(owner_id, uid)
+ if role < __spec.min_role:
+ await message.answer(INSUFFICIENT_RIGHTS_REPLY)
+ return
+ await __spec.handler(message)
+
+ router.message.register(_checked, Command(_spec.name))
+ else:
+ router.message.register(_spec.handler, Command(_spec.name))
+
+ if callbacks:
+ for cb in callbacks:
+ _cb = cb
+
+ from aiogram import F as _F
+
+ async def _cb_checked(
+ query: CallbackQuery, __cb: CallbackSpec = _cb
+ ) -> None:
+ if store is not None:
+ uid = query.from_user.id if query.from_user else 0
+ role = await store.get_role(owner_id, uid)
+ if role < __cb.min_role:
+ await query.answer(INSUFFICIENT_RIGHTS_REPLY)
+ return
+ await __cb.handler(query)
+
+ router.callback_query.register(
+ _cb_checked,
+ _F.data.startswith(_cb.prefix),
+ )
# Сеть неизвестных команд — ПОСЛЕДНЯЯ в роутере (после всех известных),
# но всё ещё до on_message. Закрывает namespace, чтобы команда не утекла.
diff --git a/lora_bridge/transports/telegram/commands/handlers.py b/lora_bridge/transports/telegram/commands/handlers.py
index 7184536..f36881e 100644
--- a/lora_bridge/transports/telegram/commands/handlers.py
+++ b/lora_bridge/transports/telegram/commands/handlers.py
@@ -1,28 +1,47 @@
-"""Конкретные команды бота — растущая часть подсистемы.
+"""Конкретные базовые команды бота — растущая часть подсистемы.
-Добавить команду = написать хэндлер здесь и дописать строку ``CommandSpec`` в
-``COMMANDS``. Каркас (``framework``) подхватит её в роутере, ``/help`` и меню
-Telegram автоматически. Когда команд станет много — модуль дробится по фичам,
-а ``COMMANDS`` собирается из них; каркас при этом не трогается.
+Добавить команду = написать хэндлер здесь и дописать строку в
+``BASIC_COMMAND_METAS`` / ``make_basic_commands``. Каркас (``framework``)
+подхватит её в роутере, ``/help`` и меню Telegram автоматически.
"""
from __future__ import annotations
+from typing import TYPE_CHECKING
+
from aiogram.types import Message as TgMessage
-from .framework import CommandSpec, render_help
+from .framework import CommandMeta, CommandSpec, render_help
+from ..moderation.roles import Role
+
+if TYPE_CHECKING:
+ from ..moderation.store import ModerationStore
+# Статические метаданные для документации и /help; без хендлеров.
+BASIC_COMMAND_METAS: list[CommandMeta] = [
+ CommandMeta("ping", "проверка живости бота", Role.USER),
+ CommandMeta("help", "список доступных команд", Role.USER),
+]
-async def ping(message: TgMessage) -> None:
+
+async def _ping(message: TgMessage) -> None:
await message.answer("pong")
-async def show_help(message: TgMessage) -> None:
- await message.answer(render_help(COMMANDS))
+def make_basic_commands(
+ store: "ModerationStore",
+ owner_id: int,
+ all_metas: list[CommandMeta],
+) -> list[CommandSpec]:
+ """Фабрика базовых команд (ping + help) с замыканием над store."""
+ async def _show_help(message: TgMessage) -> None:
+ uid = message.from_user.id if message.from_user else 0
+ role = await store.get_role(owner_id, uid)
+ visible = [m for m in all_metas if m.min_role <= role]
+ await message.answer(render_help(visible), parse_mode="HTML")
-# Реестр команд бота. Порядок = порядок и в /help, и в меню Telegram.
-COMMANDS: list[CommandSpec] = [
- CommandSpec("ping", "проверка живости бота", ping),
- CommandSpec("help", "список доступных команд", show_help),
-]
+ return [
+ CommandSpec("ping", "проверка живости бота", Role.USER, _ping),
+ CommandSpec("help", "список доступных команд", Role.USER, _show_help),
+ ]
diff --git a/lora_bridge/transports/telegram/commands/moderation.py b/lora_bridge/transports/telegram/commands/moderation.py
new file mode 100644
index 0000000..a368a3c
--- /dev/null
+++ b/lora_bridge/transports/telegram/commands/moderation.py
@@ -0,0 +1,319 @@
+"""Команды модерации — фабрика с замыканием над store и cfg."""
+from __future__ import annotations
+
+import html
+import math
+import time
+from typing import Optional, TYPE_CHECKING
+
+from aiogram.types import (
+ CallbackQuery,
+ InlineKeyboardButton,
+ InlineKeyboardMarkup,
+ Message as TgMessage,
+)
+
+from .framework import CallbackSpec, CommandMeta, CommandSpec
+from ..moderation.roles import Role
+
+if TYPE_CHECKING:
+ from ..moderation.store import ModerationStore
+
+# Статические метаданные без хендлеров — для документации и /help.
+MODERATION_COMMAND_METAS: list[CommandMeta] = [
+ CommandMeta("set-alias", "задать себе alias (или другому — для мод+)", Role.USER),
+ CommandMeta("set-transliter", "включить/выключить транслитерацию", Role.USER),
+ CommandMeta("ban", "запретить пользователю бриджинг TG→LoRa", Role.MODERATOR),
+ CommandMeta("unban", "снять бан", Role.MODERATOR),
+ CommandMeta("banlist", "список забаненных пользователей", Role.MODERATOR),
+ CommandMeta("audit", "журнал действий модерации", Role.MODERATOR),
+ CommandMeta("role", "управление ролями (grant/revoke)", Role.ADMIN),
+]
+
+_PAGE_SIZE = 10
+
+
+async def resolve_target(message: TgMessage) -> Optional[tuple[int, Optional[str]]]:
+ """Reply или числовой аргумент → (tg_id, display_name|None). None = неопределимо."""
+ if message.reply_to_message and message.reply_to_message.from_user:
+ u = message.reply_to_message.from_user
+ return u.id, u.full_name
+ parts = (message.text or "").split(maxsplit=1)
+ if len(parts) < 2:
+ return None
+ arg = parts[1].strip().split()[0]
+ if message.entities:
+ for e in message.entities:
+ if e.type == "text_mention" and e.user:
+ return e.user.id, e.user.full_name
+ if arg.lstrip("-").isdigit():
+ return int(arg), None
+ return None
+
+
+def _mention(tg_id: int, name: Optional[str]) -> str:
+ label = html.escape(name) if name else str(tg_id)
+ return f'{label}'
+
+
+async def _audit_text_and_kb(
+ page: int, store: "ModerationStore"
+) -> tuple[str, InlineKeyboardMarkup]:
+ import datetime as _dt
+ total = await store.count_audit_entries()
+ total_pages = max(1, math.ceil(total / _PAGE_SIZE))
+ page = max(1, min(page, total_pages))
+ entries = await store.get_audit_page(page=page, page_size=_PAGE_SIZE)
+
+ lines = []
+ for e in entries:
+ ts_str = _dt.datetime.utcfromtimestamp(e.ts).strftime("%Y-%m-%d %H:%M")
+ actor = _mention(e.actor_id, e.actor_name)
+ target = _mention(e.target_id, e.target_name) if e.target_id else ""
+ detail = f" [{html.escape(e.detail)}]" if e.detail else ""
+ arrow = f" → {target}" if target else ""
+ lines.append(f"{ts_str} {actor} {html.escape(e.action)}{arrow}{detail}")
+
+ text = f"Журнал действий (стр. {page}/{total_pages}):\n\n" + "\n".join(lines)
+
+ prev_btn = InlineKeyboardButton(
+ text="←" if page > 1 else " ",
+ callback_data=f"audit:page:{page - 1}" if page > 1 else "audit:noop",
+ )
+ info_btn = InlineKeyboardButton(text=f"{page} / {total_pages}", callback_data="audit:noop")
+ next_btn = InlineKeyboardButton(
+ text="→" if page < total_pages else " ",
+ callback_data=f"audit:page:{page + 1}" if page < total_pages else "audit:noop",
+ )
+ kb = InlineKeyboardMarkup(inline_keyboard=[[prev_btn, info_btn, next_btn]])
+ return text, kb
+
+
+async def _send_audit_page_edit(query: CallbackQuery, page: int, store: "ModerationStore") -> None:
+ text, kb = await _audit_text_and_kb(page, store)
+ msg = query.message
+ if isinstance(msg, TgMessage):
+ await msg.edit_text(text, parse_mode="HTML", reply_markup=kb)
+
+
+def make_moderation_commands(
+ store: "ModerationStore",
+ cfg: object,
+) -> list[CommandSpec]:
+ """Фабрика команд модерации с замыканием над store и cfg."""
+ owner_id: int = getattr(cfg, "owner_id", 0)
+ alias_max: int = getattr(cfg, "alias_max_chars", 16)
+
+ async def ban(message: TgMessage) -> None:
+ target = await resolve_target(message)
+ if target is None:
+ await message.answer(
+ "Укажите цель: ответьте на сообщение или передайте числовой ID."
+ )
+ return
+ tg_id, display_name = target
+ actor = message.from_user
+ actor_id = actor.id if actor else 0
+ actor_name = actor.full_name if actor else None
+ await store.ban_user(tg_id, display_name)
+ await store.log_action(
+ ts=int(time.time()), actor_id=actor_id, actor_name=actor_name,
+ action="ban", target_id=tg_id, target_name=display_name,
+ )
+ await message.answer(
+ f"Пользователь {_mention(tg_id, display_name)} забанен.", parse_mode="HTML"
+ )
+
+ async def unban(message: TgMessage) -> None:
+ target = await resolve_target(message)
+ if target is None:
+ await message.answer(
+ "Укажите цель: ответьте на сообщение или передайте числовой ID."
+ )
+ return
+ tg_id, display_name = target
+ actor = message.from_user
+ actor_id = actor.id if actor else 0
+ actor_name = actor.full_name if actor else None
+ await store.unban_user(tg_id)
+ await store.log_action(
+ ts=int(time.time()), actor_id=actor_id, actor_name=actor_name,
+ action="unban", target_id=tg_id, target_name=display_name,
+ )
+ await message.answer(f"Бан снят: {_mention(tg_id, display_name)}.", parse_mode="HTML")
+
+ async def banlist(message: TgMessage) -> None:
+ bans = await store.get_banned_users()
+ if not bans:
+ await message.answer("Список банов пуст.")
+ return
+ lines = []
+ for tg_id, banned_name, alias in bans:
+ mention = _mention(tg_id, banned_name)
+ suffix = f" (alias: {html.escape(alias)})" if alias else ""
+ lines.append(f"• {mention}{suffix}")
+ await message.answer(
+ "Забаненные пользователи:\n" + "\n".join(lines), parse_mode="HTML"
+ )
+
+ async def set_alias(message: TgMessage) -> None:
+ text = message.text or ""
+ parts = text.split(maxsplit=1)
+ args = parts[1].strip() if len(parts) > 1 else ""
+ actor = message.from_user
+ actor_id = actor.id if actor else 0
+
+ target_id: int = actor_id
+ target_name: Optional[str] = None
+ new_alias: Optional[str] = None
+
+ if args:
+ if message.reply_to_message and message.reply_to_message.from_user:
+ u = message.reply_to_message.from_user
+ target_id, target_name = u.id, u.full_name
+ new_alias = args or None
+ else:
+ arg_parts = args.split(maxsplit=1)
+ first = arg_parts[0]
+ is_id = first.lstrip("-").isdigit()
+ is_mention = bool(message.entities and any(
+ e.type == "text_mention" and e.user for e in (message.entities or [])
+ ))
+ if is_id:
+ target_id = int(first)
+ new_alias = arg_parts[1] if len(arg_parts) > 1 else None
+ elif is_mention:
+ for e in (message.entities or []):
+ if e.type == "text_mention" and e.user:
+ target_id = e.user.id
+ target_name = e.user.full_name
+ new_alias = arg_parts[1] if len(arg_parts) > 1 else args
+ else:
+ new_alias = args
+
+ if target_id != actor_id:
+ role = await store.get_role(owner_id, actor_id)
+ if role < Role.MODERATOR:
+ await message.answer(
+ "Недостаточно прав для изменения alias другому пользователю."
+ )
+ return
+
+ if new_alias and len(new_alias) > alias_max:
+ await message.answer(f"Alias слишком длинный: максимум {alias_max} символов.")
+ return
+
+ await store.set_alias(target_id, new_alias)
+ actor_name = actor.full_name if actor else None
+ detail = f"alias: {new_alias}" if new_alias else "alias: сброшен"
+ await store.log_action(
+ ts=int(time.time()), actor_id=actor_id, actor_name=actor_name,
+ action="set_alias", target_id=target_id, target_name=target_name, detail=detail,
+ )
+ if new_alias:
+ await message.answer(f"Alias установлен: {html.escape(new_alias)}")
+ else:
+ await message.answer("Alias сброшен.")
+
+ async def set_transliter(message: TgMessage) -> None:
+ text = message.text or ""
+ parts = text.split(maxsplit=1)
+ arg = parts[1].strip() if len(parts) > 1 else ""
+ actor = message.from_user
+ actor_id = actor.id if actor else 0
+
+ target_id = actor_id
+ if arg:
+ target = await resolve_target(message)
+ if target is not None:
+ target_id = target[0]
+ if target_id != actor_id:
+ role = await store.get_role(owner_id, actor_id)
+ if role < Role.MODERATOR:
+ await message.answer("Недостаточно прав.")
+ return
+
+ new_val = await store.toggle_transliter(target_id)
+ state = "включена" if new_val else "выключена"
+ await message.answer(f"Транслитерация {state}.")
+
+ async def role_cmd(message: TgMessage) -> None:
+ text = message.text or ""
+ parts = text.split()
+ if len(parts) < 4:
+ await message.answer("Использование: /role grant|revoke admin|moderator ")
+ return
+ action_str, role_str, target_arg = parts[1], parts[2], parts[3]
+ if action_str not in ("grant", "revoke"):
+ await message.answer("Действие должно быть grant или revoke.")
+ return
+ if role_str not in ("admin", "moderator"):
+ await message.answer("Роль должна быть admin или moderator.")
+ return
+ if not target_arg.lstrip("-").isdigit():
+ await message.answer("Укажите числовой Telegram ID.")
+ return
+ target_id = int(target_arg)
+ actor = message.from_user
+ actor_id = actor.id if actor else 0
+ actor_name = actor.full_name if actor else None
+
+ actor_role = await store.get_role(owner_id, actor_id)
+ target_role_map = {"admin": Role.ADMIN, "moderator": Role.MODERATOR}
+ target_role = target_role_map[role_str]
+
+ from ..moderation.roles import can_grant, can_revoke
+ if action_str == "grant":
+ if not can_grant(actor_role, target_role):
+ await message.answer("Недостаточно прав для выдачи этой роли.")
+ return
+ await store.set_role(target_id, role_str, chat_id=message.chat.id)
+ else:
+ current_target_role = await store.get_role(owner_id, target_id)
+ if not can_revoke(actor_role, current_target_role):
+ await message.answer("Недостаточно прав для отзыва этой роли.")
+ return
+ await store.remove_role(target_id)
+
+ await store.log_action(
+ ts=int(time.time()), actor_id=actor_id, actor_name=actor_name,
+ action=action_str, target_id=target_id, detail=f"role: {role_str}",
+ )
+ verb = "выдана" if action_str == "grant" else "отозвана"
+ await message.answer(
+ f"Роль {role_str} {verb} для {_mention(target_id, None)}.", parse_mode="HTML"
+ )
+
+ async def audit(message: TgMessage) -> None:
+ text, kb = await _audit_text_and_kb(page=1, store=store)
+ await message.answer(text, parse_mode="HTML", reply_markup=kb)
+
+ return [
+ CommandSpec("set-alias", "задать себе alias (или другому — для мод+)", Role.USER, set_alias),
+ CommandSpec("set-transliter", "включить/выключить транслитерацию", Role.USER, set_transliter),
+ CommandSpec("ban", "запретить пользователю бриджинг TG→LoRa", Role.MODERATOR, ban),
+ CommandSpec("unban", "снять бан", Role.MODERATOR, unban),
+ CommandSpec("banlist", "список забаненных пользователей", Role.MODERATOR, banlist),
+ CommandSpec("audit", "журнал действий модерации", Role.MODERATOR, audit),
+ CommandSpec("role", "управление ролями (grant/revoke)", Role.ADMIN, role_cmd),
+ ]
+
+
+def make_audit_callbacks(store: "ModerationStore") -> list[CallbackSpec]:
+ """Фабрика CallbackSpec для пагинации /audit."""
+ async def audit_page(query: CallbackQuery) -> None:
+ data = query.data or ""
+ if data == "audit:noop":
+ await query.answer()
+ return
+ try:
+ page = int(data.split(":")[-1])
+ except ValueError:
+ await query.answer()
+ return
+ await _send_audit_page_edit(query, page, store)
+ await query.answer()
+
+ return [
+ CallbackSpec(prefix="audit:page:", handler=audit_page, min_role=Role.MODERATOR),
+ ]
diff --git a/lora_bridge/transports/telegram/moderation/__init__.py b/lora_bridge/transports/telegram/moderation/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/lora_bridge/transports/telegram/moderation/roles.py b/lora_bridge/transports/telegram/moderation/roles.py
new file mode 100644
index 0000000..62c3656
--- /dev/null
+++ b/lora_bridge/transports/telegram/moderation/roles.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+from enum import IntEnum
+
+
+class Role(IntEnum):
+ USER = 0
+ MODERATOR = 1
+ ADMIN = 2
+ OWNER = 3
+
+
+def can_grant(actor: Role, target: Role) -> bool:
+ return actor > target
+
+
+def can_revoke(actor: Role, target: Role) -> bool:
+ return actor > target
diff --git a/lora_bridge/transports/telegram/moderation/store.py b/lora_bridge/transports/telegram/moderation/store.py
new file mode 100644
index 0000000..c82b9d1
--- /dev/null
+++ b/lora_bridge/transports/telegram/moderation/store.py
@@ -0,0 +1,210 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Optional
+
+import aiosqlite
+
+from .roles import Role
+
+_SCHEMA = """
+CREATE TABLE IF NOT EXISTS roles (
+ tg_id INTEGER PRIMARY KEY,
+ role TEXT NOT NULL,
+ last_chat_id INTEGER
+);
+CREATE TABLE IF NOT EXISTS user_settings (
+ tg_id INTEGER PRIMARY KEY,
+ alias TEXT,
+ transliter INTEGER DEFAULT 0,
+ disabled INTEGER DEFAULT 0,
+ banned_name TEXT
+);
+CREATE TABLE IF NOT EXISTS audit_log (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ts INTEGER NOT NULL,
+ actor_id INTEGER NOT NULL,
+ actor_name TEXT,
+ action TEXT NOT NULL,
+ target_id INTEGER,
+ target_name TEXT,
+ detail TEXT
+);
+"""
+
+
+@dataclass(frozen=True)
+class UserSettings:
+ alias: Optional[str] = None
+ transliter: bool = False
+ disabled: bool = False
+ banned_name: Optional[str] = None
+
+
+@dataclass(frozen=True)
+class AuditEntry:
+ id: int
+ ts: int
+ actor_id: int
+ actor_name: Optional[str]
+ action: str
+ target_id: Optional[int]
+ target_name: Optional[str]
+ detail: Optional[str]
+
+
+class ModerationStore:
+ def __init__(self, db_path: str) -> None:
+ self._db_path = db_path
+ self._db: Optional[aiosqlite.Connection] = None
+
+ async def start(self) -> None:
+ self._db = await aiosqlite.connect(self._db_path)
+ await self._db.executescript(_SCHEMA)
+ await self._db.commit()
+
+ async def stop(self) -> None:
+ if self._db is not None:
+ await self._db.close()
+ self._db = None
+
+ @property
+ def _conn(self) -> aiosqlite.Connection:
+ if self._db is None:
+ raise RuntimeError("ModerationStore не запущен: вызови start()")
+ return self._db
+
+ # --- роли ---
+
+ async def get_role(self, owner_id: int, tg_id: int) -> Role:
+ if tg_id == owner_id:
+ return Role.OWNER
+ cur = await self._conn.execute("SELECT role FROM roles WHERE tg_id=?", (tg_id,))
+ row = await cur.fetchone()
+ if row is None:
+ return Role.USER
+ return Role.ADMIN if row[0] == "admin" else Role.MODERATOR
+
+ async def set_role(self, tg_id: int, role: str, chat_id: Optional[int] = None) -> None:
+ await self._conn.execute(
+ "INSERT OR REPLACE INTO roles (tg_id, role, last_chat_id) VALUES (?,?,?)",
+ (tg_id, role, chat_id),
+ )
+ await self._conn.commit()
+
+ async def remove_role(self, tg_id: int) -> None:
+ await self._conn.execute("DELETE FROM roles WHERE tg_id=?", (tg_id,))
+ await self._conn.commit()
+
+ async def get_all_privileged(self) -> list[tuple[int, str, Optional[int]]]:
+ cur = await self._conn.execute("SELECT tg_id, role, last_chat_id FROM roles")
+ rows = await cur.fetchall()
+ return [(r[0], r[1], r[2]) for r in rows]
+
+ # --- user settings ---
+
+ async def is_disabled(self, tg_id: int) -> bool:
+ cur = await self._conn.execute(
+ "SELECT disabled FROM user_settings WHERE tg_id=?", (tg_id,)
+ )
+ row = await cur.fetchone()
+ return bool(row[0]) if row else False
+
+ async def get_user_settings(self, tg_id: int) -> UserSettings:
+ cur = await self._conn.execute(
+ "SELECT alias, transliter, disabled, banned_name FROM user_settings WHERE tg_id=?",
+ (tg_id,),
+ )
+ row = await cur.fetchone()
+ if row is None:
+ return UserSettings()
+ return UserSettings(
+ alias=row[0],
+ transliter=bool(row[1]),
+ disabled=bool(row[2]),
+ banned_name=row[3],
+ )
+
+ async def ban_user(self, tg_id: int, banned_name: Optional[str]) -> None:
+ await self._conn.execute(
+ "INSERT INTO user_settings (tg_id, disabled, banned_name) VALUES (?,1,?) "
+ "ON CONFLICT(tg_id) DO UPDATE SET disabled=1, banned_name=excluded.banned_name",
+ (tg_id, banned_name),
+ )
+ await self._conn.commit()
+
+ async def unban_user(self, tg_id: int) -> None:
+ await self._conn.execute(
+ "UPDATE user_settings SET disabled=0 WHERE tg_id=?", (tg_id,)
+ )
+ await self._conn.commit()
+
+ async def get_banned_users(self) -> list[tuple[int, Optional[str], Optional[str]]]:
+ cur = await self._conn.execute(
+ "SELECT tg_id, banned_name, alias FROM user_settings WHERE disabled=1"
+ )
+ rows = await cur.fetchall()
+ return [(r[0], r[1], r[2]) for r in rows]
+
+ async def set_alias(self, tg_id: int, alias: Optional[str]) -> None:
+ await self._conn.execute(
+ "INSERT INTO user_settings (tg_id, alias) VALUES (?,?) "
+ "ON CONFLICT(tg_id) DO UPDATE SET alias=excluded.alias",
+ (tg_id, alias),
+ )
+ await self._conn.commit()
+
+ async def toggle_transliter(self, tg_id: int) -> bool:
+ cur = await self._conn.execute(
+ "SELECT transliter FROM user_settings WHERE tg_id=?", (tg_id,)
+ )
+ row = await cur.fetchone()
+ new_val = 0 if (row and row[0]) else 1
+ await self._conn.execute(
+ "INSERT INTO user_settings (tg_id, transliter) VALUES (?,?) "
+ "ON CONFLICT(tg_id) DO UPDATE SET transliter=excluded.transliter",
+ (tg_id, new_val),
+ )
+ await self._conn.commit()
+ return bool(new_val)
+
+ # --- audit ---
+
+ async def log_action(
+ self,
+ ts: int,
+ actor_id: int,
+ actor_name: Optional[str],
+ action: str,
+ target_id: Optional[int] = None,
+ target_name: Optional[str] = None,
+ detail: Optional[str] = None,
+ ) -> None:
+ await self._conn.execute(
+ "INSERT INTO audit_log "
+ "(ts, actor_id, actor_name, action, target_id, target_name, detail) "
+ "VALUES (?,?,?,?,?,?,?)",
+ (ts, actor_id, actor_name, action, target_id, target_name, detail),
+ )
+ await self._conn.commit()
+
+ async def count_audit_entries(self) -> int:
+ cur = await self._conn.execute("SELECT COUNT(*) FROM audit_log")
+ row = await cur.fetchone()
+ return int(row[0]) if row else 0
+
+ async def get_audit_page(self, page: int, page_size: int = 10) -> list[AuditEntry]:
+ offset = (page - 1) * page_size
+ cur = await self._conn.execute(
+ "SELECT id, ts, actor_id, actor_name, action, target_id, target_name, detail "
+ "FROM audit_log ORDER BY ts DESC LIMIT ? OFFSET ?",
+ (page_size, offset),
+ )
+ rows = await cur.fetchall()
+ return [
+ AuditEntry(
+ id=r[0], ts=r[1], actor_id=r[2], actor_name=r[3],
+ action=r[4], target_id=r[5], target_name=r[6], detail=r[7],
+ )
+ for r in rows
+ ]
diff --git a/lora_bridge/transports/telegram/moderation/transliterate.py b/lora_bridge/transports/telegram/moderation/transliterate.py
new file mode 100644
index 0000000..b208534
--- /dev/null
+++ b/lora_bridge/transports/telegram/moderation/transliterate.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+_TABLE = str.maketrans({
+ 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd',
+ 'е': 'e', 'ё': 'yo', 'ж': 'zh', 'з': 'z', 'и': 'i',
+ 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n',
+ 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't',
+ 'у': 'u', 'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch',
+ 'ш': 'sh', 'щ': 'sch', 'ъ': '', 'ы': 'y', 'ь': '',
+ 'э': 'e', 'ю': 'yu', 'я': 'ya',
+ 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D',
+ 'Е': 'E', 'Ё': 'Yo', 'Ж': 'Zh', 'З': 'Z', 'И': 'I',
+ 'Й': 'J', 'К': 'K', 'Л': 'L', 'М': 'M', 'Н': 'N',
+ 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T',
+ 'У': 'U', 'Ф': 'F', 'Х': 'H', 'Ц': 'Ts', 'Ч': 'Ch',
+ 'Ш': 'Sh', 'Щ': 'Sch', 'Ъ': '', 'Ы': 'Y', 'Ь': '',
+ 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya',
+})
+
+
+def transliterate(text: str) -> str:
+ return text.translate(_TABLE)
diff --git a/lora_bridge/transports/telegram/reactions.py b/lora_bridge/transports/telegram/reactions.py
index 783436f..c691e98 100644
--- a/lora_bridge/transports/telegram/reactions.py
+++ b/lora_bridge/transports/telegram/reactions.py
@@ -16,7 +16,7 @@
from typing import Optional
from aiogram import Bot
-from aiogram.types import ReactionTypeEmoji, ReactionTypeUnion
+from aiogram.types import Message as TgMessage, ReactionTypeEmoji, ReactionTypeUnion
from ...domain.models import DeliveryStatus, RejectReason
@@ -156,6 +156,17 @@ async def report(
if reaction:
self._debouncer.schedule(key, reaction, self._bot)
+ async def report_disabled(self, message: "TgMessage") -> None:
+ """Реакция 🚫 на сообщение забаненного пользователя (best-effort)."""
+ try:
+ await self._bot.set_message_reaction( # verify
+ message.chat.id,
+ message.message_id,
+ reaction=[ReactionTypeEmoji(emoji="🚫")],
+ )
+ except Exception: # noqa: BLE001
+ pass
+
@staticmethod
def _reaction_for(
status: DeliveryStatus, reason: Optional[RejectReason]
diff --git a/lora_bridge/transports/telegram/transport.py b/lora_bridge/transports/telegram/transport.py
index d7a6cf5..fece920 100644
--- a/lora_bridge/transports/telegram/transport.py
+++ b/lora_bridge/transports/telegram/transport.py
@@ -17,7 +17,17 @@
from aiogram import Bot, Dispatcher, F, Router
from aiogram.types import Message as TgMessage
-from .commands import COMMANDS, build_command_router, command_menu
+from .commands import (
+ ALL_COMMAND_METAS,
+ build_command_router,
+ command_menu,
+ make_audit_callbacks,
+ make_basic_commands,
+ make_moderation_commands,
+)
+from .moderation.roles import Role
+from .moderation.store import ModerationStore, UserSettings
+from .moderation.transliterate import transliterate
from .reactions import ReactionFeedback
from ..hub import Hub
from ...domain.ports import Transport
@@ -63,14 +73,43 @@ class TelegramTransport(Transport):
_poll_task: asyncio.Task[None] | None = None
- def __init__(self, transport_id: str, config: TelegramMessengerConfig) -> None:
+ def __init__(
+ self,
+ transport_id: str,
+ config: "TelegramMessengerConfig",
+ *,
+ _store: Optional[ModerationStore] = None,
+ ) -> None:
self.id = transport_id
self._hub = Hub()
self._bot = Bot(config.token)
self._dp = Dispatcher()
+ self._store: Optional[ModerationStore] = None
+ self._owner_id: int = 0
+
+ if config.commands is not None:
+ owner_id = config.commands.owner_id
+ self._owner_id = owner_id
+ if _store is not None:
+ self._store = _store
+ else:
+ from ...settings import Settings
+ db_path = Settings.from_env().db_path
+ self._store = ModerationStore(db_path)
+ all_specs = (
+ make_basic_commands(self._store, owner_id, ALL_COMMAND_METAS)
+ + make_moderation_commands(self._store, config.commands)
+ )
+ callbacks = make_audit_callbacks(self._store)
+ self._dp.include_router(
+ build_command_router(self.id, all_specs, self._store, owner_id, callbacks)
+ )
+ else:
+ # только catchall — команды не утекают в pipeline даже без блока commands:
+ self._dp.include_router(build_command_router(self.id, []))
+
# Порядок включения = порядок диспетча: команды перехватываются ДО bridge-хэндлера,
# поэтому транспорт-локальные команды не доходят до on_message → не текут в pipeline.
- self._dp.include_router(build_command_router(self.id, COMMANDS))
bridge = Router(name=f"telegram-bridge:{self.id}")
bridge.message.register(self.on_message, F.text) # verify: фильтр текстовых
self._dp.include_router(bridge)
@@ -79,7 +118,25 @@ def __init__(self, transport_id: str, config: TelegramMessengerConfig) -> None:
async def start(self) -> None:
me = await self._bot.get_me() # verify: бот доступен (sanity)
log.info("Telegram-транспорт '%s': бот @%s (id=%d) подключён", self.id, me.username, me.id)
- await self._bot.set_my_commands(command_menu(COMMANDS)) # verify: меню команд в Telegram
+
+ if self._store is not None:
+ await self._store.start()
+ # дефолтное меню — USER-уровень для всех
+ user_menu = command_menu(ALL_COMMAND_METAS, Role.USER)
+ await self._bot.set_my_commands(user_menu) # verify
+ # восстановить per-user меню для privileged users из БД
+ privileged = await self._store.get_all_privileged()
+ for tg_id, role_str, last_chat_id in privileged:
+ if last_chat_id is None:
+ continue
+ role = Role.ADMIN if role_str == "admin" else Role.MODERATOR
+ role_menu = command_menu(ALL_COMMAND_METAS, role)
+ from aiogram.types import BotCommandScopeChatMember
+ await self._bot.set_my_commands( # verify
+ role_menu,
+ scope=BotCommandScopeChatMember(chat_id=last_chat_id, user_id=tg_id),
+ )
+
self._poll_task = asyncio.create_task(
self._dp.start_polling(self._bot, handle_signals=False) # verify
)
@@ -88,23 +145,45 @@ async def stop(self) -> None:
await self._dp.stop_polling() # verify
if self._poll_task is not None:
self._poll_task.cancel()
+ if self._store is not None:
+ await self._store.stop()
await self._bot.close() # verify
async def on_message(self, message: TgMessage) -> None:
- await self._hub.publish(self.normalize(message))
+ user_id = message.from_user.id if message.from_user else None
+ if user_id is not None and self._store is not None:
+ if await self._store.is_disabled(user_id):
+ await self._reactions.report_disabled(message)
+ return
+ settings: Optional[UserSettings] = await self._store.get_user_settings(user_id)
+ else:
+ settings = None
+ await self._hub.publish(self.normalize(message, settings))
- def normalize(self, message: TgMessage) -> Message:
+ def normalize(
+ self, message: TgMessage, settings: Optional[UserSettings] = None
+ ) -> Message:
thread = message.message_thread_id
chat_id = str(message.chat.id)
user = message.from_user
+ display_name = user.full_name if user else "unknown"
+ text = message.text or ""
+
+ if settings is not None:
+ if settings.alias:
+ display_name = settings.alias
+ if settings.transliter:
+ text = transliterate(text)
+ display_name = transliterate(display_name)
+
return Message(
id=str(message.message_id),
source=ChannelRef(self.id, messenger_channel(chat_id, str(thread) if thread else None)),
sender=Identity(
- display_name=(user.full_name if user else "unknown"),
+ display_name=display_name,
transport_uid=str(user.id) if user else "0",
),
- text=message.text or "",
+ text=text,
)
async def send(self, target: ChannelRef, msg: Message) -> SendResult:
diff --git a/mkdocs.yml b/mkdocs.yml
index 38e3d71..0d9fa93 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -7,6 +7,7 @@ docs_dir: docs
# но в выходной site/ копировать его не нужно — это не страница доки.
exclude_docs: |
gen_pages.py
+ superpowers/**
theme:
name: material
@@ -72,3 +73,6 @@ nav:
- Для разработчиков:
- Обзор: contributing/index.md
- Архитектура: ARCHITECTURE.md
+ - Дизайн-спеки: contributing/design-specs.md
+ - Справочник:
+ - Команды бота: reference/commands.md
diff --git a/tests/test_config_schema.py b/tests/test_config_schema.py
index 0014d5d..b685b2b 100644
--- a/tests/test_config_schema.py
+++ b/tests/test_config_schema.py
@@ -16,6 +16,7 @@
EndpointBase,
MessengerConfig,
MeshCoreNode,
+ TelegramCommandsConfig,
TelegramMessengerConfig,
)
@@ -210,3 +211,32 @@ def test_unknown_messenger_kind_rejected():
def test_telegram_missing_token_rejected():
with pytest.raises(ValidationError):
TelegramMessengerConfig.model_validate({"id": "tg", "kind": "telegram"})
+
+
+# ---------------------------------------------------------------------------
+# TelegramCommandsConfig
+# ---------------------------------------------------------------------------
+
+
+def test_telegram_commands_optional() -> None:
+ cfg = TelegramMessengerConfig(id="tg", kind="telegram", token="tok")
+ assert cfg.commands is None
+
+
+def test_telegram_commands_with_block() -> None:
+ cfg = TelegramMessengerConfig(
+ id="tg", kind="telegram", token="tok",
+ commands=TelegramCommandsConfig(owner_id=123),
+ )
+ assert cfg.commands is not None
+ assert cfg.commands.owner_id == 123
+ assert cfg.commands.alias_max_chars == 16
+
+
+def test_telegram_commands_alias_max_chars_custom() -> None:
+ cfg = TelegramMessengerConfig(
+ id="tg", kind="telegram", token="tok",
+ commands=TelegramCommandsConfig(owner_id=1, alias_max_chars=8),
+ )
+ assert cfg.commands is not None
+ assert cfg.commands.alias_max_chars == 8
diff --git a/tests/test_docs_generator.py b/tests/test_docs_generator.py
index 2dcd608..d893f31 100644
--- a/tests/test_docs_generator.py
+++ b/tests/test_docs_generator.py
@@ -134,6 +134,27 @@ def test_no_unresolved_python_typing_repr(emitted):
)
+def test_commands_page_emitted(emitted):
+ assert "reference/commands.md" in emitted
+ page = emitted["reference/commands.md"]
+ assert "/ping" in page
+ assert "/ban" in page
+ assert "moderator" in page.lower()
+
+
+def test_specs_index_emitted(emitted):
+ assert "contributing/design-specs.md" in emitted
+ idx = emitted["contributing/design-specs.md"]
+ assert "2026-06-28" in idx
+ assert "Telegram" in idx
+
+
+def test_commands_page_shows_all_roles(emitted):
+ page = emitted["reference/commands.md"]
+ for role in ("user", "moderator", "admin"):
+ assert role in page.lower()
+
+
def test_class_docstring_admonition_starts_at_column_zero(emitted):
"""Регрессия: ``!!! note`` в class-docstring'е должен попасть в markdown
без отступа (иначе mkdocs рендерит как code block, не admonition).
diff --git a/tests/test_telegram_commands.py b/tests/test_telegram_commands.py
index d145168..d858ae6 100644
--- a/tests/test_telegram_commands.py
+++ b/tests/test_telegram_commands.py
@@ -1,115 +1,126 @@
-"""Шов транспорт-локальных команд: гарантия «команда не течёт в pipeline».
-
-Главный инвариант (принцип #10, закодирован тестом): любое сообщение-команда,
-прогнанное через РЕАЛЬНЫЙ Dispatcher транспорта, обрабатывается локально и
-**не** доходит до ``_hub.publish`` — то есть не попадает в общий мост LoRa.
-Обычный текст, наоборот, обязан публиковаться.
-
-Бот создаётся с синтаксически валидным фейк-токеном; на сеть не ходим —
-``send_message`` подменяется, ответ команды перехватывается.
-"""
-
+"""Шов транспорт-локальных команд: гарантия «команда не течёт в pipeline»."""
from __future__ import annotations
import datetime as dt
from types import SimpleNamespace
from unittest.mock import AsyncMock
+import pytest
from aiogram.types import BotCommand, Chat, Message, Update, User
-from lora_bridge.transports.telegram.commands import COMMANDS, command_menu, render_help
+from lora_bridge.transports.telegram.commands import ALL_COMMAND_METAS, command_menu, render_help
+from lora_bridge.transports.telegram.commands.framework import CommandMeta
+from lora_bridge.transports.telegram.moderation.roles import Role
+from lora_bridge.transports.telegram.moderation.store import ModerationStore
from lora_bridge.transports.telegram.transport import TelegramTransport
_FAKE_TOKEN = "123456:AAFakeFakeFakeFakeFakeFakeFakeFakeFak"
+_OWNER_ID = 1
+
+
+async def _make_store() -> ModerationStore:
+ s = ModerationStore(":memory:")
+ await s.start()
+ return s
-def _make_transport() -> TelegramTransport:
- config = SimpleNamespace(token=_FAKE_TOKEN)
+def _make_transport_no_commands() -> TelegramTransport:
+ config = SimpleNamespace(token=_FAKE_TOKEN, commands=None)
transport = TelegramTransport("tg", config) # type: ignore[arg-type]
- # message.answer() уходит в сеть через bot.session — глушим, чтобы ответ
- # команды (reply) не делал реальный HTTP-вызов с фейк-токеном.
- transport._bot.session = AsyncMock() # type: ignore[assignment]
- transport._hub.publish = AsyncMock() # type: ignore[method-assign]
+ transport._bot.session = AsyncMock()
+ transport._hub.publish = AsyncMock()
+ return transport
+
+
+async def _make_transport_with_commands() -> TelegramTransport:
+ store = await _make_store()
+ config = SimpleNamespace(
+ token=_FAKE_TOKEN,
+ commands=SimpleNamespace(owner_id=_OWNER_ID, alias_max_chars=16),
+ )
+ transport = TelegramTransport("tg", config, _store=store) # type: ignore[arg-type]
+ transport._bot.session = AsyncMock()
+ transport._hub.publish = AsyncMock()
return transport
-def _update(text: str) -> Update:
+def _update(text: str, user_id: int = 2) -> Update:
return Update(
update_id=1,
message=Message(
message_id=10,
date=dt.datetime(2024, 1, 1),
chat=Chat(id=1, type="private"),
- from_user=User(id=2, is_bot=False, first_name="tester"),
+ from_user=User(id=user_id, is_bot=False, first_name="tester"),
text=text,
),
)
-async def _feed(transport: TelegramTransport, text: str) -> None:
- await transport._dp.feed_update(transport._bot, _update(text))
+async def _feed(transport: TelegramTransport, text: str, user_id: int = 2) -> None:
+ await transport._dp.feed_update(transport._bot, _update(text, user_id))
async def test_known_command_does_not_publish() -> None:
- transport = _make_transport()
-
+ transport = await _make_transport_with_commands()
await _feed(transport, "/ping")
-
- transport._hub.publish.assert_not_called() # не протекло в pipeline
- sent = transport._bot.session.await_args.args[1] # исходящий SendMessage
- assert sent.text == "pong" # отработал ping-хэндлер, а не сеть unknown
+ transport._hub.publish.assert_not_called()
+ sent = transport._bot.session.await_args.args[1]
+ assert sent.text == "pong"
async def test_unknown_command_does_not_publish() -> None:
- transport = _make_transport()
-
+ transport = await _make_transport_with_commands()
await _feed(transport, "/no_such_command arg")
-
- transport._hub.publish.assert_not_called() # namespace закрыт — не протекает
- transport._bot.session.assert_awaited() # «неизвестная команда» в ответ
+ transport._hub.publish.assert_not_called()
+ transport._bot.session.assert_awaited()
async def test_plain_text_is_published_to_pipeline() -> None:
- transport = _make_transport()
-
+ transport = await _make_transport_with_commands()
await _feed(transport, "привет, мост")
-
- transport._hub.publish.assert_awaited_once() # обычный текст идёт в мост
+ transport._hub.publish.assert_awaited_once()
published = transport._hub.publish.await_args.args[0]
assert published.text == "привет, мост"
- transport._bot.session.assert_not_awaited() # bridge-путь не отвечает в чат
+ transport._bot.session.assert_not_awaited()
+
+
+async def test_command_without_commands_block_does_not_publish() -> None:
+ transport = _make_transport_no_commands()
+ await _feed(transport, "/ping")
+ transport._hub.publish.assert_not_called()
-async def test_help_lists_every_registered_command() -> None:
- # render_help() — чистая функция: реестр единственный источник правды,
- # каждый зарегистрированный command попадает в выхлоп с описанием.
- help_text = render_help(COMMANDS)
- for spec in COMMANDS:
- assert f"/{spec.name}" in help_text
- assert spec.description in help_text
+async def test_help_lists_commands_for_user_role() -> None:
+ help_text = render_help([m for m in ALL_COMMAND_METAS if m.min_role <= Role.USER])
+ assert "/ping" in help_text
+ assert "/help" in help_text
-async def test_help_command_does_not_publish() -> None:
- transport = _make_transport()
+async def test_help_hides_moderator_commands_from_user() -> None:
+ help_text = render_help([m for m in ALL_COMMAND_METAS if m.min_role <= Role.USER])
+ assert "/ban" not in help_text
- await _feed(transport, "/help")
- transport._hub.publish.assert_not_called() # /help — транспорт-локальная, не в мост
- sent = transport._bot.session.await_args.args[1] # исходящий SendMessage
- assert "доступных команд" in sent.text # отработал show_help, а не сеть unknown
+async def test_help_shows_moderator_commands_to_moderator() -> None:
+ help_text = render_help([m for m in ALL_COMMAND_METAS if m.min_role <= Role.MODERATOR])
+ assert "/ban" in help_text
-def test_command_menu_mirrors_registry() -> None:
- # Меню Telegram (set_my_commands) строится из того же реестра — без дрейфа.
- assert command_menu(COMMANDS) == [
- BotCommand(command=spec.name, description=spec.description) for spec in COMMANDS
- ]
+def test_command_menu_filters_by_role() -> None:
+ user_menu = command_menu(ALL_COMMAND_METAS, Role.USER)
+ mod_menu = command_menu(ALL_COMMAND_METAS, Role.MODERATOR)
+ user_names = {c.command for c in user_menu}
+ mod_names = {c.command for c in mod_menu}
+ assert "ping" in user_names
+ assert "ban" not in user_names
+ assert "ban" in mod_names
-async def test_start_registers_command_menu() -> None:
- transport = _make_transport()
+async def test_start_registers_default_command_menu() -> None:
+ transport = await _make_transport_with_commands()
transport._bot.get_me = AsyncMock(return_value=SimpleNamespace(username="bot", id=7))
- transport._bot.set_my_commands = AsyncMock() # type: ignore[method-assign]
+ transport._bot.set_my_commands = AsyncMock()
async def _no_poll(*args: object, **kwargs: object) -> None:
return None
@@ -119,6 +130,4 @@ async def _no_poll(*args: object, **kwargs: object) -> None:
if transport._poll_task is not None:
transport._poll_task.cancel()
- transport._bot.set_my_commands.assert_awaited_once()
- sent = transport._bot.set_my_commands.await_args.args[0]
- assert sent == command_menu(COMMANDS)
+ transport._bot.set_my_commands.assert_awaited()
diff --git a/tests/test_telegram_moderation_commands.py b/tests/test_telegram_moderation_commands.py
new file mode 100644
index 0000000..ba5313a
--- /dev/null
+++ b/tests/test_telegram_moderation_commands.py
@@ -0,0 +1,137 @@
+"""Тесты команд модерации: парсинг аргументов и проверка прав."""
+from __future__ import annotations
+
+import datetime as dt
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+from collections.abc import AsyncGenerator
+
+import pytest
+from aiogram.types import Chat, Message, User
+
+from lora_bridge.transports.telegram.commands.moderation import (
+ MODERATION_COMMAND_METAS,
+ make_moderation_commands,
+ resolve_target,
+)
+from lora_bridge.transports.telegram.moderation.roles import Role
+from lora_bridge.transports.telegram.moderation.store import ModerationStore
+
+
+@pytest.fixture
+async def store() -> AsyncGenerator[ModerationStore, None]:
+ s = ModerationStore(":memory:")
+ await s.start()
+ yield s
+ await s.stop()
+
+
+def _msg(text: str, user_id: int = 10, reply_user_id: int | None = None) -> Message:
+ reply = None
+ if reply_user_id is not None:
+ reply = Message(
+ message_id=5,
+ date=dt.datetime(2024, 1, 1),
+ chat=Chat(id=1, type="group"),
+ from_user=User(id=reply_user_id, is_bot=False, first_name="Target"),
+ text="some text",
+ )
+ return Message(
+ message_id=10,
+ date=dt.datetime(2024, 1, 1),
+ chat=Chat(id=1, type="group"),
+ from_user=User(id=user_id, is_bot=False, first_name="Actor"),
+ text=text,
+ reply_to_message=reply,
+ )
+
+
+def test_moderation_command_metas_complete() -> None:
+ names = {m.name for m in MODERATION_COMMAND_METAS}
+ assert {"ban", "unban", "banlist", "set-alias", "set-transliter", "role", "audit"} == names
+
+
+def test_ban_requires_moderator_role() -> None:
+ specs = {s.name: s for s in make_moderation_commands(
+ ModerationStore(":memory:"), SimpleNamespace(owner_id=1, alias_max_chars=16)
+ )}
+ assert specs["ban"].min_role == Role.MODERATOR
+
+
+def test_role_requires_admin_role() -> None:
+ specs = {s.name: s for s in make_moderation_commands(
+ ModerationStore(":memory:"), SimpleNamespace(owner_id=1, alias_max_chars=16)
+ )}
+ assert specs["role"].min_role == Role.ADMIN
+
+
+async def test_resolve_target_from_reply() -> None:
+ msg = _msg("/ban", reply_user_id=99)
+ result = await resolve_target(msg)
+ assert result is not None
+ assert result[0] == 99
+ assert result[1] == "Target"
+
+
+async def test_resolve_target_from_numeric_arg() -> None:
+ msg = _msg("/ban 12345")
+ result = await resolve_target(msg)
+ assert result is not None
+ assert result[0] == 12345
+ assert result[1] is None
+
+
+async def test_resolve_target_none_when_no_arg() -> None:
+ msg = _msg("/ban")
+ result = await resolve_target(msg)
+ assert result is None
+
+
+async def test_ban_bans_user(store: ModerationStore) -> None:
+ cmds = {s.name: s for s in make_moderation_commands(
+ store, SimpleNamespace(owner_id=1, alias_max_chars=16)
+ )}
+ msg = _msg("/ban 555", user_id=1)
+ mock_answer = AsyncMock()
+ with patch.object(type(msg), "answer", mock_answer):
+ await cmds["ban"].handler(msg)
+ assert await store.is_disabled(555) is True
+
+
+async def test_unban_unbans_user(store: ModerationStore) -> None:
+ await store.ban_user(555, "X")
+ cmds = {s.name: s for s in make_moderation_commands(
+ store, SimpleNamespace(owner_id=1, alias_max_chars=16)
+ )}
+ msg = _msg("/unban 555", user_id=1)
+ mock_answer = AsyncMock()
+ with patch.object(type(msg), "answer", mock_answer):
+ await cmds["unban"].handler(msg)
+ assert await store.is_disabled(555) is False
+
+
+async def test_set_alias_enforces_length(store: ModerationStore) -> None:
+ cmds = {s.name: s for s in make_moderation_commands(
+ store, SimpleNamespace(owner_id=1, alias_max_chars=5)
+ )}
+ msg = _msg("/set-alias TooLongAlias", user_id=10)
+ mock_answer = AsyncMock()
+ with patch.object(type(msg), "answer", mock_answer):
+ await cmds["set-alias"].handler(msg)
+ s = await store.get_user_settings(10)
+ assert s.alias is None
+ mock_answer.assert_awaited_once()
+ assert "5" in mock_answer.await_args.args[0]
+
+
+async def test_set_alias_sets_for_self(store: ModerationStore) -> None:
+ cmds = {s.name: s for s in make_moderation_commands(
+ store, SimpleNamespace(owner_id=1, alias_max_chars=16)
+ )}
+ msg = _msg("/set-alias Вася", user_id=10)
+ mock_answer = AsyncMock()
+ with patch.object(type(msg), "answer", mock_answer):
+ await cmds["set-alias"].handler(msg)
+ s = await store.get_user_settings(10)
+ assert s.alias == "Вася"
diff --git a/tests/test_telegram_moderation_roles.py b/tests/test_telegram_moderation_roles.py
new file mode 100644
index 0000000..df2837c
--- /dev/null
+++ b/tests/test_telegram_moderation_roles.py
@@ -0,0 +1,31 @@
+from lora_bridge.transports.telegram.moderation.roles import Role, can_grant, can_revoke
+
+
+def test_role_ordering() -> None:
+ assert Role.USER < Role.MODERATOR < Role.ADMIN < Role.OWNER
+
+
+def test_can_grant_strictly_below() -> None:
+ assert can_grant(Role.ADMIN, Role.MODERATOR) is True
+ assert can_grant(Role.ADMIN, Role.ADMIN) is False
+ assert can_grant(Role.ADMIN, Role.OWNER) is False
+
+
+def test_owner_can_grant_all() -> None:
+ assert can_grant(Role.OWNER, Role.ADMIN) is True
+ assert can_grant(Role.OWNER, Role.MODERATOR) is True
+
+
+def test_user_cannot_grant() -> None:
+ assert can_grant(Role.USER, Role.USER) is False
+
+
+def test_can_revoke_strictly_below() -> None:
+ assert can_revoke(Role.ADMIN, Role.MODERATOR) is True
+ assert can_revoke(Role.ADMIN, Role.ADMIN) is False
+ assert can_revoke(Role.MODERATOR, Role.ADMIN) is False
+
+
+def test_user_cannot_revoke() -> None:
+ assert can_revoke(Role.USER, Role.USER) is False
+ assert can_revoke(Role.USER, Role.MODERATOR) is False
diff --git a/tests/test_telegram_moderation_store.py b/tests/test_telegram_moderation_store.py
new file mode 100644
index 0000000..a8679ac
--- /dev/null
+++ b/tests/test_telegram_moderation_store.py
@@ -0,0 +1,91 @@
+# tests/test_telegram_moderation_store.py
+from collections.abc import AsyncGenerator
+
+import pytest
+from lora_bridge.transports.telegram.moderation.roles import Role
+from lora_bridge.transports.telegram.moderation.store import ModerationStore, UserSettings
+
+@pytest.fixture
+async def store() -> AsyncGenerator[ModerationStore, None]:
+ s = ModerationStore(":memory:")
+ await s.start()
+ yield s
+ await s.stop()
+
+async def test_default_role_is_user(store: ModerationStore) -> None:
+ role = await store.get_role(owner_id=1, tg_id=999)
+ assert role == Role.USER
+
+async def test_owner_id_returns_owner(store: ModerationStore) -> None:
+ role = await store.get_role(owner_id=42, tg_id=42)
+ assert role == Role.OWNER
+
+async def test_set_and_get_role(store: ModerationStore) -> None:
+ await store.set_role(tg_id=10, role="admin", chat_id=100)
+ assert await store.get_role(owner_id=1, tg_id=10) == Role.ADMIN
+
+async def test_remove_role_reverts_to_user(store: ModerationStore) -> None:
+ await store.set_role(tg_id=10, role="moderator", chat_id=100)
+ await store.remove_role(tg_id=10)
+ assert await store.get_role(owner_id=1, tg_id=10) == Role.USER
+
+async def test_ban_and_is_disabled(store: ModerationStore) -> None:
+ assert await store.is_disabled(tg_id=5) is False
+ await store.ban_user(tg_id=5, banned_name="Vasya")
+ assert await store.is_disabled(tg_id=5) is True
+
+async def test_unban(store: ModerationStore) -> None:
+ await store.ban_user(tg_id=5, banned_name=None)
+ await store.unban_user(tg_id=5)
+ assert await store.is_disabled(tg_id=5) is False
+
+async def test_set_alias(store: ModerationStore) -> None:
+ await store.set_alias(tg_id=7, alias="Вася")
+ s = await store.get_user_settings(tg_id=7)
+ assert s.alias == "Вася"
+
+async def test_reset_alias(store: ModerationStore) -> None:
+ await store.set_alias(tg_id=7, alias="Вася")
+ await store.set_alias(tg_id=7, alias=None)
+ s = await store.get_user_settings(tg_id=7)
+ assert s.alias is None
+
+async def test_toggle_transliter(store: ModerationStore) -> None:
+ result = await store.toggle_transliter(tg_id=7)
+ assert result is True
+ result = await store.toggle_transliter(tg_id=7)
+ assert result is False
+
+async def test_get_banned_users(store: ModerationStore) -> None:
+ await store.ban_user(tg_id=1, banned_name="Alice")
+ await store.ban_user(tg_id=2, banned_name=None)
+ bans = await store.get_banned_users()
+ ids = [b[0] for b in bans]
+ assert 1 in ids and 2 in ids
+
+async def test_audit_log(store: ModerationStore) -> None:
+ await store.log_action(ts=1000, actor_id=1, actor_name="Admin",
+ action="ban", target_id=2, target_name="User")
+ count = await store.count_audit_entries()
+ assert count == 1
+ page = await store.get_audit_page(page=1, page_size=10)
+ assert len(page) == 1
+ assert page[0].action == "ban"
+ assert page[0].actor_id == 1
+
+async def test_audit_pagination(store: ModerationStore) -> None:
+ for i in range(25):
+ await store.log_action(ts=i, actor_id=1, actor_name="A", action="ban")
+ assert await store.count_audit_entries() == 25
+ p1 = await store.get_audit_page(page=1, page_size=10)
+ p3 = await store.get_audit_page(page=3, page_size=10)
+ assert len(p1) == 10
+ assert len(p3) == 5
+
+async def test_get_all_privileged(store: ModerationStore) -> None:
+ await store.set_role(tg_id=10, role="admin", chat_id=100)
+ await store.set_role(tg_id=20, role="moderator", chat_id=200)
+ priv = await store.get_all_privileged()
+ assert len(priv) == 2
+ tg_ids = {p[0] for p in priv}
+ assert {10, 20} == tg_ids
diff --git a/tests/test_telegram_moderation_transliterate.py b/tests/test_telegram_moderation_transliterate.py
new file mode 100644
index 0000000..bbc9fdb
--- /dev/null
+++ b/tests/test_telegram_moderation_transliterate.py
@@ -0,0 +1,26 @@
+from lora_bridge.transports.telegram.moderation.transliterate import transliterate
+
+
+def test_basic_lowercase() -> None:
+ assert transliterate("привет") == "privet"
+
+
+def test_basic_uppercase() -> None:
+ assert transliterate("Привет") == "Privet"
+
+
+def test_non_cyrillic_passthrough() -> None:
+ assert transliterate("hello 123!") == "hello 123!"
+
+
+def test_mixed() -> None:
+ result = transliterate("Вася: hello")
+ assert result == "Vasya: hello"
+
+
+def test_soft_hard_sign_removed() -> None:
+ assert transliterate("объект") == "obekt"
+
+
+def test_empty_string() -> None:
+ assert transliterate("") == ""
diff --git a/tests/test_telegram_send_format.py b/tests/test_telegram_send_format.py
index 1338d25..25093c0 100644
--- a/tests/test_telegram_send_format.py
+++ b/tests/test_telegram_send_format.py
@@ -24,7 +24,7 @@
def _make_transport() -> TelegramTransport:
- config = SimpleNamespace(token=_FAKE_TOKEN)
+ config = SimpleNamespace(token=_FAKE_TOKEN, commands=None)
transport = TelegramTransport("tg", config) # type: ignore[arg-type]
transport._bot.send_message = AsyncMock() # type: ignore[method-assign]
return transport