feat(bot): Telegram channel over the dialog engine
CI / ruff · pytest (pull_request) Successful in 39s
CI / ruff · pytest (pull_request) Successful in 39s
Deep-link /start <code> binds the chat via IdentityProvider, text and button presses go to get_engine().handle(), replies render as inline keyboards and are sent verbatim (no parse_mode). TelegramOutbox delivers scheduler pushes to bound chats and falls back to the previous outbox. No-op without TG_BOT_TOKEN; logs carry no text or chat ids. Refs #11 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfbiLXFJswz2EK3qtWF7S7
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
"""Telegram channel (#11): deep-link binding, routing to the engine, rendering, outbox — no network, SQLite DBs."""
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator, AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.session.base import BaseSession
|
||||
from aiogram.methods import SendMessage, TelegramMethod
|
||||
from aiogram.types import CallbackQuery, Chat, Update, User
|
||||
from aiogram.types import Message as TgMessage
|
||||
|
||||
from app.channels import telegram
|
||||
from app.channels.telegram import (
|
||||
CALLBACK_DATA_MAX,
|
||||
ChatDirectory,
|
||||
TelegramChannel,
|
||||
TelegramOutbox,
|
||||
load_texts,
|
||||
render_markup,
|
||||
split_text,
|
||||
)
|
||||
from app.db import identity_repo, session
|
||||
from app.db.bootstrap import init_db
|
||||
from app.dialog.contracts import Button, Event, Reply
|
||||
from app.integrations.base import MemoryOutbox, VerifiedPerson, integrations
|
||||
|
||||
T0 = datetime(2026, 10, 3, 9, 0, tzinfo=UTC)
|
||||
TOKEN = "a" * 32
|
||||
TOKEN2 = "b" * 32
|
||||
CHAT = 1001
|
||||
SECRET_TEXT = "меня зовут Андрей, мне плохо"
|
||||
|
||||
|
||||
class FakeSender:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[tuple[int, str, object]] = []
|
||||
|
||||
async def send_message(
|
||||
self, chat_id: int, text: str, parse_mode: str | None = None, reply_markup: object = None
|
||||
) -> object:
|
||||
self.sent.append((chat_id, text, reply_markup))
|
||||
return None
|
||||
|
||||
@property
|
||||
def texts(self) -> list[str]:
|
||||
return [t for _, t, _ in self.sent]
|
||||
|
||||
|
||||
class RecordingEngine:
|
||||
def __init__(self, fail: bool = False) -> None:
|
||||
self.calls: list[tuple[str, Event]] = []
|
||||
self.fail = fail
|
||||
|
||||
async def handle(self, token: str, event: Event) -> list[Reply]:
|
||||
self.calls.append((token, event))
|
||||
if self.fail:
|
||||
raise RuntimeError("boom")
|
||||
return [Reply(text=f"ok:{event.type}", buttons=[[Button(label="Да", data="yes")]])]
|
||||
|
||||
|
||||
class FakeIdentity:
|
||||
def __init__(self, codes: dict[str, str]) -> None:
|
||||
self.codes = codes
|
||||
|
||||
async def verify(self, auth_code: str) -> VerifiedPerson | None:
|
||||
token = self.codes.get(auth_code)
|
||||
return VerifiedPerson(token=token, doctor_id="doc-1") if token else None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def dbs() -> AsyncIterator[None]:
|
||||
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
|
||||
await init_db()
|
||||
async with session.get_identity_session() as s:
|
||||
for token, snils in ((TOKEN, "000-000-000 01"), (TOKEN2, "000-000-000 02")):
|
||||
await identity_repo.create_patient(
|
||||
s, token=token, display_name="Синтетика", snils=snils, clinic_id="c1", doctor_id="doc-1", now=T0
|
||||
)
|
||||
yield
|
||||
await session.configure()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(monkeypatch: pytest.MonkeyPatch) -> RecordingEngine:
|
||||
eng = RecordingEngine()
|
||||
monkeypatch.setattr(telegram, "get_engine", lambda: eng)
|
||||
return eng
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def identity(monkeypatch: pytest.MonkeyPatch) -> FakeIdentity:
|
||||
fake = FakeIdentity({"DEMO-0001": TOKEN, "DEMO-0002": TOKEN2, "GHOST": "c" * 32})
|
||||
monkeypatch.setattr(integrations, "identity", fake)
|
||||
return fake
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- rendering
|
||||
|
||||
|
||||
def test_render_markup_rows_and_empty() -> None:
|
||||
reply = Reply(
|
||||
text="?", buttons=[[Button(label="1", data="a"), Button(label="2", data="b")], [Button(label="3", data="c")]]
|
||||
)
|
||||
markup = render_markup(reply)
|
||||
assert markup is not None
|
||||
assert [[b.callback_data for b in row] for row in markup.inline_keyboard] == [["a", "b"], ["c"]]
|
||||
assert render_markup(Reply(text="x")) is None
|
||||
|
||||
|
||||
def test_render_markup_drops_too_long_data() -> None:
|
||||
reply = Reply(
|
||||
text="?", buttons=[[Button(label="ok", data="x"), Button(label="long", data="я" * CALLBACK_DATA_MAX)]]
|
||||
)
|
||||
markup = render_markup(reply)
|
||||
assert markup is not None
|
||||
assert [b.callback_data for b in markup.inline_keyboard[0]] == ["x"]
|
||||
|
||||
|
||||
def test_split_text_respects_limit_and_lines() -> None:
|
||||
text = "\n".join(f"строка {i}" for i in range(50))
|
||||
parts = split_text(text, limit=100)
|
||||
assert all(len(p) <= 100 for p in parts)
|
||||
assert "\n".join(parts).split("\n") == text.split("\n")
|
||||
assert split_text("коротко") == ["коротко"]
|
||||
assert all(len(p) <= 10 for p in split_text("x" * 35, limit=10))
|
||||
|
||||
|
||||
def test_texts_load() -> None:
|
||||
texts = load_texts()
|
||||
assert texts.not_connected and texts.code_invalid and texts.error and texts.unsupported
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- channel
|
||||
|
||||
|
||||
async def test_start_with_code_binds_chat_and_calls_engine(dbs: None, engine: RecordingEngine) -> None:
|
||||
sender = FakeSender()
|
||||
channel = TelegramChannel(sender)
|
||||
await channel.on_start(CHAT, "DEMO-0001")
|
||||
|
||||
assert engine.calls == [(TOKEN, Event(type="start", params={"code": "DEMO-0001"}))]
|
||||
assert sender.sent[0][1] == "ok:start"
|
||||
assert sender.sent[0][2] is not None # buttons rendered
|
||||
async with session.get_identity_session() as s:
|
||||
patient = await identity_repo.get_patient(s, TOKEN)
|
||||
assert patient is not None and patient.tg_chat_id == CHAT
|
||||
|
||||
|
||||
async def test_invalid_code_does_not_reach_engine(dbs: None, engine: RecordingEngine) -> None:
|
||||
sender = FakeSender()
|
||||
await TelegramChannel(sender).on_start(CHAT, "WRONG")
|
||||
assert engine.calls == []
|
||||
assert sender.texts == [load_texts().code_invalid]
|
||||
|
||||
|
||||
async def test_verified_token_without_identity_record(dbs: None, engine: RecordingEngine) -> None:
|
||||
sender = FakeSender()
|
||||
await TelegramChannel(sender).on_start(CHAT, "GHOST")
|
||||
assert engine.calls == []
|
||||
assert sender.texts == [load_texts().code_invalid]
|
||||
|
||||
|
||||
async def test_unbound_chat_gets_not_connected(dbs: None, engine: RecordingEngine) -> None:
|
||||
sender = FakeSender()
|
||||
channel = TelegramChannel(sender)
|
||||
await channel.on_start(CHAT, None)
|
||||
await channel.on_text(CHAT, "привет")
|
||||
await channel.on_button(CHAT, "yes")
|
||||
assert engine.calls == []
|
||||
assert sender.texts == [load_texts().not_connected] * 3
|
||||
|
||||
|
||||
async def test_bound_chat_routes_text_button_and_plain_start(dbs: None, engine: RecordingEngine) -> None:
|
||||
sender = FakeSender()
|
||||
channel = TelegramChannel(sender)
|
||||
await channel.on_start(CHAT, "DEMO-0001")
|
||||
# Fresh directory: binding must come from the identity DB, not the cache.
|
||||
channel = TelegramChannel(sender, ChatDirectory())
|
||||
await channel.on_text(CHAT, "плохо спал")
|
||||
await channel.on_button(CHAT, "yes")
|
||||
await channel.on_start(CHAT, None)
|
||||
await channel.on_text(CHAT, None) # sticker / photo
|
||||
assert [e for _, e in engine.calls[1:]] == [
|
||||
Event(type="text", text="плохо спал"),
|
||||
Event(type="button", button="yes"),
|
||||
Event(type="start"),
|
||||
]
|
||||
assert all(tok == TOKEN for tok, _ in engine.calls)
|
||||
assert sender.texts[-1] == load_texts().unsupported
|
||||
|
||||
|
||||
async def test_rebinding_chat_to_another_patient(dbs: None, engine: RecordingEngine) -> None:
|
||||
channel = TelegramChannel(FakeSender())
|
||||
await channel.on_start(CHAT, "DEMO-0001")
|
||||
await channel.on_start(CHAT, "DEMO-0002")
|
||||
await channel.on_text(CHAT, "привет")
|
||||
assert engine.calls[-1][0] == TOKEN2
|
||||
assert await channel.directory.chat_for(TOKEN) is None
|
||||
async with session.get_identity_session() as s:
|
||||
first = await identity_repo.get_patient(s, TOKEN)
|
||||
assert first is not None and first.tg_chat_id is None
|
||||
|
||||
|
||||
async def test_engine_failure_sends_error_without_text_in_logs(
|
||||
dbs: None, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
eng = RecordingEngine()
|
||||
monkeypatch.setattr(telegram, "get_engine", lambda: eng)
|
||||
sender = FakeSender()
|
||||
channel = TelegramChannel(sender)
|
||||
await channel.on_start(CHAT, "DEMO-0001")
|
||||
eng.fail = True
|
||||
with caplog.at_level(logging.DEBUG, logger="app.channels.telegram"):
|
||||
await channel.on_text(CHAT, SECRET_TEXT)
|
||||
assert sender.texts[-1] == load_texts().error
|
||||
logged = "\n".join(r.getMessage() for r in caplog.records)
|
||||
assert SECRET_TEXT not in logged and str(CHAT) not in logged and TOKEN not in logged
|
||||
|
||||
|
||||
async def test_crisis_text_sent_verbatim(dbs: None, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
crisis = "Позвоните 8-800-2000-122 *сейчас* <b>не</b> _одни_"
|
||||
|
||||
class CrisisEngine:
|
||||
async def handle(self, token: str, event: Event) -> list[Reply]:
|
||||
return [Reply(text=crisis, kind="crisis")]
|
||||
|
||||
monkeypatch.setattr(telegram, "get_engine", CrisisEngine)
|
||||
sender = FakeSender()
|
||||
await TelegramChannel(sender).on_start(CHAT, "DEMO-0001")
|
||||
assert sender.texts == [crisis]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- outbox
|
||||
|
||||
|
||||
async def test_outbox_pushes_to_bound_chat_and_falls_back(dbs: None, engine: RecordingEngine) -> None:
|
||||
sender = FakeSender()
|
||||
channel = TelegramChannel(sender)
|
||||
await channel.on_start(CHAT, "DEMO-0001")
|
||||
sender.sent.clear()
|
||||
|
||||
fallback = MemoryOutbox()
|
||||
outbox = TelegramOutbox(sender, ChatDirectory(), fallback=fallback)
|
||||
await outbox.push(TOKEN, [Reply(text="Время принять сертралин")])
|
||||
await outbox.push(TOKEN2, [Reply(text="Как вы сегодня?")])
|
||||
|
||||
assert sender.sent == [(CHAT, "Время принять сертралин", None)]
|
||||
assert [r.text for r in fallback.drain(TOKEN2)] == ["Как вы сегодня?"]
|
||||
|
||||
|
||||
async def test_start_is_noop_without_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(telegram, "get_settings", lambda: type("S", (), {"tg_bot_token": ""})())
|
||||
before = integrations.outbox
|
||||
await telegram.start()
|
||||
assert telegram._task is None
|
||||
assert integrations.outbox is before
|
||||
await telegram.stop()
|
||||
|
||||
|
||||
async def test_start_stop_swaps_outbox(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(telegram, "get_settings", lambda: type("S", (), {"tg_bot_token": "123:fake"})())
|
||||
|
||||
async def fake_polling(self: object, *bots: object, **kwargs: object) -> None:
|
||||
import asyncio
|
||||
|
||||
await asyncio.Event().wait()
|
||||
|
||||
monkeypatch.setattr(telegram.Dispatcher, "start_polling", fake_polling)
|
||||
before = integrations.outbox
|
||||
await telegram.start()
|
||||
assert isinstance(integrations.outbox, TelegramOutbox)
|
||||
await telegram.stop()
|
||||
assert integrations.outbox is before
|
||||
assert telegram._task is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- aiogram wiring (fake HTTP session)
|
||||
|
||||
|
||||
class FakeSession(BaseSession):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.calls: list[TelegramMethod[Any]] = []
|
||||
|
||||
async def make_request(self, bot: Bot, method: TelegramMethod[Any], timeout: int | None = None) -> Any: # noqa: ASYNC109 — aiogram signature
|
||||
self.calls.append(method)
|
||||
if isinstance(method, SendMessage):
|
||||
return TgMessage(message_id=1, date=T0, chat=Chat(id=method.chat_id, type="private"), text=method.text)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
async def stream_content(self, *args: Any, **kwargs: Any) -> AsyncGenerator[bytes, None]:
|
||||
yield b""
|
||||
|
||||
|
||||
def _update(update_id: int, **kwargs: Any) -> Update:
|
||||
return Update(update_id=update_id, **kwargs)
|
||||
|
||||
|
||||
async def test_router_handles_real_updates(dbs: None, engine: RecordingEngine) -> None:
|
||||
fake = FakeSession()
|
||||
bot = Bot("123:fake", session=fake)
|
||||
channel = TelegramChannel(bot)
|
||||
dp = Dispatcher()
|
||||
dp.include_router(telegram.build_router(channel))
|
||||
chat = Chat(id=CHAT, type="private")
|
||||
user = User(id=CHAT, is_bot=False, first_name="Синтетика")
|
||||
|
||||
start = TgMessage(message_id=10, date=T0, chat=chat, from_user=user, text="/start DEMO-0001")
|
||||
await dp.feed_update(bot, _update(1, message=start))
|
||||
text = TgMessage(message_id=11, date=T0, chat=chat, from_user=user, text="плохо спал")
|
||||
await dp.feed_update(bot, _update(2, message=text))
|
||||
shown = TgMessage(message_id=12, date=T0, chat=chat, text="?")
|
||||
query = CallbackQuery(id="q1", from_user=user, chat_instance="ci", message=shown, data="yes")
|
||||
await dp.feed_update(bot, _update(3, callback_query=query))
|
||||
|
||||
assert [e for _, e in engine.calls] == [
|
||||
Event(type="start", params={"code": "DEMO-0001"}),
|
||||
Event(type="text", text="плохо спал"),
|
||||
Event(type="button", button="yes"),
|
||||
]
|
||||
kinds = [type(m).__name__ for m in fake.calls]
|
||||
assert kinds.count("SendMessage") == 3
|
||||
assert "SendChatAction" in kinds and "AnswerCallbackQuery" in kinds and "EditMessageReplyMarkup" in kinds
|
||||
sent = [m for m in fake.calls if isinstance(m, SendMessage)]
|
||||
assert all(m.parse_mode is None for m in sent) # crisis scripts must reach the patient verbatim
|
||||
Reference in New Issue
Block a user