Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ 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)

rooms:
# topic указан → берём/шлём ТОЛЬКО эту тему; topic опущен → работаем с General (и только с ним)
Expand Down
538 changes: 538 additions & 0 deletions docs/superpowers/plans/2026-07-04-require-alias.md

Large diffs are not rendered by default.

118 changes: 118 additions & 0 deletions docs/superpowers/specs/2026-07-04-require-alias-design.md
Original file line number Diff line number Diff line change
@@ -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`-фильтр).
10 changes: 9 additions & 1 deletion lora_bridge/config/schema/messengers.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,17 @@ 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(
default=True,
description=(
"Обязательность alias для бриджинга. Пока пользователь не поставит себе "
"alias через /set_alias, его сообщения не публикуются в LoRa (реакция 🪪 "
"+ самоудаляющееся напоминание). Действует для всех ролей без исключений."
),
)


class TelegramMessengerConfig(BaseMessengerConfig):
Expand Down
12 changes: 3 additions & 9 deletions lora_bridge/transports/telegram/commands/framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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]]

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion lora_bridge/transports/telegram/commands/moderation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions lora_bridge/transports/telegram/ephemeral.py
Original file line number Diff line number Diff line change
@@ -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()
24 changes: 24 additions & 0 deletions lora_bridge/transports/telegram/reactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
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__)
Expand All @@ -41,6 +42,8 @@
# 2.0с даёт запас чтобы 👀 не мелькал при нормальной работе.
REACTION_DEBOUNCE_S = 2.0

ALIAS_REPLY_TTL_S = 5.0


class ReactionDebouncer:
"""Откладывает простановку реакции; SENT немедленно очищает.
Expand Down Expand Up @@ -168,6 +171,27 @@ 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`` секунд. Исходное сообщение не трогаем."""
try:
bot_msg = await message.reply(text)
except Exception: # noqa: BLE001
return
asyncio.create_task(delete_after(delay, bot_msg))

@staticmethod
def _reaction_for(
status: DeliveryStatus, reason: Optional[RejectReason]
Expand Down
13 changes: 12 additions & 1 deletion lora_bridge/transports/telegram/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -191,7 +198,11 @@ 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)
return
else:
settings = None
await self._hub.publish(self.normalize(message, settings))
Expand Down
16 changes: 13 additions & 3 deletions tests/test_config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,13 +257,23 @@ 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:
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
4 changes: 3 additions & 1 deletion tests/test_telegram_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
41 changes: 41 additions & 0 deletions tests/test_telegram_reactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from __future__ import annotations

import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock

from aiogram.types import ReactionTypeEmoji
Expand Down Expand Up @@ -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) # не должно бросать
Loading
Loading