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:
Mikhail Putilovskij 2026-03-29 00:48:19 +03:00
parent 944c383552
commit 36730ae716
27 changed files with 1315 additions and 3 deletions

13
conftest.py Normal file
View file

@ -0,0 +1,13 @@
import sys
from pathlib import Path
# Insert project root at position 0 so our local `platform/` package
# shadows the Python stdlib `platform` module.
root = str(Path(__file__).parent)
sys.path.insert(0, root)
# If stdlib platform was already cached, remove it so Python re-resolves
# to our local package.
stdlib_platform = sys.modules.get("platform")
if stdlib_platform is not None and not hasattr(stdlib_platform, "mock"):
del sys.modules["platform"]

52
core/auth.py Normal file
View file

@ -0,0 +1,52 @@
# core/auth.py
from __future__ import annotations
import structlog
from core.protocol import AuthFlow
from core.store import StateStore
logger = structlog.get_logger(__name__)
def _to_dict(flow: AuthFlow) -> dict:
return {
"user_id": flow.user_id,
"platform": flow.platform,
"state": flow.state,
"platform_user_id": flow.platform_user_id,
}
def _from_dict(d: dict) -> AuthFlow:
return AuthFlow(
user_id=d["user_id"],
platform=d["platform"],
state=d["state"],
platform_user_id=d.get("platform_user_id"),
)
class AuthManager:
def __init__(self, platform: object, store: StateStore) -> None:
self._store = store
async def start_flow(self, user_id: str, platform: str) -> AuthFlow:
flow = AuthFlow(user_id=user_id, platform=platform, state="pending")
await self._store.set(f"auth:{user_id}", _to_dict(flow))
return flow
async def confirm(self, user_id: str) -> AuthFlow:
"""В моке — автоматическое подтверждение. В реальном SDK — валидация кода."""
stored = await self._store.get(f"auth:{user_id}")
if not stored:
stored = {"user_id": user_id, "platform": "unknown", "state": "pending", "platform_user_id": None}
stored["state"] = "confirmed"
stored["platform_user_id"] = f"plt_{user_id}"
await self._store.set(f"auth:{user_id}", stored)
return _from_dict(stored)
async def is_authenticated(self, user_id: str) -> bool:
stored = await self._store.get(f"auth:{user_id}")
return stored is not None and stored.get("state") == "confirmed"

111
core/chat.py Normal file
View file

@ -0,0 +1,111 @@
# core/chat.py
from __future__ import annotations
from datetime import UTC, datetime
import structlog
from core.protocol import ChatContext
from core.store import StateStore
logger = structlog.get_logger(__name__)
def _to_dict(ctx: ChatContext) -> dict:
return {
"chat_id": ctx.chat_id,
"display_name": ctx.display_name,
"platform": ctx.platform,
"surface_ref": ctx.surface_ref,
"created_at": ctx.created_at.isoformat(),
"is_archived": ctx.is_archived,
}
def _from_dict(d: dict) -> ChatContext:
return ChatContext(
chat_id=d["chat_id"],
display_name=d["display_name"],
platform=d["platform"],
surface_ref=d["surface_ref"],
created_at=datetime.fromisoformat(d["created_at"]),
is_archived=d.get("is_archived", False),
)
class ChatManager:
"""
Управляет метаданными чатов (C1/C2/C3 в workspace пользователя).
НЕ управляет lifecycle контейнера это дело Master'а на стороне платформы.
"""
def __init__(self, platform: object, store: StateStore) -> None:
self._store = store
def _key(self, user_id: str, chat_id: str) -> str:
return f"chat:{user_id}:{chat_id}"
async def get_or_create(
self,
user_id: str,
chat_id: str,
platform: str,
surface_ref: str,
name: str | None = None,
) -> ChatContext:
key = self._key(user_id, chat_id)
stored = await self._store.get(key)
if stored:
return _from_dict(stored)
ctx = ChatContext(
chat_id=chat_id,
display_name=name or f"Чат {chat_id}",
platform=platform,
surface_ref=surface_ref,
created_at=datetime.now(UTC),
)
await self._store.set(key, _to_dict(ctx))
logger.info("Chat created", chat_id=chat_id, user_id=user_id)
return ctx
async def get(self, chat_id: str, user_id: str | None = None) -> ChatContext | None:
if user_id:
stored = await self._store.get(self._key(user_id, chat_id))
return _from_dict(stored) if stored else None
# Scan by chat_id suffix when user_id unknown (slower)
for key in await self._store.keys("chat:"):
if key.endswith(f":{chat_id}"):
stored = await self._store.get(key)
if stored:
return _from_dict(stored)
return None
async def rename(self, chat_id: str, name: str, user_id: str | None = None) -> ChatContext:
ctx = await self.get(chat_id, user_id)
if not ctx:
raise ValueError(f"Chat {chat_id} not found")
ctx.display_name = name
for key in await self._store.keys("chat:"):
if key.endswith(f":{chat_id}"):
await self._store.set(key, _to_dict(ctx))
break
return ctx
async def archive(self, chat_id: str, user_id: str | None = None) -> None:
ctx = await self.get(chat_id, user_id)
if not ctx:
raise ValueError(f"Chat {chat_id} not found")
ctx.is_archived = True
for key in await self._store.keys("chat:"):
if key.endswith(f":{chat_id}"):
await self._store.set(key, _to_dict(ctx))
break
async def list_active(self, user_id: str) -> list[ChatContext]:
chats = []
for key in await self._store.keys(f"chat:{user_id}:"):
stored = await self._store.get(key)
if stored and not stored.get("is_archived"):
chats.append(_from_dict(stored))
return chats

