- Surface Protocol: unified IncomingMessage/OutgoingUI/ChatContext - Telegram: Forum Topics (group + topics per chat) - Matrix: Space + rooms per chat - MockPlatformClient with PlatformClient Protocol - docs: surface-protocol, telegram/matrix specs, api-contract, claude-code-guide - project scaffold: src/, tests/, pyproject.toml Co-Authored-By: Claude Sonnet 4-6 <noreply@anthropic.com>
246 lines
8.8 KiB
Python
246 lines
8.8 KiB
Python
"""
|
||
MockPlatformClient — заглушка SDK платформы Lambda.
|
||
|
||
Единственный файл который нужно заменить при подключении реального SDK.
|
||
Все обращения к платформе в коде ботов идут только через этот класс.
|
||
|
||
При подключении реального SDK:
|
||
1. Скопируй интерфейс (методы и сигнатуры) из этого файла
|
||
2. Замени тело методов на реальные API вызовы
|
||
3. Обнови импорт в telegram_bot/main.py и matrix_bot/main.py
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import uuid
|
||
from datetime import datetime, timedelta
|
||
from typing import Any
|
||
|
||
import structlog
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
|
||
class PlatformError(Exception):
|
||
"""Базовый класс ошибок платформы."""
|
||
def __init__(self, message: str, code: str = "PLATFORM_ERROR"):
|
||
super().__init__(message)
|
||
self.code = code
|
||
|
||
|
||
class SessionNotFoundError(PlatformError):
|
||
def __init__(self, session_id: str):
|
||
super().__init__(f"Session {session_id} not found", "SESSION_NOT_FOUND")
|
||
|
||
|
||
class MockPlatformClient:
|
||
"""
|
||
Имитирует поведение SDK платформы Lambda.
|
||
|
||
Хранит состояние в памяти — при перезапуске сбрасывается.
|
||
Для персистентности в моке используй MockPlatformClient(persistent=True)
|
||
(тогда сохраняет в /tmp/mock_platform_state.json).
|
||
"""
|
||
|
||
def __init__(self, base_url: str = "http://localhost:8000", timeout: float = 5.0):
|
||
self._sessions: dict[str, dict] = {}
|
||
self._messages: dict[str, list] = {} # session_id -> messages
|
||
self._base_url = base_url
|
||
self._timeout = timeout
|
||
logger.info("MockPlatformClient initialized", base_url=base_url)
|
||
|
||
# ─── Sessions ────────────────────────────────────────────────────────────
|
||
|
||
async def create_session(
|
||
self,
|
||
user_id: str,
|
||
platform: str, # "telegram" | "matrix"
|
||
context: dict[str, Any] | None = None,
|
||
) -> dict:
|
||
"""
|
||
Создаёт новую сессию пользователя с AI-агентом.
|
||
|
||
Returns:
|
||
{
|
||
"session_id": str,
|
||
"agent_id": str,
|
||
"created_at": ISO8601,
|
||
"expires_at": ISO8601,
|
||
}
|
||
"""
|
||
await self._simulate_latency()
|
||
|
||
session_id = str(uuid.uuid4())
|
||
agent_id = f"agent-{uuid.uuid4().hex[:8]}"
|
||
now = datetime.utcnow()
|
||
|
||
session = {
|
||
"session_id": session_id,
|
||
"agent_id": agent_id,
|
||
"user_id": user_id,
|
||
"platform": platform,
|
||
"context": context or {},
|
||
"created_at": now.isoformat() + "Z",
|
||
"expires_at": (now + timedelta(hours=24)).isoformat() + "Z",
|
||
"status": "active",
|
||
}
|
||
|
||
self._sessions[session_id] = session
|
||
self._messages[session_id] = []
|
||
|
||
logger.info("Session created", session_id=session_id, user_id=user_id, platform=platform)
|
||
return {k: v for k, v in session.items() if k not in ("user_id", "platform", "context", "status")}
|
||
|
||
async def get_session(self, session_id: str) -> dict:
|
||
"""
|
||
Возвращает информацию о сессии.
|
||
|
||
Raises:
|
||
SessionNotFoundError: если сессия не существует или истекла
|
||
"""
|
||
await self._simulate_latency()
|
||
|
||
session = self._sessions.get(session_id)
|
||
if not session:
|
||
raise SessionNotFoundError(session_id)
|
||
|
||
return session
|
||
|
||
async def close_session(self, session_id: str) -> bool:
|
||
"""
|
||
Завершает сессию.
|
||
|
||
Returns:
|
||
True если сессия была закрыта, False если уже была закрыта
|
||
"""
|
||
await self._simulate_latency()
|
||
|
||
session = self._sessions.get(session_id)
|
||
if not session:
|
||
raise SessionNotFoundError(session_id)
|
||
|
||
was_active = session["status"] == "active"
|
||
session["status"] = "closed"
|
||
session["closed_at"] = datetime.utcnow().isoformat() + "Z"
|
||
|
||
logger.info("Session closed", session_id=session_id)
|
||
return was_active
|
||
|
||
# ─── Messages ─────────────────────────────────────────────────────────────
|
||
|
||
async def send_message(
|
||
self,
|
||
session_id: str,
|
||
text: str,
|
||
attachments: list[dict] | None = None,
|
||
) -> dict:
|
||
"""
|
||
Отправляет сообщение пользователя в сессию и получает ответ агента.
|
||
|
||
Returns:
|
||
{
|
||
"message_id": str,
|
||
"response": str,
|
||
"tokens_used": int,
|
||
"finished": bool,
|
||
}
|
||
"""
|
||
await self._simulate_latency(min_ms=200, max_ms=800)
|
||
|
||
if session_id not in self._sessions:
|
||
raise SessionNotFoundError(session_id)
|
||
|
||
message_id = str(uuid.uuid4())
|
||
|
||
# Mock ответ агента
|
||
response = f"[MOCK] Ответ на: «{text[:50]}{'...' if len(text) > 50 else ''}»"
|
||
|
||
message = {
|
||
"message_id": message_id,
|
||
"session_id": session_id,
|
||
"user_text": text,
|
||
"response": response,
|
||
"tokens_used": len(text.split()) * 2, # грубая оценка
|
||
"finished": True,
|
||
"created_at": datetime.utcnow().isoformat() + "Z",
|
||
}
|
||
|
||
self._messages[session_id].append(message)
|
||
|
||
logger.info("Message sent", session_id=session_id, message_id=message_id)
|
||
return {
|
||
"message_id": message["message_id"],
|
||
"response": message["response"],
|
||
"tokens_used": message["tokens_used"],
|
||
"finished": message["finished"],
|
||
}
|
||
|
||
async def get_message_history(
|
||
self,
|
||
session_id: str,
|
||
limit: int = 20,
|
||
offset: int = 0,
|
||
) -> list[dict]:
|
||
"""
|
||
Возвращает историю сообщений сессии.
|
||
"""
|
||
await self._simulate_latency()
|
||
|
||
if session_id not in self._sessions:
|
||
raise SessionNotFoundError(session_id)
|
||
|
||
messages = self._messages.get(session_id, [])
|
||
return messages[offset : offset + limit]
|
||
|
||
# ─── User ─────────────────────────────────────────────────────────────────
|
||
|
||
async def get_or_create_user(
|
||
self,
|
||
external_id: str,
|
||
platform: str,
|
||
display_name: str | None = None,
|
||
) -> dict:
|
||
"""
|
||
Возвращает или создаёт пользователя платформы.
|
||
|
||
Returns:
|
||
{
|
||
"user_id": str,
|
||
"external_id": str,
|
||
"platform": str,
|
||
"display_name": str | None,
|
||
"created_at": ISO8601,
|
||
"is_new": bool,
|
||
}
|
||
"""
|
||
await self._simulate_latency()
|
||
|
||
# В моке генерируем детерминированный user_id
|
||
user_id = f"user-{platform}-{external_id}"
|
||
|
||
logger.info("User fetched", user_id=user_id, platform=platform)
|
||
return {
|
||
"user_id": user_id,
|
||
"external_id": external_id,
|
||
"platform": platform,
|
||
"display_name": display_name,
|
||
"created_at": "2025-01-01T00:00:00Z",
|
||
"is_new": False,
|
||
}
|
||
|
||
# ─── Internals ────────────────────────────────────────────────────────────
|
||
|
||
async def _simulate_latency(self, min_ms: int = 10, max_ms: int = 100) -> None:
|
||
"""Имитирует сетевую задержку для реалистичного тестирования."""
|
||
import random
|
||
delay = random.randint(min_ms, max_ms) / 1000
|
||
await asyncio.sleep(delay)
|
||
|
||
def get_stats(self) -> dict:
|
||
"""Возвращает статистику мока (для отладки)."""
|
||
return {
|
||
"active_sessions": sum(1 for s in self._sessions.values() if s["status"] == "active"),
|
||
"total_sessions": len(self._sessions),
|
||
"total_messages": sum(len(msgs) for msgs in self._messages.values()),
|
||
}
|