feat: extend platform mock + add research docs

platform/interface.py:
- Add Attachment, MessageChunk, AgentEvent types
- Add stream_message() to PlatformClient Protocol (door open for streaming)
- Add WebhookReceiver Protocol

platform/mock.py:
- Add attachment_mode config (url/binary/s3)
- Implement stream_message() — single chunk, ready for real streaming
- Add register_webhook_receiver() + simulate_agent_event() for testing

docs/research/:
- telegram-forum-topics.md — aiogram 3.x Forum Topics API, FSM patterns, UX analysis
- fsm-patterns.md — FSM storage options, StateData best practices
- matrix-spaces.md — matrix-nio Space API, room ordering, invite flow
- matrix-events.md — reactions, threads, typing, sync loop pitfalls
- telegram-chat-alternatives.md — 7 alternatives for multi-chat UX, virtual chats in DM recommended
This commit is contained in:
Mikhail Putilovskij 2026-03-30 14:04:34 +03:00
parent 6f0e9a53a6
commit 67499daa61
7 changed files with 1515 additions and 29 deletions

View file

@ -5,11 +5,19 @@ import asyncio
import random
import uuid
from datetime import UTC, datetime
from typing import Any
from typing import Any, AsyncIterator, Literal
import structlog
from platform.interface import MessageResponse, User, UserSettings
from platform.interface import (
AgentEvent,
Attachment,
MessageChunk,
MessageResponse,
User,
UserSettings,
WebhookReceiver,
)
logger = structlog.get_logger(__name__)
@ -21,15 +29,27 @@ class MockPlatformClient:
Реализует PlatformClient Protocol. При подключении реального SDK
заменяется только этот файл core/ и адаптеры не трогаются.
Ключевое отличие от реальной платформы: не управляет lifecycle контейнера.
Master делает это сам при получении send_message.
attachment_mode симулирует разные варианты передачи файлов:
"url" платформа получает URL, скачивает сама (текущий план)
"binary" бинарные данные в теле (резерв)
"s3" pre-signed S3 URL (резерв)
Webhook: зарегистрируй WebhookReceiver через register_webhook_receiver(),
вызови simulate_agent_event() чтобы имитировать входящее уведомление.
"""
def __init__(self) -> None:
def __init__(
self,
attachment_mode: Literal["url", "binary", "s3"] = "url",
) -> None:
self.attachment_mode = attachment_mode
self._users: dict[str, dict] = {}
self._messages: dict[str, list] = {} # "{user_id}:{chat_id}" → messages
self._settings: dict[str, dict] = {}
logger.info("MockPlatformClient initialized")
self._webhook_receiver: WebhookReceiver | None = None
logger.info("MockPlatformClient initialized", attachment_mode=attachment_mode)
# ------------------------------------------------------------------ users
async def get_or_create_user(
self,
@ -52,39 +72,53 @@ class MockPlatformClient:
data = {**self._users[key], "is_new": is_new}
return User(**data)
# --------------------------------------------------------------- messages
async def send_message(
self,
user_id: str,
chat_id: str,
text: str,
attachments: list | None = None,
attachments: list[Attachment] | None = None,
) -> MessageResponse:
await self._latency(200, 600)
key = f"{user_id}:{chat_id}"
if key not in self._messages:
self._messages[key] = []
message_id = str(uuid.uuid4())
preview = text[:50] + ("..." if len(text) > 50 else "")
response = f"[MOCK] Ответ на: «{preview}»"
self._messages[key].append({
"message_id": message_id,
"user_text": text,
"response": response,
"tokens_used": len(text.split()) * 2,
"finished": True,
"created_at": datetime.now(UTC).isoformat(),
})
logger.info("Message sent", user_id=user_id, chat_id=chat_id, message_id=message_id)
message_id, response, tokens = self._build_response(user_id, chat_id, text, attachments)
logger.info("send_message", user_id=user_id, chat_id=chat_id, message_id=message_id)
return MessageResponse(
message_id=message_id,
response=response,
tokens_used=len(text.split()) * 2,
tokens_used=tokens,
finished=True,
)
async def stream_message(
self,
user_id: str,
chat_id: str,
text: str,
attachments: list[Attachment] | None = None,
) -> AsyncIterator[MessageChunk]:
"""
Сейчас: один чанк с полным ответом (sync под капотом).
При реальном SDK: заменить на SSE/WebSocket итератор в platform/mock.py.
Адаптеры переписывать не нужно.
"""
await self._latency(200, 600)
message_id, response, tokens = self._build_response(user_id, chat_id, text, attachments)
logger.info("stream_message", user_id=user_id, chat_id=chat_id, message_id=message_id)
async def _gen() -> AsyncIterator[MessageChunk]:
yield MessageChunk(
message_id=message_id,
delta=response,
finished=True,
tokens_used=tokens,
)
return _gen()
# --------------------------------------------------------------- settings
async def get_settings(self, user_id: str) -> UserSettings:
await self._latency()
stored = self._settings.get(user_id, {})
@ -127,11 +161,71 @@ class MockPlatformClient:
logger.info("Settings updated", user_id=user_id, action=action.action)
# --------------------------------------------------------------- webhooks
def register_webhook_receiver(self, receiver: WebhookReceiver) -> None:
"""Бот регистрирует свой обработчик входящих событий от платформы."""
self._webhook_receiver = receiver
logger.info("WebhookReceiver registered")
async def simulate_agent_event(
self,
user_id: str,
chat_id: str,
event_type: Literal["task_done", "task_error", "task_progress"] = "task_done",
payload: dict | None = None,
) -> None:
"""Имитирует входящий webhook от платформы. Используется в тестах и ручном QA."""
if self._webhook_receiver is None:
logger.warning("simulate_agent_event: no WebhookReceiver registered")
return
event = AgentEvent(
event_id=str(uuid.uuid4()),
user_id=user_id,
chat_id=chat_id,
event_type=event_type,
payload=payload or {"message": "[MOCK] Долгая задача выполнена"},
)
await self._webhook_receiver.on_agent_event(event)
# ------------------------------------------------------------------ utils
def get_stats(self) -> dict:
return {
"total_users": len(self._users),
"total_messages": sum(len(msgs) for msgs in self._messages.values()),
"attachment_mode": self.attachment_mode,
}
def _build_response(
self,
user_id: str,
chat_id: str,
text: str,
attachments: list[Attachment] | None,
) -> tuple[str, str, int]:
key = f"{user_id}:{chat_id}"
if key not in self._messages:
self._messages[key] = []
message_id = str(uuid.uuid4())
preview = text[:50] + ("..." if len(text) > 50 else "")
attachment_note = ""
if attachments:
names = [a.filename or a.mime_type for a in attachments]
attachment_note = f" [вложения: {', '.join(names)}]"
response = f"[MOCK] Ответ на: «{preview}»{attachment_note}"
tokens = len(text.split()) * 2
self._messages[key].append({
"message_id": message_id,
"user_text": text,
"response": response,
"tokens_used": tokens,
"finished": True,
"created_at": datetime.now(UTC).isoformat(),
})
return message_id, response, tokens
async def _latency(self, min_ms: int = 10, max_ms: int = 80) -> None:
await asyncio.sleep(random.randint(min_ms, max_ms) / 1000)