71
core/handler.py Normal file
View file

@ -0,0 +1,71 @@
# core/handler.py
from __future__ import annotations
from typing import Awaitable, Callable
import structlog
from core.auth import AuthManager
from core.chat import ChatManager
from core.protocol import (
IncomingCallback,
IncomingCommand,
IncomingEvent,
IncomingMessage,
OutgoingEvent,
)
from core.settings import SettingsManager
from platform.interface import PlatformClient
logger = structlog.get_logger(__name__)
HandlerFn = Callable[..., Awaitable[list[OutgoingEvent]]]
class EventDispatcher:
def __init__(
self,
platform: PlatformClient,
chat_mgr: ChatManager,
auth_mgr: AuthManager,
settings_mgr: SettingsManager,
) -> None:
self._platform = platform
self._chat_mgr = chat_mgr
self._auth_mgr = auth_mgr
self._settings_mgr = settings_mgr
self._handlers: dict[type, dict[str, HandlerFn]] = {
IncomingCommand: {},
IncomingMessage: {},
IncomingCallback: {},
}
def register(self, event_type: type, key: str, handler: HandlerFn) -> None:
self._handlers[event_type][key] = handler
async def dispatch(self, event: IncomingEvent) -> list[OutgoingEvent]:
event_type = type(event)
handlers = self._handlers.get(event_type, {})
key = self._routing_key(event)
handler = handlers.get(key) or handlers.get("*")
if handler is None:
logger.warning("No handler registered", event_type=event_type.__name__, key=key)
return []
return await handler(
event=event,
chat_mgr=self._chat_mgr,
auth_mgr=self._auth_mgr,
settings_mgr=self._settings_mgr,
platform=self._platform,
)
def _routing_key(self, event: IncomingEvent) -> str:
if isinstance(event, IncomingCommand):
return event.command
if isinstance(event, IncomingCallback):
return event.action
if isinstance(event, IncomingMessage) and event.attachments:
return event.attachments[0].type
return "*"

24
core/handlers/__init__.py Normal file
View file

@ -0,0 +1,24 @@
# core/handlers/__init__.py
from __future__ import annotations
from core.handler import EventDispatcher
from core.protocol import IncomingCallback, IncomingCommand, IncomingMessage
from core.handlers import callback, chat, message, settings, start
def register_all(dispatcher: EventDispatcher) -> None:
# Commands
dispatcher.register(IncomingCommand, "start", start.handle_start)
dispatcher.register(IncomingCommand, "new", chat.handle_new_chat)
dispatcher.register(IncomingCommand, "rename", chat.handle_rename)
dispatcher.register(IncomingCommand, "archive", chat.handle_archive)
dispatcher.register(IncomingCommand, "chats", chat.handle_list_chats)
dispatcher.register(IncomingCommand, "settings", settings.handle_settings)
# Messages — catch-all (audio falls back here until voice_handler registered)
dispatcher.register(IncomingMessage, "*", message.handle_message)
# Callbacks
dispatcher.register(IncomingCallback, "confirm", callback.handle_confirm)
dispatcher.register(IncomingCallback, "cancel", callback.handle_cancel)
dispatcher.register(IncomingCallback, "toggle_skill", callback.handle_toggle_skill)

25
core/handlers/callback.py Normal file
View file

