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