feat: implement core/ and platform/ with full test coverage
- platform/interface.py: PlatformClient Protocol + Pydantic models (User, MessageResponse, UserSettings) — no explicit session management, Master handles container lifecycle - platform/mock.py: MockPlatformClient with simulated latency, [MOCK] responses, is_new correctly True only on first creation - core/protocol.py: unified dataclasses for all events and responses (IncomingMessage/Command/Callback, OutgoingMessage/UI/Notification, AuthFlow, ChatContext, SettingsAction, etc.) - core/store.py: StateStore Protocol + InMemoryStore (tests) + SQLiteStore (prod) with JSON serialization - core/chat.py: ChatManager — chat metadata (C1/C2/C3), not container lifecycle (that's the platform's job) - core/auth.py: AuthManager — start_flow / confirm / is_authenticated - core/settings.py: SettingsManager — get/apply with store cache - core/handler.py: EventDispatcher — registry-based routing with keys (command name, action name, attachment type, "*" catch-all) - core/handlers/: register_all() + start/new/message/callback/settings handlers; voice slot falls back to stub text until voice_handler added - conftest.py: sys.path fix so local platform/ shadows stdlib platform - docs/api-contract.md: rewritten for Lambda Lab 3.0 container model 46 tests passing, 0 warnings.
This commit is contained in:
parent
944c383552
commit
36730ae716
27 changed files with 1315 additions and 3 deletions
137
platform/mock.py
Normal file
137
platform/mock.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# platform/mock.py
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from platform.interface import MessageResponse, User, UserSettings
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class MockPlatformClient:
|
||||
"""
|
||||
Заглушка SDK платформы Lambda.
|
||||
|
||||
Реализует PlatformClient Protocol. При подключении реального SDK
|
||||
заменяется только этот файл — core/ и адаптеры не трогаются.
|
||||
|
||||
Ключевое отличие от реальной платформы: не управляет lifecycle контейнера.
|
||||
Master делает это сам при получении send_message.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._users: dict[str, dict] = {}
|
||||
self._messages: dict[str, list] = {} # "{user_id}:{chat_id}" → messages
|
||||
self._settings: dict[str, dict] = {}
|
||||
logger.info("MockPlatformClient initialized")
|
||||
|
||||
async def get_or_create_user(
|
||||
self,
|
||||
external_id: str,
|
||||
platform: str,
|
||||
display_name: str | None = None,
|
||||
) -> User:
|
||||
await self._latency()
|
||||
key = f"{platform}:{external_id}"
|
||||
is_new = key not in self._users
|
||||
if is_new:
|
||||
self._users[key] = {
|
||||
"user_id": f"usr-{platform}-{external_id}",
|
||||
"external_id": external_id,
|
||||
"platform": platform,
|
||||
"display_name": display_name,
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"is_new": True,
|
||||
}
|
||||
data = {**self._users[key], "is_new": is_new}
|
||||
return User(**data)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
user_id: str,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
attachments: list | 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)
|
||||
return MessageResponse(
|
||||
message_id=message_id,
|
||||
response=response,
|
||||
tokens_used=len(text.split()) * 2,
|
||||
finished=True,
|
||||
)
|
||||
|
||||
async def get_settings(self, user_id: str) -> UserSettings:
|
||||
await self._latency()
|
||||
stored = self._settings.get(user_id, {})
|
||||
return UserSettings(
|
||||
skills=stored.get("skills", {
|
||||
"web-search": True,
|
||||
"fetch-url": True,
|
||||
"email": False,
|
||||
"browser": False,
|
||||
"image-gen": False,
|
||||
"files": True,
|
||||
}),
|
||||
connectors=stored.get("connectors", {}),
|
||||
soul=stored.get("soul", {"name": "Лямбда", "style": "friendly"}),
|
||||
safety=stored.get("safety", {
|
||||
"email-send": True,
|
||||
"file-delete": True,
|
||||
"social-post": True,
|
||||
}),
|
||||
plan=stored.get("plan", {
|
||||
"name": "Beta",
|
||||
"tokens_used": 0,
|
||||
"tokens_limit": 1000,
|
||||
}),
|
||||
)
|
||||
|
||||
async def update_settings(self, user_id: str, action: Any) -> None:
|
||||
await self._latency()
|
||||
settings = self._settings.setdefault(user_id, {})
|
||||
|
||||
if action.action == "toggle_skill":
|
||||
skills = settings.setdefault("skills", {})
|
||||
skills[action.payload["skill"]] = action.payload.get("enabled", True)
|
||||
elif action.action == "set_soul":
|
||||
soul = settings.setdefault("soul", {})
|
||||
soul[action.payload["field"]] = action.payload["value"]
|
||||
elif action.action == "set_safety":
|
||||
safety = settings.setdefault("safety", {})
|
||||
safety[action.payload["trigger"]] = action.payload.get("enabled", True)
|
||||
|
||||
logger.info("Settings updated", user_id=user_id, action=action.action)
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
return {
|
||||
"total_users": len(self._users),
|
||||
"total_messages": sum(len(msgs) for msgs in self._messages.values()),
|
||||
}
|
||||
|
||||
async def _latency(self, min_ms: int = 10, max_ms: int = 80) -> None:
|
||||
await asyncio.sleep(random.randint(min_ms, max_ms) / 1000)
|
||||
Loading…
Add table
Add a link
Reference in a new issue