@ -0,0 +1,25 @@
# core/handlers/callback.py
from __future__ import annotations
from core.protocol import IncomingCallback, OutgoingMessage, SettingsAction
async def handle_confirm(event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
action_id = event.payload.get("action_id", "unknown")
return [OutgoingMessage(chat_id=event.chat_id, text=f"Действие подтверждено (id: {action_id}).")]
async def handle_cancel(event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
action_id = event.payload.get("action_id", "unknown")
return [OutgoingMessage(chat_id=event.chat_id, text=f"Действие отменено (id: {action_id}).")]
async def handle_toggle_skill(event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
skill = event.payload.get("skill")
enabled = event.payload.get("enabled", True)
if not skill:
return [OutgoingMessage(chat_id=event.chat_id, text="Ошибка: не указан навык.")]
action = SettingsAction(action="toggle_skill", payload={"skill": skill, "enabled": enabled})
await settings_mgr.apply(event.user_id, action)
state = "включён" if enabled else "выключен"
return [OutgoingMessage(chat_id=event.chat_id, text=f"Навык {skill} {state}.")]

38
core/handlers/chat.py Normal file
View file

@ -0,0 +1,38 @@
# core/handlers/chat.py
from __future__ import annotations
from core.protocol import IncomingCommand, OutgoingMessage
async def handle_new_chat(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not await auth_mgr.is_authenticated(event.user_id):
return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")]
name = " ".join(event.args) if event.args else None
ctx = await chat_mgr.get_or_create(
user_id=event.user_id,
chat_id=event.chat_id,
platform=event.platform,
surface_ref=event.chat_id,
name=name,
)
return [OutgoingMessage(chat_id=event.chat_id, text=f"Создан чат: {ctx.display_name}")]
async def handle_rename(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not event.args:
return [OutgoingMessage(chat_id=event.chat_id, text="Укажите название: /rename Название")]
ctx = await chat_mgr.rename(event.chat_id, " ".join(event.args))
return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")]
async def handle_archive(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
await chat_mgr.archive(event.chat_id)
return [OutgoingMessage(chat_id=event.chat_id, text="Чат архивирован.")]
async def handle_list_chats(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
chats = await chat_mgr.list_active(event.user_id)
if not chats:
return [OutgoingMessage(chat_id=event.chat_id, text="Нет активных чатов.")]
lines = [f"{c.display_name} ({c.chat_id})" for c in chats]
return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]

29
core/handlers/message.py Normal file
View file

@ -0,0 +1,29 @@
# core/handlers/message.py
from __future__ import annotations
from core.protocol import IncomingMessage, OutgoingMessage, OutgoingTyping
async def handle_message(event: IncomingMessage, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
if not await auth_mgr.is_authenticated(event.user_id):
return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")]
# Voice slot fallback: audio attachment without registered voice_handler
if event.attachments and event.attachments[0].type == "audio":
return [OutgoingMessage(
chat_id=event.chat_id,
text="Голосовые сообщения скоро поддержим.",
parse_mode="plain",
)]
response = await platform.send_message(
user_id=event.user_id,
chat_id=event.chat_id,
text=event.text,
attachments=[],
)
return [
OutgoingTyping(chat_id=event.chat_id, is_typing=False),
OutgoingMessage(chat_id=event.chat_id, text=response.response, parse_mode="markdown"),
]

25
core/handlers/settings.py Normal file
View file

@ -0,0 +1,25 @@
# core/handlers/settings.py
from __future__ import annotations
from core.protocol import IncomingCommand, OutgoingMessage, OutgoingUI, UIButton
async def handle_settings(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
return [OutgoingUI(
chat_id=event.chat_id,
text="⚙️ Настройки",
buttons=[
UIButton(label="🔗 Коннекторы", action="settings_connectors", style="secondary"),
UIButton(label="🧩 Скиллы", action="settings_skills", style="secondary"),
UIButton(label="🧠 Личность", action="settings_soul", style="secondary"),
UIButton(label="🔒 Безопасность", action="settings_safety", style="secondary"),
UIButton(label="💳 Подписка", action="settings_plan", style="secondary"),
],
)]
async def handle_settings_skills(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
s = await settings_mgr.get(event.user_id)
lines = [("" if on else "") + f" {name}" for name, on in s.skills.items()]
text = "🧩 Скиллы\n\n" + ("\n".join(lines) or "Нет доступных скиллов")
return [OutgoingMessage(chat_id=event.chat_id, text=text)]

16
core/handlers/start.py Normal file
View file

@ -0,0 +1,16 @@
# core/handlers/start.py
from __future__ import annotations
from core.protocol import IncomingCommand, OutgoingMessage
async def handle_start(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
user = await platform.get_or_create_user(event.user_id, event.platform)
await auth_mgr.confirm(event.user_id)
name = user.display_name or event.user_id
text = (
f"Добро пожаловать, {name}! Я агент Lambda. Напишите что-нибудь чтобы начать."
if user.is_new
else f"С возвращением, {name}!"
)
return [OutgoingMessage(chat_id=event.chat_id, text=text)]

124
core/protocol.py Normal file
View file

@ -0,0 +1,124 @@
# core/protocol.py
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class Attachment:
type: str # "image" | "document" | "audio" | "video"
url: str | None = None
content: bytes | None = None
filename: str | None = None
mime_type: str | None = None
@dataclass
class IncomingMessage:
user_id: str
platform: str
chat_id: str
text: str
attachments: list[Attachment] = field(default_factory=list)
reply_to: str | None = None
@dataclass
class IncomingCommand:
user_id: str
platform: str
chat_id: str
command: str
args: list[str] = field(default_factory=list)
@dataclass
class IncomingCallback:
user_id: str
platform: str
chat_id: str
action: str
payload: dict = field(default_factory=dict)
@dataclass
class OutgoingMessage:
chat_id: str
text: str
parse_mode: str = "plain"
attachments: list[Attachment] = field(default_factory=list)
reply_to: str | None = None
@dataclass
class UIButton:
label: str
action: str
payload: dict = field(default_factory=dict)
style: str = "secondary" # "primary" | "danger" | "secondary"
@dataclass
class OutgoingUI:
chat_id: str
text: str
buttons: list[UIButton] = field(default_factory=list)
@dataclass
class OutgoingNotification:
chat_id: str
text: str
level: str = "info" # "info" | "warning" | "success" | "error"
@dataclass
class OutgoingTyping:
chat_id: str
is_typing: bool
@dataclass
class ChatContext:
chat_id: str
display_name: str
platform: str
surface_ref: str
created_at: datetime
is_archived: bool = False
@dataclass
class AuthFlow:
user_id: str
platform: str
state: str # "pending" | "code_sent" | "confirmed" | "failed"
platform_user_id: str | None = None
@dataclass
class ConfirmationRequest:
action_id: str
chat_id: str
description: str
risk_level: str # "low" | "medium" | "high"
expires_at: datetime
@dataclass
class SettingsAction:
action: str
payload: dict = field(default_factory=dict)
@dataclass
class PaymentRequired:
user_id: str
reason: str
current_plan: str
# Type aliases
IncomingEvent = IncomingMessage | IncomingCommand | IncomingCallback
OutgoingEvent = OutgoingMessage | OutgoingUI | OutgoingNotification | OutgoingTyping

29
core/settings.py Normal file
View file

@ -0,0 +1,29 @@
# core/settings.py
from __future__ import annotations
import structlog
from core.protocol import SettingsAction
from core.store import StateStore
from platform.interface import PlatformClient, UserSettings
logger = structlog.get_logger(__name__)
class SettingsManager:
def __init__(self, platform: PlatformClient, store: StateStore) -> None:
self._platform = platform
self._store = store
async def get(self, user_id: str) -> UserSettings:
cached = await self._store.get(f"settings:{user_id}")
if cached:
return UserSettings(**cached)
settings = await self._platform.get_settings(user_id)
await self._store.set(f"settings:{user_id}", settings.model_dump())
return settings
async def apply(self, user_id: str, action: SettingsAction) -> None:
await self._platform.update_settings(user_id, action)
await self._store.delete(f"settings:{user_id}") # invalidate cache
logger.info("Settings applied", user_id=user_id, action=action.action)

73
core/store.py Normal file
View file

@ -0,0 +1,73 @@
# core/store.py
from __future__ import annotations
import json
import sqlite3
from typing import Protocol
class StateStore(Protocol):
async def get(self, key: str) -> dict | None: ...
async def set(self, key: str, value: dict) -> None: ...
async def delete(self, key: str) -> None: ...
async def keys(self, prefix: str) -> list[str]: ...
class InMemoryStore:
def __init__(self) -> None:
self._data: dict[str, dict] = {}
async def get(self, key: str) -> dict | None:
return self._data.get(key)
async def set(self, key: str, value: dict) -> None:
self._data[key] = value
async def delete(self, key: str) -> None:
self._data.pop(key, None)
async def keys(self, prefix: str) -> list[str]:
return [k for k in self._data if k.startswith(prefix)]
class SQLiteStore:
def __init__(self, db_path: str) -> None:
self._db_path = db_path
self._init_db()
def _init_db(self) -> None:
conn = sqlite3.connect(self._db_path)
conn.execute(
"CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
)
conn.commit()
conn.close()
async def get(self, key: str) -> dict | None:
conn = sqlite3.connect(self._db_path)
row = conn.execute("SELECT value FROM kv WHERE key = ?", (key,)).fetchone()
conn.close()
return json.loads(row[0]) if row else None
async def set(self, key: str, value: dict) -> None:
conn = sqlite3.connect(self._db_path)
conn.execute(
"INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)",
(key, json.dumps(value, default=str)),
)
conn.commit()
conn.close()
async def delete(self, key: str) -> None:
conn = sqlite3.connect(self._db_path)
conn.execute("DELETE FROM kv WHERE key = ?", (key,))
conn.commit()
conn.close()
async def keys(self, prefix: str) -> list[str]:
conn = sqlite3.connect(self._db_path)
rows = conn.execute(
"SELECT key FROM kv WHERE key LIKE ?", (prefix + "%",)
).fetchall()
conn.close()
return [row[0] for row in rows]

View file

@ -135,11 +135,9 @@ Master решает: нужен ли новый контейнер, или ра
--- ---
## Открытые вопросы к Азамату (SDK) ## Открытые вопросы к команде платфрмы (SDK)
- [ ] Точный формат эндпоинта отправки сообщения — URL, поля - [ ] Точный формат эндпоинта отправки сообщения — URL, поля
- [ ] Как передавать вложения (файлы, изображения)? Через S3 pre-signed URL или напрямую? - [ ] Как передавать вложения (файлы, изображения)? Через S3 pre-signed URL или напрямую?
- [ ] Стриминговый ответ (SSE / WebSocket) или только sync? - [ ] Стриминговый ответ (SSE / WebSocket) или только sync?
- [ ] Как обрабатывается `chat_id` на стороне платформы — это имя директории (C1/C2) или наш произвольный идентификатор?
- [ ] Есть ли endpoint для получения истории чата или она хранится только в `history.db` внутри контейнера?
- [ ] Формат `SettingsAction` — совпадает с нашим или другой? - [ ] Формат `SettingsAction` — совпадает с нашим или другой?

0
platform/__init__.py Normal file
View file

59
platform/interface.py Normal file
View file

@ -0,0 +1,59 @@
# platform/interface.py
from __future__ import annotations
from datetime import datetime
from typing import Any, Protocol
from pydantic import BaseModel
class User(BaseModel):
user_id: str
external_id: str
platform: str
display_name: str | None = None
created_at: datetime
is_new: bool = False
class MessageResponse(BaseModel):
message_id: str
response: str
tokens_used: int
finished: bool
class UserSettings(BaseModel):
skills: dict[str, bool] = {}
connectors: dict[str, dict] = {}
soul: dict[str, str] = {}
safety: dict[str, bool] = {}
plan: dict[str, Any] = {}
class PlatformError(Exception):
def __init__(self, message: str, code: str = "PLATFORM_ERROR"):
super().__init__(message)
self.code = code
class PlatformClient(Protocol):
async def get_or_create_user(
self,
external_id: str,
platform: str,
display_name: str | None = None,
) -> User: ...
# Master manages container lifecycle — bot only sends user_id + chat_id.
async def send_message(
self,
user_id: str,
chat_id: str,
text: str,
attachments: list | None = None,
) -> MessageResponse: ...
async def get_settings(self, user_id: str) -> UserSettings: ...
async def update_settings(self, user_id: str, action: Any) -> None: ...

137
platform/mock.py Normal file
View 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)

View file

@ -29,6 +29,7 @@ dev = [
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["."]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100

38
tests/core/test_auth.py Normal file
View file

@ -0,0 +1,38 @@
# tests/core/test_auth.py
import pytest
from core.auth import AuthManager
from core.store import InMemoryStore
from platform.mock import MockPlatformClient
@pytest.fixture
def mgr():
return AuthManager(MockPlatformClient(), InMemoryStore())
async def test_not_authenticated_initially(mgr):
assert await mgr.is_authenticated("u1") is False
async def test_start_flow_returns_pending(mgr):
flow = await mgr.start_flow("u1", "telegram")
assert flow.state == "pending"
assert flow.user_id == "u1"
async def test_confirm_sets_confirmed(mgr):
await mgr.start_flow("u1", "telegram")
flow = await mgr.confirm("u1")
assert flow.state == "confirmed"
async def test_is_authenticated_after_confirm(mgr):
await mgr.start_flow("u1", "telegram")
await mgr.confirm("u1")
assert await mgr.is_authenticated("u1") is True
async def test_confirm_without_start_flow(mgr):
flow = await mgr.confirm("new_user")
assert flow.state == "confirmed"
assert await mgr.is_authenticated("new_user") is True

53
tests/core/test_chat.py Normal file
View file

@ -0,0 +1,53 @@
# tests/core/test_chat.py
import pytest
from core.chat import ChatManager
from core.store import InMemoryStore
from platform.mock import MockPlatformClient
@pytest.fixture
def mgr():
return ChatManager(MockPlatformClient(), InMemoryStore())
async def test_get_or_create_new_chat(mgr):
ctx = await mgr.get_or_create("u1", "C1", "telegram", "topic-123")
assert ctx.chat_id == "C1"
assert ctx.platform == "telegram"
assert ctx.is_archived is False
async def test_get_or_create_idempotent(mgr):
c1 = await mgr.get_or_create("u1", "C1", "telegram", "t1")
c2 = await mgr.get_or_create("u1", "C1", "telegram", "t1")
assert c1.chat_id == c2.chat_id
assert c1.display_name == c2.display_name
async def test_get_or_create_with_custom_name(mgr):
ctx = await mgr.get_or_create("u1", "C1", "telegram", "t1", name="Анализ рынка")
assert ctx.display_name == "Анализ рынка"
async def test_rename_chat(mgr):
await mgr.get_or_create("u1", "C1", "telegram", "t1")
ctx = await mgr.rename("C1", "Новое название")
assert ctx.display_name == "Новое название"
async def test_archive_chat(mgr):
await mgr.get_or_create("u1", "C1", "telegram", "t1")
await mgr.archive("C1")
ctx = await mgr.get("C1")
assert ctx is not None
assert ctx.is_archived is True
async def test_list_active_excludes_archived(mgr):
await mgr.get_or_create("u1", "C1", "telegram", "t1")
await mgr.get_or_create("u1", "C2", "telegram", "t2")
await mgr.archive("C2")
chats = await mgr.list_active("u1")
ids = [c.chat_id for c in chats]
assert "C1" in ids
assert "C2" not in ids

View file

@ -0,0 +1,85 @@
# tests/core/test_dispatcher.py
import pytest
from core.handler import EventDispatcher
from core.protocol import (
IncomingCommand, IncomingMessage, IncomingCallback,
OutgoingMessage, Attachment,
)
from core.chat import ChatManager
from core.auth import AuthManager
from core.settings import SettingsManager
from core.store import InMemoryStore
from platform.mock import MockPlatformClient
@pytest.fixture
def dispatcher():
platform = MockPlatformClient()
store = InMemoryStore()
return EventDispatcher(
platform=platform,
chat_mgr=ChatManager(platform, store),
auth_mgr=AuthManager(platform, store),
settings_mgr=SettingsManager(platform, store),
)
async def test_dispatch_command_to_handler(dispatcher):
called_with = {}
async def my_handler(event, **kwargs):
called_with["event"] = event
return [OutgoingMessage(chat_id=event.chat_id, text="ok")]
dispatcher.register(IncomingCommand, "ping", my_handler)
cmd = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="ping")
result = await dispatcher.dispatch(cmd)
assert called_with["event"] is cmd
assert result[0].text == "ok"
async def test_dispatch_unknown_command_returns_empty(dispatcher):
cmd = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="unknown")
result = await dispatcher.dispatch(cmd)
assert result == []
async def test_dispatch_message_to_catchall(dispatcher):
async def catch_all(event, **kwargs):
return [OutgoingMessage(chat_id=event.chat_id, text="caught")]
dispatcher.register(IncomingMessage, "*", catch_all)
msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="hello")
result = await dispatcher.dispatch(msg)
assert result[0].text == "caught"
async def test_dispatch_routes_audio_before_catchall(dispatcher):
async def audio_handler(event, **kwargs):
return [OutgoingMessage(chat_id=event.chat_id, text="audio")]
async def catch_all(event, **kwargs):
return [OutgoingMessage(chat_id=event.chat_id, text="text")]
dispatcher.register(IncomingMessage, "audio", audio_handler)
dispatcher.register(IncomingMessage, "*", catch_all)
audio_msg = IncomingMessage(
user_id="u1", platform="telegram", chat_id="C1", text="",
attachments=[Attachment(type="audio")],
)
text_msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="hi")
assert (await dispatcher.dispatch(audio_msg))[0].text == "audio"
assert (await dispatcher.dispatch(text_msg))[0].text == "text"
async def test_dispatch_callback_by_action(dispatcher):
async def confirm_handler(event, **kwargs):
return [OutgoingMessage(chat_id=event.chat_id, text="confirmed")]
dispatcher.register(IncomingCallback, "confirm", confirm_handler)
cb = IncomingCallback(user_id="u1", platform="telegram", chat_id="C1", action="confirm")
result = await dispatcher.dispatch(cb)
assert result[0].text == "confirmed"

View file

@ -0,0 +1,85 @@
# tests/core/test_integration.py
"""
Smoke test: полный цикл через dispatcher + реальные managers + MockPlatformClient.
Имитирует что делает адаптер (Telegram или Matrix) при получении события.
"""
import pytest
from platform.mock import MockPlatformClient
from core.store import InMemoryStore
from core.chat import ChatManager
from core.auth import AuthManager
from core.settings import SettingsManager
from core.handler import EventDispatcher
from core.handlers import register_all
from core.protocol import (
IncomingCommand, IncomingMessage, IncomingCallback,
OutgoingMessage, OutgoingUI,
Attachment, SettingsAction,
)
@pytest.fixture
def dispatcher():
platform = MockPlatformClient()
store = InMemoryStore()
d = EventDispatcher(
platform=platform,
chat_mgr=ChatManager(platform, store),
auth_mgr=AuthManager(platform, store),
settings_mgr=SettingsManager(platform, store),
)
register_all(d)
return d
async def test_full_flow_start_then_message(dispatcher):
start = IncomingCommand(user_id="tg_123", platform="telegram", chat_id="C1", command="start")
result = await dispatcher.dispatch(start)
assert any(isinstance(r, OutgoingMessage) for r in result)
msg = IncomingMessage(user_id="tg_123", platform="telegram", chat_id="C1", text="Привет!")
result = await dispatcher.dispatch(msg)
texts = [r.text for r in result if isinstance(r, OutgoingMessage)]
assert any("[MOCK]" in t for t in texts)
async def test_new_chat_command(dispatcher):
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
await dispatcher.dispatch(start)
new = IncomingCommand(user_id="u1", platform="matrix", chat_id="C2", command="new", args=["Анализ"])
result = await dispatcher.dispatch(new)
assert any("Анализ" in r.text for r in result if isinstance(r, OutgoingMessage))
async def test_settings_menu(dispatcher):
start = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="start")
await dispatcher.dispatch(start)
s = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="settings")
result = await dispatcher.dispatch(s)
assert any(isinstance(r, OutgoingUI) for r in result)
async def test_voice_message_fallback(dispatcher):
start = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="start")
await dispatcher.dispatch(start)
voice = IncomingMessage(
user_id="u1", platform="telegram", chat_id="C1", text="",
attachments=[Attachment(type="audio")],
)
result = await dispatcher.dispatch(voice)
assert any("голосов" in r.text.lower() for r in result if isinstance(r, OutgoingMessage))
async def test_toggle_skill_callback(dispatcher):
start = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="start")
await dispatcher.dispatch(start)
cb = IncomingCallback(
user_id="u1", platform="telegram", chat_id="C1",
action="toggle_skill", payload={"skill": "browser", "enabled": True},
)
result = await dispatcher.dispatch(cb)
assert any("browser" in r.text for r in result if isinstance(r, OutgoingMessage))

View file

@ -0,0 +1,42 @@
# tests/core/test_protocol.py
from datetime import datetime
from core.protocol import (
Attachment, IncomingMessage, IncomingCommand, IncomingCallback,
OutgoingMessage, OutgoingUI, OutgoingTyping, OutgoingNotification,
UIButton, ChatContext, AuthFlow, ConfirmationRequest, SettingsAction, PaymentRequired,
)
def test_incoming_message_defaults():
msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="hi")
assert msg.attachments == []
assert msg.reply_to is None
def test_attachment_audio():
a = Attachment(type="audio", filename="voice.ogg", mime_type="audio/ogg")
assert a.type == "audio"
assert a.url is None
def test_incoming_command_defaults():
cmd = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="new")
assert cmd.args == []
def test_outgoing_message_defaults():
msg = OutgoingMessage(chat_id="C1", text="hello")
assert msg.parse_mode == "plain"
assert msg.attachments == []
def test_ui_button_defaults():
btn = UIButton(label="OK", action="confirm")
assert btn.style == "secondary"
assert btn.payload == {}
def test_settings_action():
action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True})
assert action.action == "toggle_skill"
assert action.payload["skill"] == "browser"

