init: surfaces-bot — Telegram & Matrix prototype

- 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>
This commit is contained in:
Mikhail Putilovskij 2026-03-27 00:35:42 +03:00
commit b6df29bd9b
29 changed files with 2504 additions and 0 deletions

246
src/mock_platform.py Normal file
View file

@ -0,0 +1,246 @@
"""
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()),
}