feat(bot): Telegram-адаптер поверх движка диалога #51
+304
-3
@@ -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 <code>` (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")
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Служебные тексты Telegram-канала (#11). Реплики сценариев приходят из движка диалога, здесь — только то,
|
||||
# что канал говорит сам, пока пациент не подключён или движок недоступен. Зона clinical вычитывает формулировки.
|
||||
|
||||
# Чат ещё не привязан к пациенту: /start без кода или любое сообщение до подключения.
|
||||
not_connected: >-
|
||||
Здравствуйте! Я помощник для сопровождения лечения между визитами к врачу.
|
||||
Чтобы начать, откройте ссылку или отсканируйте QR-код, который выдал ваш врач.
|
||||
|
||||
# Код из ссылки неизвестен или уже использован.
|
||||
code_invalid: >-
|
||||
Не получилось подключиться по этому коду: он неверный или уже использован.
|
||||
Попросите врача выдать новый код.
|
||||
|
||||
# Кнопка от старого сообщения, движок её уже не ждёт, или внутренняя ошибка.
|
||||
error: "Что-то пошло не так. Попробуйте, пожалуйста, ещё раз чуть позже."
|
||||
|
||||
# Сообщение без текста (стикер, фото, голосовое).
|
||||
unsupported: "Пока я понимаю только текст и кнопки. Напишите, пожалуйста, словами."
|
||||
@@ -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