View file

@ -0,0 +1,32 @@
# tests/core/test_settings.py
import pytest
from core.settings import SettingsManager
from core.store import InMemoryStore
from core.protocol import SettingsAction
from platform.mock import MockPlatformClient
@pytest.fixture
def mgr():
return SettingsManager(MockPlatformClient(), InMemoryStore())
async def test_get_returns_defaults(mgr):
settings = await mgr.get("u1")
assert "web-search" in settings.skills
async def test_apply_toggle_skill(mgr):
action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True})
await mgr.apply("u1", action)
settings = await mgr.get("u1")
assert settings.skills.get("browser") is True
async def test_apply_invalidates_cache(mgr):
s1 = await mgr.get("u1")
initial = s1.skills.get("browser", False)
action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": not initial})
await mgr.apply("u1", action)
s2 = await mgr.get("u1")
assert s2.skills.get("browser") == (not initial)

58
tests/core/test_store.py Normal file
View file

@ -0,0 +1,58 @@
# tests/core/test_store.py
from core.store import InMemoryStore, SQLiteStore
async def test_inmemory_get_missing_returns_none():
store = InMemoryStore()
assert await store.get("missing") is None
async def test_inmemory_set_and_get():
store = InMemoryStore()
await store.set("k", {"x": 1})
assert await store.get("k") == {"x": 1}
async def test_inmemory_delete():
store = InMemoryStore()
await store.set("k", {"x": 1})
await store.delete("k")
assert await store.get("k") is None
async def test_inmemory_keys_prefix():
store = InMemoryStore()
await store.set("chat:u1:C1", {"a": 1})
await store.set("chat:u1:C2", {"b": 2})
await store.set("auth:u1", {"c": 3})
keys = await store.keys("chat:u1:")
assert set(keys) == {"chat:u1:C1", "chat:u1:C2"}
async def test_sqlite_set_and_get(tmp_path):
store = SQLiteStore(str(tmp_path / "test.db"))
await store.set("k", {"hello": "world"})
assert await store.get("k") == {"hello": "world"}
async def test_sqlite_overwrite(tmp_path):
store = SQLiteStore(str(tmp_path / "test.db"))
await store.set("k", {"v": 1})
await store.set("k", {"v": 2})
assert await store.get("k") == {"v": 2}
async def test_sqlite_delete(tmp_path):
store = SQLiteStore(str(tmp_path / "test.db"))
await store.set("k", {"v": 1})
await store.delete("k")
assert await store.get("k") is None
async def test_sqlite_keys_prefix(tmp_path):
store = SQLiteStore(str(tmp_path / "test.db"))
await store.set("chat:u1:C1", {})
await store.set("chat:u1:C2", {})
await store.set("auth:u1", {})
keys = await store.keys("chat:u1:")
assert set(keys) == {"chat:u1:C1", "chat:u1:C2"}

View file

@ -0,0 +1,49 @@
# tests/core/test_voice_slot.py
import pytest
from core.protocol import IncomingMessage, Attachment, OutgoingMessage
from core.handlers.message import handle_message
from core.store import InMemoryStore
from core.auth import AuthManager
from core.chat import ChatManager
from core.settings import SettingsManager
from platform.mock import MockPlatformClient
@pytest.fixture
def deps():
platform = MockPlatformClient()
store = InMemoryStore()
auth_mgr = AuthManager(platform, store)
return dict(
platform=platform,
chat_mgr=ChatManager(platform, store),
auth_mgr=auth_mgr,
settings_mgr=SettingsManager(platform, store),
)
async def test_voice_message_returns_stub(deps):
await deps["auth_mgr"].confirm("u1")
msg = IncomingMessage(
user_id="u1", platform="telegram", chat_id="C1", text="",
attachments=[Attachment(type="audio", filename="voice.ogg")],
)
result = await handle_message(event=msg, **deps)
assert len(result) == 1
assert isinstance(result[0], OutgoingMessage)
assert "голосов" in result[0].text.lower()
async def test_text_message_calls_platform(deps):
await deps["auth_mgr"].confirm("u1")
msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="Привет!")
result = await handle_message(event=msg, **deps)
texts = [r.text for r in result if isinstance(r, OutgoingMessage)]
assert any("[MOCK]" in t for t in texts)
async def test_unauthenticated_user_gets_start_prompt(deps):
msg = IncomingMessage(user_id="new_user", platform="telegram", chat_id="C1", text="hello")
result = await handle_message(event=msg, **deps)
assert len(result) == 1
assert "/start" in result[0].text

View file

@ -0,0 +1,45 @@
# tests/platform/test_mock.py
from platform.mock import MockPlatformClient
from platform.interface import User, MessageResponse, UserSettings
from core.protocol import SettingsAction
async def test_get_or_create_user_returns_user():
client = MockPlatformClient()
user = await client.get_or_create_user("12345", "telegram", "Иван")
assert isinstance(user, User)
assert user.external_id == "12345"
assert user.platform == "telegram"
assert user.is_new is True
async def test_get_or_create_user_idempotent():
client = MockPlatformClient()
u1 = await client.get_or_create_user("42", "matrix")
u2 = await client.get_or_create_user("42", "matrix")
assert u1.user_id == u2.user_id
assert u2.is_new is False
async def test_send_message_returns_response():
client = MockPlatformClient()
user = await client.get_or_create_user("u1", "telegram")
result = await client.send_message(user.user_id, "C1", "Привет!")
assert isinstance(result, MessageResponse)
assert result.finished is True
assert len(result.response) > 0
async def test_get_settings_returns_defaults():
client = MockPlatformClient()
settings = await client.get_settings("usr-telegram-42")
assert isinstance(settings, UserSettings)
assert "web-search" in settings.skills
async def test_update_settings_toggle_skill():
client = MockPlatformClient()
action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True})
await client.update_settings("usr-1", action)
settings = await client.get_settings("usr-1")
assert settings.skills.get("browser") is True