From d4bfe672b0a1772fbe84ced68d599d7f3886b66c Mon Sep 17 00:00:00 2001 From: Flexlug Date: Sat, 4 Jul 2026 15:24:28 +0300 Subject: [PATCH 1/9] =?UTF-8?q?docs(design):=20=D1=81=D0=BF=D0=B5=D0=BA?= =?UTF-8?q?=D0=B0=20=E2=80=94=20=D0=BE=D0=B1=D1=8F=D0=B7=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20alias=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B1=D1=80=D0=B8=D0=B4=D0=B6=D0=B8=D0=BD=D0=B3=D0=B0=20=D0=B2?= =?UTF-8?q?=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- .../specs/2026-07-04-require-alias-design.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-04-require-alias-design.md diff --git a/docs/superpowers/specs/2026-07-04-require-alias-design.md b/docs/superpowers/specs/2026-07-04-require-alias-design.md new file mode 100644 index 0000000..c650593 --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-require-alias-design.md @@ -0,0 +1,118 @@ +# Telegram: обязательный alias для бриджинга + +**Дата**: 2026-07-04 +**Ветка**: worktree-config-example-review +**Слой**: `lora_bridge/transports/telegram/` + +--- + +## 1. Контекст + +Alias уже существует (`docs/superpowers/specs/2026-06-28-telegram-roles-moderation-design.md`, +§2–4) как опциональный псевдоним, подставляемый в `display_name` при наличии. Пользователь +без alias сейчас всё равно бриджится — под своим Telegram full name. + +Этот дизайн делает alias **обязательным условием** для бриджинга: сообщение пользователя без +alias в LoRa не уходит вообще (не публикуется в `Hub`), независимо от роли (включая OWNER). + +--- + +## 2. Конфиг + +Новое поле в `TelegramCommandsConfig` (`config/schema/messengers.py`): + +```python +class TelegramCommandsConfig(BaseModel): + owner_id: int + alias_max_chars: int = 16 + require_alias: bool = True # новое; без alias — сообщение не публикуется в Hub +``` + +Работает только когда блок `commands` включён — без него `/set_alias` недоступен и +`ModerationStore` не создаётся (`transport.py:92-100`), так что enforcement естественным +образом неактивен (соответствующая ветка `on_message` не вызывается). + +--- + +## 3. Поведение в `on_message` + +Проверка встаёт сразу после `is_disabled`, до `normalize`/`publish`: + +```python +async def on_message(self, message: TgMessage) -> None: + user_id = message.from_user.id if message.from_user else None + settings: Optional[UserSettings] = 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 = await self._store.get_user_settings(user_id) + if self._require_alias and not settings.alias: + await self._reactions.report_alias_required(message) + asyncio.create_task( + self._reactions.send_expiring_reply(message, ALIAS_REQUIRED_TEXT) + ) + return + await self._hub.publish(self.normalize(message, settings)) +``` + +`self._require_alias` — читается один раз в `__init__` из +`config.commands.require_alias if config.commands else False`. + +Правило единое для всех ролей — не проверяется `Role`, ветка одна. + +Текст `ALIAS_REQUIRED_TEXT` учитывает, что командный роутер работает +`private_only=True` (`transport.py:120-124`) — `/set_alias` нельзя вызвать прямо в +группе, где идёт мост: + +``` +Установи alias в личке с ботом: /set_alias <имя> — иначе сообщения из этого чата +не долетают до LoRa. +``` + +--- + +## 4. UX-фидбэк (`reactions.py`) + +Два новых метода на `ReactionFeedback`, по образцу существующего `report_disabled` +(реакция) и `_delete_after` из `commands/framework.py` (самоудаляющийся reply): + +```python +_ALIAS_REPLY_TTL_S = 5.0 # тот же интервал, что _GROUP_DELETE_DELAY в commands/framework.py + +async def report_alias_required(self, message: "TgMessage") -> None: + """Реакция 🪪 на сообщение без alias (best-effort).""" + try: + await self._bot.set_message_reaction( + message.chat.id, message.message_id, + reaction=[ReactionTypeEmoji(emoji="🪪")], + ) + except Exception: # noqa: BLE001 + pass + +async def send_expiring_reply( + self, message: "TgMessage", text: str, delay: float = _ALIAS_REPLY_TTL_S +) -> None: + """Reply, который сам удаляется через ``delay`` секунд. Исходное сообщение не трогаем.""" + try: + bot_msg = await message.reply(text) + except Exception: # noqa: BLE001 + return + await asyncio.sleep(delay) + with suppress(Exception): + await bot_msg.delete() +``` + +`send_expiring_reply` вызывается через `asyncio.create_task` в `on_message` — не блокирует +обработку следующих сообщений на время сна. + +Проверка выполняется на **каждое** проигнорированное сообщение (без счётчика/флага +«уже предупредили») — реплика самоудаляется, так что повторный reply не копится в чате. + +--- + +## 5. Что НЕ входит в этот дизайн + +- Изменения схемы БД — не требуются (`user_settings` не меняется). +- Исключения по ролям — нет ни одного (OWNER/ADMIN/MODERATOR наравне с USER). +- Поведение для non-text сообщений — не меняется (уже не бриджатся, `F.text`-фильтр). From 7b6282343e573acffb0a6f1f3d58054db676de9b Mon Sep 17 00:00:00 2001 From: Flexlug Date: Sat, 4 Jul 2026 15:30:12 +0300 Subject: [PATCH 2/9] =?UTF-8?q?docs(plan):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=E2=80=94=20=D0=BE=D0=B1=D1=8F=D0=B7=D0=B0=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B9=20alias=20=D0=B4=D0=BB=D1=8F=20=D0=B1?= =?UTF-8?q?=D1=80=D0=B8=D0=B4=D0=B6=D0=B8=D0=BD=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- .../plans/2026-07-04-require-alias.md | 538 ++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-04-require-alias.md diff --git a/docs/superpowers/plans/2026-07-04-require-alias.md b/docs/superpowers/plans/2026-07-04-require-alias.md new file mode 100644 index 0000000..bc9328f --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-require-alias.md @@ -0,0 +1,538 @@ +# Обязательный alias для бриджинга — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Сообщения Telegram-пользователей без alias перестают публиковаться в `Hub` (не долетают до LoRa), пока пользователь не поставит себе alias через `/set_alias`. + +**Architecture:** Новая проверка встраивается в `TelegramTransport.on_message`, сразу после существующей проверки `is_disabled` — тот же паттерн (best-effort реакция + `return` до `Hub.publish`). Управляется полем `require_alias: bool = True` в `TelegramCommandsConfig`; активно только когда включён блок `commands` (иначе `/set_alias` физически недоступен). UX-фидбэк — реакция 🪪 + самоудаляющийся reply, оба метода живут в `reactions.py` рядом с существующим `report_disabled`. + +**Tech Stack:** Python, pydantic v2, aiogram, pytest (anyio mode=auto), aiosqlite. + +Спека: `docs/superpowers/specs/2026-07-04-require-alias-design.md`. + +--- + +### Task 1: Конфиг — поле `require_alias` + +**Files:** +- Modify: `lora_bridge/config/schema/messengers.py:37-44` +- Test: `tests/test_config_schema.py` + +- [ ] **Step 1: Написать падающие тесты** + +Добавить в конец `tests/test_config_schema.py`: + +```python +def test_telegram_commands_require_alias_default_true() -> None: + cfg = TelegramCommandsConfig(owner_id=1) + assert cfg.require_alias is True + + +def test_telegram_commands_require_alias_can_disable() -> None: + cfg = TelegramCommandsConfig(owner_id=1, require_alias=False) + assert cfg.require_alias is False +``` + +- [ ] **Step 2: Убедиться что тесты падают** + +Run: `pytest tests/test_config_schema.py -k require_alias -v` +Expected: обе FAIL с `AttributeError: 'TelegramCommandsConfig' object has no attribute 'require_alias'` +(поле пока не объявлено в модели — pydantic по умолчанию молча игнорирует незнакомый kwarg, +`extra` не задан как `forbid` в этом файле). + +- [ ] **Step 3: Добавить поле в схему** + +В `lora_bridge/config/schema/messengers.py` заменить: + +```python +class TelegramCommandsConfig(BaseModel): + """Опциональный блок команд Telegram-бота. Отсутствие = команды выключены.""" + + owner_id: int = Field(description="Telegram user ID владельца бота (роль OWNER).") + alias_max_chars: int = Field( + default=16, + description="Максимальная длина псевдонима пользователя.", + ) +``` + +на: + +```python +class TelegramCommandsConfig(BaseModel): + """Опциональный блок команд Telegram-бота. Отсутствие = команды выключены.""" + + owner_id: int = Field(description="Telegram user ID владельца бота (роль OWNER).") + alias_max_chars: int = Field( + default=16, + description="Максимальная длина псевдонима пользователя.", + ) + require_alias: bool = Field( + default=True, + description=( + "Обязательность alias для бриджинга. Пока пользователь не поставит себе " + "alias через /set_alias, его сообщения не публикуются в LoRa (реакция 🪪 " + "+ самоудаляющееся напоминание). Действует для всех ролей без исключений." + ), + ) +``` + +- [ ] **Step 4: Убедиться что тесты проходят** + +Run: `pytest tests/test_config_schema.py -k require_alias -v` +Expected: PASS (2 passed) + +- [ ] **Step 5: Коммит** + +```bash +git add lora_bridge/config/schema/messengers.py tests/test_config_schema.py +git commit -m "feat(config): поле require_alias в TelegramCommandsConfig (дефолт true)" +``` + +--- + +### Task 2: UX-фидбэк в `reactions.py` + +**Files:** +- Modify: `lora_bridge/transports/telegram/reactions.py` +- Test: `tests/test_telegram_reactions.py` + +- [ ] **Step 1: Написать падающие тесты** + +В `tests/test_telegram_reactions.py` добавить импорт `SimpleNamespace` в блок импортов +(после `from unittest.mock import AsyncMock`): + +```python +from types import SimpleNamespace +``` + +И добавить в конец файла: + +```python +# --- ReactionFeedback: alias required --------------------------------------- + + +async def test_report_alias_required_sets_identity_card_emoji() -> None: + bot = _bot() + fb = ReactionFeedback(bot) + message = SimpleNamespace(chat=SimpleNamespace(id=111), message_id=5) + + await fb.report_alias_required(message) + + bot.set_message_reaction.assert_awaited_once() + _, kwargs = bot.set_message_reaction.call_args + assert kwargs["reaction"][0].emoji == "🪪" + + +async def test_send_expiring_reply_sends_then_deletes_after_delay() -> None: + bot = _bot() + fb = ReactionFeedback(bot) + sent = AsyncMock() + message = AsyncMock() + message.reply = AsyncMock(return_value=sent) + + await fb.send_expiring_reply(message, "текст", delay=0.03) + + message.reply.assert_awaited_once_with("текст") + sent.delete.assert_not_awaited() # ещё не истёк delay + + await asyncio.sleep(0.05) + sent.delete.assert_awaited_once() + + +async def test_send_expiring_reply_swallows_reply_failure() -> None: + bot = _bot() + fb = ReactionFeedback(bot) + message = AsyncMock() + message.reply = AsyncMock(side_effect=RuntimeError("boom")) + + await fb.send_expiring_reply(message, "текст", delay=0.01) # не должно бросать +``` + +- [ ] **Step 2: Убедиться что тесты падают** + +Run: `pytest tests/test_telegram_reactions.py -k "alias_required or expiring" -v` +Expected: все три FAIL с `AttributeError: 'ReactionFeedback' object has no attribute 'report_alias_required'` +(и аналогично для `send_expiring_reply`). + +- [ ] **Step 3: Реализовать методы** + +В `lora_bridge/transports/telegram/reactions.py` добавить `suppress` в импорты +(строка 12, было `from __future__ import annotations`, дальше идёт пустая строка и `import asyncio`): + +```python +from __future__ import annotations + +import asyncio +import logging +from contextlib import suppress +from typing import Optional +``` + +Добавить константу после `REACTION_DEBOUNCE_S = 2.0` (строка 42): + +```python +# Время жизни self-destruct реплики с напоминанием про alias (секунды). +# Совпадает с _GROUP_DELETE_DELAY в commands/framework.py — единый UX-интервал +# для служебных сообщений бота, которые не должны копиться в чате. +ALIAS_REPLY_TTL_S = 5.0 +``` + +Добавить два метода в класс `ReactionFeedback`, сразу после `report_disabled` +(после строки `pass`, перед `@staticmethod def _reaction_for`): + +```python + async def report_alias_required(self, message: "TgMessage") -> None: + """Реакция 🪪 на сообщение без alias, когда он обязателен (best-effort).""" + try: + await self._bot.set_message_reaction( + message.chat.id, + message.message_id, + reaction=[ReactionTypeEmoji(emoji="🪪")], + ) + except Exception: # noqa: BLE001 + pass + + async def send_expiring_reply( + self, message: "TgMessage", text: str, delay: float = ALIAS_REPLY_TTL_S + ) -> None: + """Reply, который сам удаляется через ``delay`` секунд. + + Сам reply отправляется синхронно (быстрый API-вызов); удаление — фоновой + задачей, чтобы не блокировать обработку следующих сообщений. Исходное + сообщение пользователя не трогаем — удаляется только ответ бота. + """ + try: + bot_msg = await message.reply(text) + except Exception: # noqa: BLE001 + return + asyncio.create_task(self._delete_reply_after(delay, bot_msg)) + + @staticmethod + async def _delete_reply_after(delay: float, reply: "TgMessage") -> None: + await asyncio.sleep(delay) + with suppress(Exception): + await reply.delete() +``` + +- [ ] **Step 4: Убедиться что тесты проходят** + +Run: `pytest tests/test_telegram_reactions.py -v` +Expected: все тесты в файле PASS (старые характеризационные + 3 новых). + +- [ ] **Step 5: Коммит** + +```bash +git add lora_bridge/transports/telegram/reactions.py tests/test_telegram_reactions.py +git commit -m "feat(telegram): report_alias_required + send_expiring_reply в ReactionFeedback" +``` + +--- + +### Task 3: Гейт в `on_message` + +**Files:** +- Modify: `lora_bridge/transports/telegram/transport.py` +- Modify: `tests/test_telegram_commands.py:34-43` (не регрессировать существующие тесты) +- Test: `tests/test_telegram_require_alias.py` (новый файл) + +- [ ] **Step 1: Защитить существующие тесты от регрессии** + +Существующие тесты в `tests/test_telegram_commands.py` шлют текст от `user_id=2` без +alias — после Task 3 они попадут под новый гейт, если не отключить его явно. В +`_make_transport_with_commands` (строки 34-43) заменить: + +```python +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 +``` + +на: + +```python +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, require_alias=False, + ), + ) + transport = TelegramTransport("tg", config, _store=store) # type: ignore[arg-type] + transport._bot.session = AsyncMock() + transport._hub.publish = AsyncMock() + return transport +``` + +Эти тесты проверяют роутинг команд, а не alias-гейт — `require_alias=False` изолирует +концерны, как и было до этой фичи. + +- [ ] **Step 2: Запустить существующий файл, убедиться что всё ещё зелёный** + +Run: `pytest tests/test_telegram_commands.py -v` +Expected: PASS (все тесты, как и до правки — фикстура просто явно фиксирует прежнее поведение). + +- [ ] **Step 3: Написать падающие тесты для нового поведения** + +Создать `tests/test_telegram_require_alias.py`: + +```python +"""require_alias: сообщения без alias не публикуются в Hub (design от 2026-07-04).""" + +from __future__ import annotations + +import datetime as dt +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from aiogram.types import Chat, Message, Update, User + +from lora_bridge.transports.telegram.moderation.store import ModerationStore +from lora_bridge.transports.telegram.transport import ALIAS_REQUIRED_TEXT, TelegramTransport + +_FAKE_TOKEN = "123456:AAFakeFakeFakeFakeFakeFakeFakeFakeFak" +_OWNER_ID = 1 +_GROUP_CHAT_ID = -100123 +_USER_ID = 2 + + +async def _make_transport(*, require_alias: bool = True) -> TelegramTransport: + store = ModerationStore(":memory:") + await store.start() + config = SimpleNamespace( + token=_FAKE_TOKEN, + commands=SimpleNamespace( + owner_id=_OWNER_ID, alias_max_chars=16, require_alias=require_alias, + ), + ) + transport = TelegramTransport("tg", config, _store=store) # type: ignore[arg-type] + transport._bot.session = AsyncMock() + transport._hub.publish = AsyncMock() + return transport + + +def _group_update(text: str, user_id: int = _USER_ID) -> Update: + return Update( + update_id=1, + message=Message( + message_id=10, + date=dt.datetime(2024, 1, 1), + chat=Chat(id=_GROUP_CHAT_ID, type="supergroup"), + from_user=User(id=user_id, is_bot=False, first_name="tester"), + text=text, + ), + ) + + +async def _feed(transport: TelegramTransport, text: str, user_id: int = _USER_ID) -> None: + await transport._dp.feed_update(transport._bot, _group_update(text, user_id)) + + +async def test_message_without_alias_is_not_published() -> None: + transport = await _make_transport(require_alias=True) + await _feed(transport, "привет из моста") + transport._hub.publish.assert_not_called() + + +async def test_message_without_alias_gets_identity_card_reaction() -> None: + transport = await _make_transport(require_alias=True) + await _feed(transport, "привет из моста") + transport._bot.set_message_reaction.assert_awaited_once() + _, kwargs = transport._bot.set_message_reaction.call_args + assert kwargs["reaction"][0].emoji == "🪪" + + +async def test_message_without_alias_gets_expiring_reminder() -> None: + transport = await _make_transport(require_alias=True) + await _feed(transport, "привет из моста") + transport._bot.session.assert_awaited() + sent = transport._bot.session.await_args.args[1] + assert ALIAS_REQUIRED_TEXT in sent.text + + +async def test_message_with_alias_is_published() -> None: + transport = await _make_transport(require_alias=True) + await transport._store.set_alias(_USER_ID, "Вася") + await _feed(transport, "привет из моста") + transport._hub.publish.assert_awaited_once() + + +async def test_require_alias_disabled_publishes_without_alias() -> None: + transport = await _make_transport(require_alias=False) + await _feed(transport, "привет из моста") + transport._hub.publish.assert_awaited_once() + + +async def test_disabled_user_takes_priority_over_missing_alias() -> None: + transport = await _make_transport(require_alias=True) + await transport._store.ban_user(_USER_ID, "Vasya") + await _feed(transport, "привет из моста") + transport._hub.publish.assert_not_called() + _, kwargs = transport._bot.set_message_reaction.call_args + assert kwargs["reaction"][0].emoji == "🚫" # бан приоритетнее alias-гейта, не 🪪 +``` + +- [ ] **Step 4: Убедиться что новые тесты падают** + +Run: `pytest tests/test_telegram_require_alias.py -v` +Expected: `test_message_without_alias_is_not_published` и `test_message_without_alias_gets_identity_card_reaction` +и `test_message_without_alias_gets_expiring_reminder` — FAIL (`hub.publish` вызывается, реакции/reply нет). +`test_message_with_alias_is_published`, `test_require_alias_disabled_publishes_without_alias`, +`test_disabled_user_takes_priority_over_missing_alias` — уже PASS (текущее поведение их не меняет), +это нормально для этого шага — они закрепляют то, что НЕ должно сломаться. + +- [ ] **Step 5: Реализовать гейт** + +В `lora_bridge/transports/telegram/transport.py` добавить константу после `log = logging.getLogger(__name__)` +(строка 50): + +```python +log = logging.getLogger(__name__) + +ALIAS_REQUIRED_TEXT = ( + "Установи alias в личке с ботом: /set_alias <имя> — иначе сообщения из " + "этого чата не долетают до LoRa." +) +``` + +В `__init__` (строки 86-94) заменить: + +```python + self._store: Optional[ModerationStore] = None + self._owner_id: int = 0 + # (tg_id, chat_id) — уже обновлённые scope; избегаем лишних API-вызовов + self._cmd_scope_done: set[tuple[int, int]] = set() + + if config.commands is not None: + owner_id = config.commands.owner_id + self._owner_id = owner_id +``` + +на: + +```python + self._store: Optional[ModerationStore] = None + self._owner_id: int = 0 + self._require_alias: bool = False + # (tg_id, chat_id) — уже обновлённые scope; избегаем лишних API-вызовов + self._cmd_scope_done: set[tuple[int, int]] = set() + + if config.commands is not None: + owner_id = config.commands.owner_id + self._owner_id = owner_id + self._require_alias = getattr(config.commands, "require_alias", True) +``` + +(`getattr` с дефолтом — по образцу `moderation.py:108`, устойчиво к `SimpleNamespace` +в тестах без явного `require_alias`.) + +В `on_message` (строки 188-197) заменить: + +```python + async def on_message(self, message: TgMessage) -> None: + 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)) +``` + +на: + +```python + async def on_message(self, message: TgMessage) -> None: + 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) + if self._require_alias and not settings.alias: + await self._reactions.report_alias_required(message) + await self._reactions.send_expiring_reply(message, ALIAS_REQUIRED_TEXT) + return + else: + settings = None + await self._hub.publish(self.normalize(message, settings)) +``` + +- [ ] **Step 6: Убедиться что все тесты проходят** + +Run: `pytest tests/test_telegram_require_alias.py tests/test_telegram_commands.py -v` +Expected: PASS (всё, включая ранее уже проходившие тесты из Step 4). + +- [ ] **Step 7: Коммит** + +```bash +git add lora_bridge/transports/telegram/transport.py \ + tests/test_telegram_commands.py tests/test_telegram_require_alias.py +git commit -m "feat(telegram): гейтовать бриджинг по наличию alias (require_alias)" +``` + +--- + +### Task 4: Документировать в `config.example.yaml` + +**Files:** +- Modify: `config.example.yaml:44-47` + +- [ ] **Step 1: Добавить комментарий про новое поле** + +Заменить блок: + +```yaml + commands: # опционально; без блока — командный роутер не включается + owner_id: 123456789 # Telegram user ID владельца (роль OWNER) + alias_max_chars: 8 # максимальная длина псевдонима (по умолчанию 16) +``` + +на: + +```yaml + commands: # опционально; без блока — командный роутер не включается + owner_id: 123456789 # Telegram user ID владельца (роль OWNER) + alias_max_chars: 8 # максимальная длина псевдонима (по умолчанию 16) + # require_alias: false # без alias сообщения не бриджатся (по умолчанию true, + # действует для всех ролей без исключений — см. /set_alias) +``` + +Ничего не тестируется — файл документационный (не парсится тестами), поэтому шага +verify/run для этого таска нет. + +- [ ] **Step 2: Коммит** + +```bash +git add config.example.yaml +git commit -m "docs(config): задокументировать require_alias в примере конфига" +``` + +--- + +### Task 5: Финальная проверка + +- [ ] **Step 1: Полный прогон тестов** + +Run: `pytest -q` +Expected: все тесты зелёные, 0 failed. + +- [ ] **Step 2: Линт и типы** + +Run: `ruff check` +Expected: `All checks passed!` + +Run: `mypy lora_bridge` +Expected: `Success: no issues found` + +Если что-то падает — почини на месте и закоммить отдельным коммитом с +понятным сообщением (не смешивать с коммитами предыдущих задач). From 6a5146591dc534f2f71efd6237f1983dc170af0c Mon Sep 17 00:00:00 2001 From: Flexlug Date: Sat, 4 Jul 2026 15:33:42 +0300 Subject: [PATCH 3/9] =?UTF-8?q?feat(config):=20=D0=BF=D0=BE=D0=BB=D0=B5=20?= =?UTF-8?q?require=5Falias=20=D0=B2=20TelegramCommandsConfig=20(=D0=B4?= =?UTF-8?q?=D0=B5=D1=84=D0=BE=D0=BB=D1=82=20true)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- lora_bridge/config/schema/messengers.py | 8 ++++++++ tests/test_config_schema.py | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/lora_bridge/config/schema/messengers.py b/lora_bridge/config/schema/messengers.py index fcb7698..566a662 100644 --- a/lora_bridge/config/schema/messengers.py +++ b/lora_bridge/config/schema/messengers.py @@ -42,6 +42,14 @@ class TelegramCommandsConfig(BaseModel): default=16, description="Максимальная длина псевдонима пользователя.", ) + require_alias: bool = Field( + default=True, + description=( + "Обязательность alias для бриджинга. Пока пользователь не поставит себе " + "alias через /set_alias, его сообщения не публикуются в LoRa (реакция 🪪 " + "+ самоудаляющееся напоминание). Действует для всех ролей без исключений." + ), + ) class TelegramMessengerConfig(BaseMessengerConfig): diff --git a/tests/test_config_schema.py b/tests/test_config_schema.py index e10192a..6915825 100644 --- a/tests/test_config_schema.py +++ b/tests/test_config_schema.py @@ -267,3 +267,13 @@ def test_telegram_commands_alias_max_chars_custom() -> None: ) assert cfg.commands is not None assert cfg.commands.alias_max_chars == 8 + + +def test_telegram_commands_require_alias_default_true() -> None: + cfg = TelegramCommandsConfig(owner_id=1) + assert cfg.require_alias is True + + +def test_telegram_commands_require_alias_can_disable() -> None: + cfg = TelegramCommandsConfig(owner_id=1, require_alias=False) + assert cfg.require_alias is False From 1bd579bb4f83372a947ee93ca6990f211d25f3bb Mon Sep 17 00:00:00 2001 From: Flexlug Date: Sat, 4 Jul 2026 15:34:48 +0300 Subject: [PATCH 4/9] =?UTF-8?q?feat(telegram):=20report=5Falias=5Frequired?= =?UTF-8?q?=20+=20send=5Fexpiring=5Freply=20=D0=B2=20ReactionFeedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- lora_bridge/transports/telegram/reactions.py | 38 ++++++++++++++++++ tests/test_telegram_reactions.py | 41 ++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/lora_bridge/transports/telegram/reactions.py b/lora_bridge/transports/telegram/reactions.py index 9c7267b..1126fe6 100644 --- a/lora_bridge/transports/telegram/reactions.py +++ b/lora_bridge/transports/telegram/reactions.py @@ -13,6 +13,7 @@ import asyncio import logging +from contextlib import suppress from typing import Optional from aiogram import Bot @@ -41,6 +42,11 @@ # 2.0с даёт запас чтобы 👀 не мелькал при нормальной работе. REACTION_DEBOUNCE_S = 2.0 +# Время жизни self-destruct реплики с напоминанием про alias (секунды). +# Совпадает с _GROUP_DELETE_DELAY в commands/framework.py — единый UX-интервал +# для служебных сообщений бота, которые не должны копиться в чате. +ALIAS_REPLY_TTL_S = 5.0 + class ReactionDebouncer: """Откладывает простановку реакции; SENT немедленно очищает. @@ -168,6 +174,38 @@ async def report_disabled(self, message: "TgMessage") -> None: except Exception: # noqa: BLE001 pass + async def report_alias_required(self, message: "TgMessage") -> None: + """Реакция 🪪 на сообщение без alias, когда он обязателен (best-effort).""" + try: + await self._bot.set_message_reaction( + message.chat.id, + message.message_id, + reaction=[ReactionTypeEmoji(emoji="🪪")], + ) + except Exception: # noqa: BLE001 + pass + + async def send_expiring_reply( + self, message: "TgMessage", text: str, delay: float = ALIAS_REPLY_TTL_S + ) -> None: + """Reply, который сам удаляется через ``delay`` секунд. + + Сам reply отправляется синхронно (быстрый API-вызов); удаление — фоновой + задачей, чтобы не блокировать обработку следующих сообщений. Исходное + сообщение пользователя не трогаем — удаляется только ответ бота. + """ + try: + bot_msg = await message.reply(text) + except Exception: # noqa: BLE001 + return + asyncio.create_task(self._delete_reply_after(delay, bot_msg)) + + @staticmethod + async def _delete_reply_after(delay: float, reply: "TgMessage") -> None: + await asyncio.sleep(delay) + with suppress(Exception): + await reply.delete() + @staticmethod def _reaction_for( status: DeliveryStatus, reason: Optional[RejectReason] diff --git a/tests/test_telegram_reactions.py b/tests/test_telegram_reactions.py index b4f1298..9761a03 100644 --- a/tests/test_telegram_reactions.py +++ b/tests/test_telegram_reactions.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio +from types import SimpleNamespace from unittest.mock import AsyncMock from aiogram.types import ReactionTypeEmoji @@ -141,3 +142,43 @@ async def test_sent_clears_applied_reaction() -> None: bot.set_message_reaction.assert_awaited_once() _, kwargs = bot.set_message_reaction.call_args assert kwargs["reaction"] == [] + + +# --- ReactionFeedback: alias required --------------------------------------- + + +async def test_report_alias_required_sets_identity_card_emoji() -> None: + bot = _bot() + fb = ReactionFeedback(bot) + message = SimpleNamespace(chat=SimpleNamespace(id=111), message_id=5) + + await fb.report_alias_required(message) + + bot.set_message_reaction.assert_awaited_once() + _, kwargs = bot.set_message_reaction.call_args + assert kwargs["reaction"][0].emoji == "🪪" + + +async def test_send_expiring_reply_sends_then_deletes_after_delay() -> None: + bot = _bot() + fb = ReactionFeedback(bot) + sent = AsyncMock() + message = AsyncMock() + message.reply = AsyncMock(return_value=sent) + + await fb.send_expiring_reply(message, "текст", delay=0.03) + + message.reply.assert_awaited_once_with("текст") + sent.delete.assert_not_awaited() # ещё не истёк delay + + await asyncio.sleep(0.05) + sent.delete.assert_awaited_once() + + +async def test_send_expiring_reply_swallows_reply_failure() -> None: + bot = _bot() + fb = ReactionFeedback(bot) + message = AsyncMock() + message.reply = AsyncMock(side_effect=RuntimeError("boom")) + + await fb.send_expiring_reply(message, "текст", delay=0.01) # не должно бросать From 1cbfd4baeb1fde26a3003dccc6254f1a52239a1e Mon Sep 17 00:00:00 2001 From: Flexlug Date: Sat, 4 Jul 2026 15:42:11 +0300 Subject: [PATCH 5/9] =?UTF-8?q?feat(telegram):=20=D0=B3=D0=B5=D0=B9=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B1=D1=80=D0=B8=D0=B4=D0=B6?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20=D0=BF=D0=BE=20=D0=BD=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D1=87=D0=B8=D1=8E=20alias=20(require=5Falias)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- lora_bridge/transports/telegram/transport.py | 11 +++ tests/test_telegram_commands.py | 4 +- tests/test_telegram_require_alias.py | 94 ++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tests/test_telegram_require_alias.py diff --git a/lora_bridge/transports/telegram/transport.py b/lora_bridge/transports/telegram/transport.py index fde4432..d3a464a 100644 --- a/lora_bridge/transports/telegram/transport.py +++ b/lora_bridge/transports/telegram/transport.py @@ -49,6 +49,11 @@ log = logging.getLogger(__name__) +ALIAS_REQUIRED_TEXT = ( + "Установи alias в личке с ботом: /set_alias <имя> — иначе сообщения из " + "этого чата не долетают до LoRa." +) + def split_channel(channel: str) -> tuple[int, Optional[int]]: """``"chat"`` / ``"chat#topic"`` → ``(chat_id, thread_id|None)``. Инверсия ``messenger_channel``. @@ -86,12 +91,14 @@ def __init__( self._dp = Dispatcher() self._store: Optional[ModerationStore] = None self._owner_id: int = 0 + self._require_alias: bool = False # (tg_id, chat_id) — уже обновлённые scope; избегаем лишних API-вызовов self._cmd_scope_done: set[tuple[int, int]] = set() if config.commands is not None: owner_id = config.commands.owner_id self._owner_id = owner_id + self._require_alias = getattr(config.commands, "require_alias", True) if _store is not None: self._store = _store else: @@ -192,6 +199,10 @@ async def on_message(self, message: TgMessage) -> None: await self._reactions.report_disabled(message) return settings: Optional[UserSettings] = await self._store.get_user_settings(user_id) + if self._require_alias and not settings.alias: + await self._reactions.report_alias_required(message) + await self._reactions.send_expiring_reply(message, ALIAS_REQUIRED_TEXT) + return else: settings = None await self._hub.publish(self.normalize(message, settings)) diff --git a/tests/test_telegram_commands.py b/tests/test_telegram_commands.py index 2c12344..91f4f73 100644 --- a/tests/test_telegram_commands.py +++ b/tests/test_telegram_commands.py @@ -35,7 +35,9 @@ 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), + commands=SimpleNamespace( + owner_id=_OWNER_ID, alias_max_chars=16, require_alias=False, + ), ) transport = TelegramTransport("tg", config, _store=store) # type: ignore[arg-type] transport._bot.session = AsyncMock() diff --git a/tests/test_telegram_require_alias.py b/tests/test_telegram_require_alias.py new file mode 100644 index 0000000..3c14974 --- /dev/null +++ b/tests/test_telegram_require_alias.py @@ -0,0 +1,94 @@ +"""require_alias: сообщения без alias не публикуются в Hub (design от 2026-07-04).""" + +from __future__ import annotations + +import datetime as dt +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from aiogram.types import Chat, Message, Update, User + +from lora_bridge.transports.telegram.moderation.store import ModerationStore +from lora_bridge.transports.telegram.transport import ALIAS_REQUIRED_TEXT, TelegramTransport + +_FAKE_TOKEN = "123456:AAFakeFakeFakeFakeFakeFakeFakeFakeFak" +_OWNER_ID = 1 +_GROUP_CHAT_ID = -100123 +_USER_ID = 2 + + +async def _make_transport(*, require_alias: bool = True) -> TelegramTransport: + store = ModerationStore(":memory:") + await store.start() + config = SimpleNamespace( + token=_FAKE_TOKEN, + commands=SimpleNamespace( + owner_id=_OWNER_ID, alias_max_chars=16, require_alias=require_alias, + ), + ) + transport = TelegramTransport("tg", config, _store=store) # type: ignore[arg-type] + transport._bot.session = AsyncMock() + transport._bot.set_message_reaction = AsyncMock() + transport._hub.publish = AsyncMock() + return transport + + +def _group_update(text: str, user_id: int = _USER_ID) -> Update: + return Update( + update_id=1, + message=Message( + message_id=10, + date=dt.datetime(2024, 1, 1), + chat=Chat(id=_GROUP_CHAT_ID, type="supergroup"), + from_user=User(id=user_id, is_bot=False, first_name="tester"), + text=text, + ), + ) + + +async def _feed(transport: TelegramTransport, text: str, user_id: int = _USER_ID) -> None: + await transport._dp.feed_update(transport._bot, _group_update(text, user_id)) + + +async def test_message_without_alias_is_not_published() -> None: + transport = await _make_transport(require_alias=True) + await _feed(transport, "привет из моста") + transport._hub.publish.assert_not_called() + + +async def test_message_without_alias_gets_identity_card_reaction() -> None: + transport = await _make_transport(require_alias=True) + await _feed(transport, "привет из моста") + transport._bot.set_message_reaction.assert_awaited_once() + _, kwargs = transport._bot.set_message_reaction.call_args + assert kwargs["reaction"][0].emoji == "🪪" + + +async def test_message_without_alias_gets_expiring_reminder() -> None: + transport = await _make_transport(require_alias=True) + await _feed(transport, "привет из моста") + transport._bot.session.assert_awaited() + sent = transport._bot.session.await_args.args[1] + assert ALIAS_REQUIRED_TEXT in sent.text + + +async def test_message_with_alias_is_published() -> None: + transport = await _make_transport(require_alias=True) + await transport._store.set_alias(_USER_ID, "Вася") + await _feed(transport, "привет из моста") + transport._hub.publish.assert_awaited_once() + + +async def test_require_alias_disabled_publishes_without_alias() -> None: + transport = await _make_transport(require_alias=False) + await _feed(transport, "привет из моста") + transport._hub.publish.assert_awaited_once() + + +async def test_disabled_user_takes_priority_over_missing_alias() -> None: + transport = await _make_transport(require_alias=True) + await transport._store.ban_user(_USER_ID, "Vasya") + await _feed(transport, "привет из моста") + transport._hub.publish.assert_not_called() + _, kwargs = transport._bot.set_message_reaction.call_args + assert kwargs["reaction"][0].emoji == "🚫" # бан приоритетнее alias-гейта, не 🪪 From bf7243a6de59defe0162deff5f7cfaeb1a25f113 Mon Sep 17 00:00:00 2001 From: Flexlug Date: Sat, 4 Jul 2026 15:42:28 +0300 Subject: [PATCH 6/9] =?UTF-8?q?docs(config):=20=D0=B7=D0=B0=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20require=5Falias=20=D0=B2=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D1=80=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8?= =?UTF-8?q?=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- config.example.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config.example.yaml b/config.example.yaml index 33875f4..470a7b3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -45,6 +45,8 @@ messengers: commands: # опционально; без блока — командный роутер не включается owner_id: 123456789 # Telegram user ID владельца (роль OWNER) alias_max_chars: 8 # максимальная длина псевдонима (по умолчанию 16) + # require_alias: false # без alias сообщения не бриджатся (по умолчанию true, + # действует для всех ролей без исключений — см. /set_alias) rooms: # topic указан → берём/шлём ТОЛЬКО эту тему; topic опущен → работаем с General (и только с ним) From 4ebc8e883ea732bb7cf56a218bb09be0551b2e5c Mon Sep 17 00:00:00 2001 From: Flexlug Date: Sat, 4 Jul 2026 15:44:31 +0300 Subject: [PATCH 7/9] =?UTF-8?q?fix(telegram):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D1=8F=D0=B2=D0=BD=D1=83=D1=8E=20Optional-=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D1=82=D0=B0=D1=86=D0=B8=D1=8E=20settings=20?= =?UTF-8?q?=E2=80=94=20=D0=BC=D0=B5=D1=88=D0=B0=D0=BB=D0=B0=20mypy-=D0=BD?= =?UTF-8?q?=D0=B0=D1=80=D1=80=D0=BE=D0=B2=D0=B8=D0=BD=D0=B3=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mypy не сужает объявленный Optional[UserSettings] после присваивания не-Optional значения (в отличие от pyright); без аннотации сужение работает по ветке if/else как положено. Co-Authored-By: Claude Sonnet 5 --- lora_bridge/transports/telegram/transport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lora_bridge/transports/telegram/transport.py b/lora_bridge/transports/telegram/transport.py index d3a464a..4415a59 100644 --- a/lora_bridge/transports/telegram/transport.py +++ b/lora_bridge/transports/telegram/transport.py @@ -198,7 +198,7 @@ async def on_message(self, message: TgMessage) -> 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) + settings = await self._store.get_user_settings(user_id) if self._require_alias and not settings.alias: await self._reactions.report_alias_required(message) await self._reactions.send_expiring_reply(message, ALIAS_REQUIRED_TEXT) From 7432f5df2f9f2f36a818d0eafc075cf1ac141512 Mon Sep 17 00:00:00 2001 From: Flexlug Date: Sat, 4 Jul 2026 16:17:50 +0300 Subject: [PATCH 8/9] =?UTF-8?q?refactor(telegram):=20=D0=B2=D1=8B=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=20delete=5Fafter=20=D0=B2=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B8=D0=B9=20ephemeral.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _delete_after в commands/framework.py и новый _delete_reply_after в reactions.py делали одно и то же — единый примитив для самоудаляющихся служебных сообщений бота. Co-Authored-By: Claude Sonnet 5 --- .../transports/telegram/commands/framework.py | 12 +++-------- lora_bridge/transports/telegram/ephemeral.py | 15 ++++++++++++++ lora_bridge/transports/telegram/reactions.py | 20 +++---------------- 3 files changed, 21 insertions(+), 26 deletions(-) create mode 100644 lora_bridge/transports/telegram/ephemeral.py diff --git a/lora_bridge/transports/telegram/commands/framework.py b/lora_bridge/transports/telegram/commands/framework.py index 2bcd3a2..3fc2530 100644 --- a/lora_bridge/transports/telegram/commands/framework.py +++ b/lora_bridge/transports/telegram/commands/framework.py @@ -24,7 +24,6 @@ import logging import re from collections.abc import Awaitable, Callable -from contextlib import suppress from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -34,6 +33,8 @@ from aiogram.types import CallbackQuery from aiogram.types import Message as TgMessage +from ..ephemeral import delete_after + if TYPE_CHECKING: from ..moderation.roles import Role from ..moderation.store import ModerationStore @@ -49,13 +50,6 @@ _GROUP_DELETE_DELAY = 5.0 - -async def _delete_after(delay: float, *messages: TgMessage) -> None: - await asyncio.sleep(delay) - for msg in messages: - with suppress(Exception): - await msg.delete() - CommandHandler = Callable[[TgMessage], Awaitable[None]] CallbackHandler = Callable[[CallbackQuery], Awaitable[None]] @@ -124,7 +118,7 @@ def build_command_router( async def _group_redirect(message: TgMessage) -> None: bot_msg = await message.reply(PRIVATE_ONLY_REPLY) asyncio.create_task( - _delete_after(_GROUP_DELETE_DELAY, bot_msg, message) + delete_after(_GROUP_DELETE_DELAY, bot_msg, message) ) for spec in commands: diff --git a/lora_bridge/transports/telegram/ephemeral.py b/lora_bridge/transports/telegram/ephemeral.py new file mode 100644 index 0000000..f240d06 --- /dev/null +++ b/lora_bridge/transports/telegram/ephemeral.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import asyncio +from contextlib import suppress +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from aiogram.types import Message as TgMessage + + +async def delete_after(delay: float, *messages: "TgMessage") -> None: + await asyncio.sleep(delay) + for msg in messages: + with suppress(Exception): + await msg.delete() diff --git a/lora_bridge/transports/telegram/reactions.py b/lora_bridge/transports/telegram/reactions.py index 1126fe6..de465a2 100644 --- a/lora_bridge/transports/telegram/reactions.py +++ b/lora_bridge/transports/telegram/reactions.py @@ -13,12 +13,12 @@ import asyncio import logging -from contextlib import suppress from typing import Optional from aiogram import Bot from aiogram.types import Message as TgMessage, ReactionTypeEmoji, ReactionTypeUnion +from .ephemeral import delete_after from ...domain.models import DeliveryStatus, RejectReason log = logging.getLogger(__name__) @@ -42,9 +42,6 @@ # 2.0с даёт запас чтобы 👀 не мелькал при нормальной работе. REACTION_DEBOUNCE_S = 2.0 -# Время жизни self-destruct реплики с напоминанием про alias (секунды). -# Совпадает с _GROUP_DELETE_DELAY в commands/framework.py — единый UX-интервал -# для служебных сообщений бота, которые не должны копиться в чате. ALIAS_REPLY_TTL_S = 5.0 @@ -188,23 +185,12 @@ async def report_alias_required(self, message: "TgMessage") -> None: async def send_expiring_reply( self, message: "TgMessage", text: str, delay: float = ALIAS_REPLY_TTL_S ) -> None: - """Reply, который сам удаляется через ``delay`` секунд. - - Сам reply отправляется синхронно (быстрый API-вызов); удаление — фоновой - задачей, чтобы не блокировать обработку следующих сообщений. Исходное - сообщение пользователя не трогаем — удаляется только ответ бота. - """ + """Reply, который сам удаляется через ``delay`` секунд. Исходное сообщение не трогаем.""" try: bot_msg = await message.reply(text) except Exception: # noqa: BLE001 return - asyncio.create_task(self._delete_reply_after(delay, bot_msg)) - - @staticmethod - async def _delete_reply_after(delay: float, reply: "TgMessage") -> None: - await asyncio.sleep(delay) - with suppress(Exception): - await reply.delete() + asyncio.create_task(delete_after(delay, bot_msg)) @staticmethod def _reaction_for( From a4710aea5c84f614617dd20eafb52e641e4d8ba4 Mon Sep 17 00:00:00 2001 From: Flexlug Date: Sat, 4 Jul 2026 16:28:03 +0300 Subject: [PATCH 9/9] =?UTF-8?q?feat(config):=20=D0=B4=D0=B5=D1=84=D0=BE?= =?UTF-8?q?=D0=BB=D1=82=20alias=5Fmax=5Fchars=2016=20=E2=86=92=208?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- config.example.yaml | 2 +- lora_bridge/config/schema/messengers.py | 2 +- lora_bridge/transports/telegram/commands/moderation.py | 2 +- tests/test_config_schema.py | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 470a7b3..ded8b58 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -44,7 +44,7 @@ messengers: # privacy mode у бота ДОЛЖЕН быть отключён (BotFather /setprivacy → Disable) commands: # опционально; без блока — командный роутер не включается owner_id: 123456789 # Telegram user ID владельца (роль OWNER) - alias_max_chars: 8 # максимальная длина псевдонима (по умолчанию 16) + alias_max_chars: 8 # максимальная длина псевдонима (по умолчанию 8) # require_alias: false # без alias сообщения не бриджатся (по умолчанию true, # действует для всех ролей без исключений — см. /set_alias) diff --git a/lora_bridge/config/schema/messengers.py b/lora_bridge/config/schema/messengers.py index 566a662..8c3c11d 100644 --- a/lora_bridge/config/schema/messengers.py +++ b/lora_bridge/config/schema/messengers.py @@ -39,7 +39,7 @@ class TelegramCommandsConfig(BaseModel): owner_id: int = Field(description="Telegram user ID владельца бота (роль OWNER).") alias_max_chars: int = Field( - default=16, + default=8, description="Максимальная длина псевдонима пользователя.", ) require_alias: bool = Field( diff --git a/lora_bridge/transports/telegram/commands/moderation.py b/lora_bridge/transports/telegram/commands/moderation.py index 4bc1f77..08fb669 100644 --- a/lora_bridge/transports/telegram/commands/moderation.py +++ b/lora_bridge/transports/telegram/commands/moderation.py @@ -105,7 +105,7 @@ def make_moderation_commands( ) -> list[CommandSpec]: """Фабрика команд модерации с замыканием над store и cfg.""" owner_id: int = getattr(cfg, "owner_id", 0) - alias_max: int = getattr(cfg, "alias_max_chars", 16) + alias_max: int = getattr(cfg, "alias_max_chars", 8) async def ban(message: TgMessage) -> None: target = await resolve_target(message) diff --git a/tests/test_config_schema.py b/tests/test_config_schema.py index 6915825..ac53217 100644 --- a/tests/test_config_schema.py +++ b/tests/test_config_schema.py @@ -257,16 +257,16 @@ def test_telegram_commands_with_block() -> None: ) assert cfg.commands is not None assert cfg.commands.owner_id == 123 - assert cfg.commands.alias_max_chars == 16 + assert cfg.commands.alias_max_chars == 8 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), + commands=TelegramCommandsConfig(owner_id=1, alias_max_chars=20), ) assert cfg.commands is not None - assert cfg.commands.alias_max_chars == 8 + assert cfg.commands.alias_max_chars == 20 def test_telegram_commands_require_alias_default_true() -> None: