Merge pull request 'feat(bot): Telegram-адаптер поверх движка диалога' (#51) from feat/11-telegram into dev
CI / ruff · pytest (push) Successful in 31s
CI / ruff · pytest (push) Successful in 31s
This commit was merged in pull request #51.
This commit is contained in:
+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:
|
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:
|
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