diff --git a/app/channels/telegram.py b/app/channels/telegram.py index 398ad75..486d36a 100644 --- a/app/channels/telegram.py +++ b/app/channels/telegram.py @@ -1,9 +1,310 @@ -"""Telegram channel — stub until #11. Prototype on synthetic data only; prod channel is MAX / Gosuslugi.""" +"""Telegram channel (#11). Prototype on synthetic data only; prod channel is MAX / Gosuslugi. + +A thin layer over the dialog engine: update → `get_engine().handle(token, event)` → render `Reply` list. +- `/start ` (deep link from the doctor's QR) → `integrations.identity.verify(code)` → bind chat to token. +- Text / button presses of a bound chat → engine events; unbound chats get a fixed "ask your doctor" text. +- `TelegramOutbox` delivers system-initiated replies (scheduler pushes) to the bound chat. + +The channel is the registration boundary, so it is allowed to use the identity DB (chat id ↔ token, guardrail #5). +Logs carry only event types and short token prefixes — never chat ids or message text (guardrail #8). +Without `TG_BOT_TOKEN` `start()` is a no-op. +""" + +import asyncio +import contextlib +import logging +from functools import lru_cache +from pathlib import Path +from typing import Protocol + +import yaml +from aiogram import Bot, Dispatcher, F, Router +from aiogram.filters import CommandObject, CommandStart +from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message +from pydantic import BaseModel + +from app.config import get_settings +from app.db import identity_repo +from app.db.session import get_identity_session +from app.dialog.contracts import Event, Reply, get_engine +from app.integrations.base import Outbox, integrations + +log = logging.getLogger("app.channels.telegram") + +CALLBACK_DATA_MAX = 64 # Telegram limit for callback_data, bytes +MESSAGE_MAX = 4096 # Telegram limit for message text, characters + + +class TelegramTexts(BaseModel): + not_connected: str + code_invalid: str + error: str + unsupported: str + + +@lru_cache +def load_texts(path: Path | None = None) -> TelegramTexts: + path = path or get_settings().config_dir / "texts" / "telegram.yaml" + return TelegramTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) + + +class Sender(Protocol): + """The part of `aiogram.Bot` the channel uses; tests pass a recorder.""" + + async def send_message( + self, chat_id: int, text: str, parse_mode: str | None = None, reply_markup: InlineKeyboardMarkup | None = None + ) -> object: ... + + +def _short(token: str) -> str: + return token[:6] + + +# ----------------------------------------------------------------- rendering + + +def render_markup(reply: Reply) -> InlineKeyboardMarkup | None: + """Abstract button rows → inline keyboard. Over-long `data` is dropped (Telegram rejects the whole message).""" + rows: list[list[InlineKeyboardButton]] = [] + for row in reply.buttons: + buttons = [] + for button in row: + if len(button.data.encode()) > CALLBACK_DATA_MAX: + log.warning("button data too long, dropped len=%d", len(button.data.encode())) + continue + buttons.append(InlineKeyboardButton(text=button.label, callback_data=button.data)) + if buttons: + rows.append(buttons) + return InlineKeyboardMarkup(inline_keyboard=rows) if rows else None + + +def split_text(text: str, limit: int = MESSAGE_MAX) -> list[str]: + """Split by paragraphs/lines so long replies (safety plan) fit Telegram's limit; hard cut as a last resort.""" + chunks: list[str] = [] + rest = text + while len(rest) > limit: + cut = rest.rfind("\n", 0, limit) + if cut <= 0: + cut = limit + chunks.append(rest[:cut].rstrip()) + rest = rest[cut:].lstrip("\n") + chunks.append(rest) + return [c for c in chunks if c] or [""] + + +async def send_replies(sender: Sender, chat_id: int, replies: list[Reply]) -> None: + """Send replies in order; text is sent verbatim (no parse_mode: crisis scripts must not be altered).""" + for reply in replies: + parts = split_text(reply.text) + markup = render_markup(reply) + for i, part in enumerate(parts): + # Buttons go under the last chunk so they stay next to the question. + await sender.send_message( + chat_id, part, parse_mode=None, reply_markup=markup if i == len(parts) - 1 else None + ) + + +# ----------------------------------------------------------------- chat ↔ token + + +class ChatDirectory: + """chat id ↔ token on top of the identity DB, with an in-process cache (a chat is bound once).""" + + def __init__(self) -> None: + self._token_by_chat: dict[int, str] = {} + self._chat_by_token: dict[str, int] = {} + + def _remember(self, chat_id: int, token: str) -> None: + self._token_by_chat[chat_id] = token + self._chat_by_token[token] = chat_id + + async def token_for(self, chat_id: int) -> str | None: + if chat_id in self._token_by_chat: + return self._token_by_chat[chat_id] + async with get_identity_session() as s: + patient = await identity_repo.get_patient_by_chat(s, chat_id) + if patient is None: + return None + self._remember(chat_id, patient.token) + return patient.token + + async def chat_for(self, token: str) -> int | None: + if token in self._chat_by_token: + return self._chat_by_token[token] + async with get_identity_session() as s: + patient = await identity_repo.get_patient(s, token) + if patient is None or patient.tg_chat_id is None: + return None + self._remember(patient.tg_chat_id, token) + return patient.tg_chat_id + + async def bind(self, chat_id: int, token: str) -> bool: + async with get_identity_session() as s: + # A chat id is unique in the identity DB: rebinding to another patient frees it first. + previous = await identity_repo.get_patient_by_chat(s, chat_id) + if previous is not None and previous.token != token: + previous.tg_chat_id = None + await s.flush() + patient = await identity_repo.bind_chat(s, token=token, tg_chat_id=chat_id) + if patient is None: + return False + stale = self._token_by_chat.pop(chat_id, None) + if stale is not None: + self._chat_by_token.pop(stale, None) + self._remember(chat_id, token) + return True + + +# ----------------------------------------------------------------- channel logic (no aiogram types) + + +class TelegramChannel: + """Channel logic decoupled from aiogram updates, so it is testable with a fake sender.""" + + def __init__(self, sender: Sender, directory: ChatDirectory | None = None) -> None: + self.sender = sender + self.directory = directory or ChatDirectory() + + async def _say(self, chat_id: int, text: str) -> None: + await send_replies(self.sender, chat_id, [Reply(text=text, kind="info")]) + + async def _dispatch(self, chat_id: int, token: str, event: Event) -> None: + try: + replies = await get_engine().handle(token, event) + except Exception: + # The engine already logs details; here only the fact, without text or chat id. + log.exception("engine failed tok=%s event=%s", _short(token), event.type) + replies = [Reply(text=load_texts().error, kind="info")] + await send_replies(self.sender, chat_id, replies) + + async def on_start(self, chat_id: int, code: str | None) -> None: + texts = load_texts() + if code: + person = await integrations.identity.verify(code) + if person is None: + log.info("start: code rejected") + await self._say(chat_id, texts.code_invalid) + return + if not await self.directory.bind(chat_id, person.token): + log.warning("start: verified token has no identity record tok=%s", _short(person.token)) + await self._say(chat_id, texts.code_invalid) + return + log.info("start: chat bound tok=%s", _short(person.token)) + await self._dispatch(chat_id, person.token, Event(type="start", params={"code": code})) + return + token = await self.directory.token_for(chat_id) + if token is None: + await self._say(chat_id, texts.not_connected) + return + await self._dispatch(chat_id, token, Event(type="start")) + + async def on_text(self, chat_id: int, text: str | None) -> None: + texts = load_texts() + token = await self.directory.token_for(chat_id) + if token is None: + await self._say(chat_id, texts.not_connected) + return + if not text: + await self._say(chat_id, texts.unsupported) + return + await self._dispatch(chat_id, token, Event(type="text", text=text)) + + async def on_button(self, chat_id: int, data: str) -> None: + token = await self.directory.token_for(chat_id) + if token is None: + await self._say(chat_id, load_texts().not_connected) + return + await self._dispatch(chat_id, token, Event(type="button", button=data)) + + +class TelegramOutbox: + """Outbox for scheduler pushes: bound chats get Telegram messages, the rest go to the wrapped outbox + (the web demo console polls it), so a patient connected via the console keeps working.""" + + def __init__(self, sender: Sender, directory: ChatDirectory, fallback: Outbox) -> None: + self.sender = sender + self.directory = directory + self.fallback = fallback + + async def push(self, token: str, replies: list[Reply]) -> None: + chat_id = await self.directory.chat_for(token) + if chat_id is None: + await self.fallback.push(token, replies) + return + await send_replies(self.sender, chat_id, replies) + + +# ----------------------------------------------------------------- aiogram wiring + + +def build_router(channel: TelegramChannel) -> Router: + router = Router(name="patient") + + @router.message(CommandStart()) + async def _start(message: Message, command: CommandObject) -> None: + await channel.on_start(message.chat.id, (command.args or "").strip() or None) + + @router.message(F.chat.type == "private") + async def _text(message: Message) -> None: + if message.bot is not None and message.text: + await message.bot.send_chat_action(message.chat.id, "typing") + await channel.on_text(message.chat.id, message.text) + + @router.callback_query(F.data) + async def _button(query: CallbackQuery) -> None: + await query.answer() + msg = query.message + if not isinstance(msg, Message): # inaccessible (too old) message + return + # Remove the keyboard so the same answer cannot be submitted twice. + try: + await msg.edit_reply_markup(reply_markup=None) + except Exception: + log.debug("could not remove keyboard") + await channel.on_button(msg.chat.id, query.data or "") + + return router + + +_bot: Bot | None = None +_dp: Dispatcher | None = None +_task: asyncio.Task[None] | None = None +_prev_outbox: Outbox | None = None async def start() -> None: - """Start polling if TG_BOT_TOKEN is set (#11); no-op otherwise.""" + """Start polling if TG_BOT_TOKEN is set; no-op otherwise.""" + global _bot, _dp, _task, _prev_outbox + token = get_settings().tg_bot_token + if not token or _task is not None: + if not token: + log.info("telegram disabled: TG_BOT_TOKEN is empty") + return + bot = Bot(token) + channel = TelegramChannel(bot) + dp = Dispatcher() + dp.include_router(build_router(channel)) + _prev_outbox = integrations.outbox + integrations.outbox = TelegramOutbox(bot, channel.directory, fallback=_prev_outbox) + _bot, _dp = bot, dp + _task = asyncio.create_task(dp.start_polling(bot, handle_signals=False), name="telegram-polling") + log.info("telegram polling started") async def stop() -> None: - """Stop polling (#11).""" + """Stop polling and restore the previous outbox.""" + global _bot, _dp, _task, _prev_outbox + if _task is None: + return + if _dp is not None: + with contextlib.suppress(RuntimeError): # polling never got to start + await _dp.stop_polling() + _task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await _task + if _bot is not None: + await _bot.session.close() + if _prev_outbox is not None and isinstance(integrations.outbox, TelegramOutbox): + integrations.outbox = _prev_outbox + _bot = _dp = _task = _prev_outbox = None + log.info("telegram polling stopped") diff --git a/config/texts/telegram.yaml b/config/texts/telegram.yaml new file mode 100644 index 0000000..0cda957 --- /dev/null +++ b/config/texts/telegram.yaml @@ -0,0 +1,18 @@ +# Служебные тексты Telegram-канала (#11). Реплики сценариев приходят из движка диалога, здесь — только то, +# что канал говорит сам, пока пациент не подключён или движок недоступен. Зона clinical вычитывает формулировки. + +# Чат ещё не привязан к пациенту: /start без кода или любое сообщение до подключения. +not_connected: >- + Здравствуйте! Я помощник для сопровождения лечения между визитами к врачу. + Чтобы начать, откройте ссылку или отсканируйте QR-код, который выдал ваш врач. + +# Код из ссылки неизвестен или уже использован. +code_invalid: >- + Не получилось подключиться по этому коду: он неверный или уже использован. + Попросите врача выдать новый код. + +# Кнопка от старого сообщения, движок её уже не ждёт, или внутренняя ошибка. +error: "Что-то пошло не так. Попробуйте, пожалуйста, ещё раз чуть позже." + +# Сообщение без текста (стикер, фото, голосовое). +unsupported: "Пока я понимаю только текст и кнопки. Напишите, пожалуйста, словами." diff --git a/tests/test_telegram.py b/tests/test_telegram.py new file mode 100644 index 0000000..77b9065 --- /dev/null +++ b/tests/test_telegram.py @@ -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 *сейчас* не _одни_" + + 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