release: dev → main (сдача хакатона) #106

Merged
Eleboba merged 128 commits from dev into main 2026-10-04 23:00:56 +03:00
3 changed files with 653 additions and 3 deletions
Showing only changes of commit 8fd1202063 - Show all commits
+304 -3
View File
@@ -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")
+18
View File
@@ -0,0 +1,18 @@
# Служебные тексты Telegram-канала (#11). Реплики сценариев приходят из движка диалога, здесь — только то,
# что канал говорит сам, пока пациент не подключён или движок недоступен. Зона clinical вычитывает формулировки.
# Чат ещё не привязан к пациенту: /start без кода или любое сообщение до подключения.
not_connected: >-
Здравствуйте! Я помощник для сопровождения лечения между визитами к врачу.
Чтобы начать, откройте ссылку или отсканируйте QR-код, который выдал ваш врач.
# Код из ссылки неизвестен или уже использован.
code_invalid: >-
Не получилось подключиться по этому коду: он неверный или уже использован.
Попросите врача выдать новый код.
# Кнопка от старого сообщения, движок её уже не ждёт, или внутренняя ошибка.
error: "Что-то пошло не так. Попробуйте, пожалуйста, ещё раз чуть позже."
# Сообщение без текста (стикер, фото, голосовое).
unsupported: "Пока я понимаю только текст и кнопки. Напишите, пожалуйста, словами."
+331
View File
@@ -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