diff --git a/README.md b/README.md index aedfc16..23ae9ba 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,10 @@ surfaces-bot/ core/ — общее ядро, не зависит от транспорта protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...) - handler.py — EventDispatcher: IncomingEvent → OutgoingEvent - handlers/ — обработчики по типам событий - store.py — StateStore Protocol + InMemoryStore + SQLiteStore - chat.py — ChatManager: метаданные чатов C1/C2/C3 - auth.py — AuthManager: аутентификация - settings.py — SettingsManager: коннекторы, скиллы, SOUL, безопасность + handler.py — логика: IncomingEvent → OutgoingEvent + session.py — управление сессиями и чатами + auth.py — аутентификация + settings.py — коннекторы, скиллы, SOUL, безопасность adapter/ telegram/ — aiogram 3.x адаптер @@ -77,15 +75,14 @@ surfaces-bot/ ```python class PlatformClient(Protocol): - async def get_or_create_user(self, external_id: str, platform: str, ...) -> User: ... - async def send_message(self, user_id: str, chat_id: str, text: str, ...) -> MessageResponse: ... - async def get_settings(self, user_id: str) -> UserSettings: ... - async def update_settings(self, user_id: str, action: Any) -> None: ... + async def get_or_create_user(...) -> User: ... + async def create_session(...) -> Session: ... + async def send_message(...) -> AgentResponse: ... + async def close_session(...) -> None: ... + async def get_settings(...) -> UserSettings: ... + async def update_settings(...) -> None: ... ``` -Бот не управляет lifecycle контейнеров — это делает Master (платформа). -Бот передаёт `user_id` + `chat_id` + сообщение; платформа сама решает нужно ли поднять контейнер. - Сейчас: `MockPlatformClient` в `platform/mock.py`. Когда SDK готов: добавляем `SdkPlatformClient`, меняем одну строку в DI. Адаптеры и ядро не трогаем. diff --git a/conftest.py b/conftest.py deleted file mode 100644 index 60f4b07..0000000 --- a/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -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"] diff --git a/core/auth.py b/core/auth.py deleted file mode 100644 index 688f1cd..0000000 --- a/core/auth.py +++ /dev/null @@ -1,52 +0,0 @@ -# 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" diff --git a/core/chat.py b/core/chat.py deleted file mode 100644 index d05a274..0000000 --- a/core/chat.py +++ /dev/null @@ -1,111 +0,0 @@ -# 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 diff --git a/core/handler.py b/core/handler.py deleted file mode 100644 index f6dd5bd..0000000 --- a/core/handler.py +++ /dev/null @@ -1,71 +0,0 @@ -# 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 "*" diff --git a/core/handlers/__init__.py b/core/handlers/__init__.py deleted file mode 100644 index 9ed15d9..0000000 --- a/core/handlers/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -# 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) diff --git a/core/handlers/callback.py b/core/handlers/callback.py deleted file mode 100644 index 28b7659..0000000 --- a/core/handlers/callback.py +++ /dev/null @@ -1,25 +0,0 @@ -# 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}.")] diff --git a/core/handlers/chat.py b/core/handlers/chat.py deleted file mode 100644 index 8e32468..0000000 --- a/core/handlers/chat.py +++ /dev/null @@ -1,38 +0,0 @@ -# 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))] diff --git a/core/handlers/message.py b/core/handlers/message.py deleted file mode 100644 index e1475ef..0000000 --- a/core/handlers/message.py +++ /dev/null @@ -1,29 +0,0 @@ -# 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"), - ] diff --git a/core/handlers/settings.py b/core/handlers/settings.py deleted file mode 100644 index b1fae5d..0000000 --- a/core/handlers/settings.py +++ /dev/null @@ -1,25 +0,0 @@ -# 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)] diff --git a/core/handlers/start.py b/core/handlers/start.py deleted file mode 100644 index 578c899..0000000 --- a/core/handlers/start.py +++ /dev/null @@ -1,16 +0,0 @@ -# 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)] diff --git a/core/protocol.py b/core/protocol.py deleted file mode 100644 index 02a9f4a..0000000 --- a/core/protocol.py +++ /dev/null @@ -1,124 +0,0 @@ -# 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 diff --git a/core/settings.py b/core/settings.py deleted file mode 100644 index ba72a89..0000000 --- a/core/settings.py +++ /dev/null @@ -1,29 +0,0 @@ -# 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) diff --git a/core/store.py b/core/store.py deleted file mode 100644 index c1402bc..0000000 --- a/core/store.py +++ /dev/null @@ -1,73 +0,0 @@ -# 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] diff --git a/docs/api-contract.md b/docs/api-contract.md index 10fd899..a578f08 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -1,18 +1,11 @@ # API Contract — Lambda Platform -> **Статус:** ЧЕРНОВИК — проектируем сами, уточняем с Азаматом когда SDK будет готов -> **Последнее обновление:** 2026-03-29 +> **Статус:** ЧЕРНОВИК — проектируем сами, не ждём SDK +> **Автор:** @architect +> **Последнее обновление:** заполнить дату ---- - -## Архитектурный контекст - -Каждому пользователю выделяется **один LXC-контейнер** с workspace 10 ГБ. -Workspace содержит директории чатов: `C1/`, `C2/`, `C3/` — файлы + `history.db` в каждом. - -**Master** управляет lifecycle контейнера (запуск, заморозка, пробуждение). -Бот **не управляет lifecycle** — он передаёт `user_id` + `chat_id` + сообщение. -Master сам решает: нужно ли поднять контейнер, смонтировать нужный чат, запустить агента. +Это описание того, что нам нужно от платформы. +`MockPlatformClient` реализует этот контракт. При подключении реального SDK — только он меняется. --- @@ -35,7 +28,6 @@ Authorization: Bearer {SERVICE_TOKEN} ## Users ### GET /users/{external_id}?platform={platform} - Получает или создаёт пользователя. **Query params:** @@ -55,15 +47,50 @@ Authorization: Bearer {SERVICE_TOKEN} --- +## Sessions + +### POST /sessions +Создаёт новую сессию с AI-агентом. + +**Request:** +```json +{ + "user_id": "usr_abc123", + "platform": "telegram", + "context": {} +} +``` + +**Response 201:** +```json +{ + "session_id": "ses_xyz789", + "agent_id": "agt_def456", + "created_at": "2025-01-15T10:30:00Z", + "expires_at": "2025-01-16T10:30:00Z" +} +``` + +### GET /sessions/{session_id} +Получает информацию о сессии. + +**Response 200:** — см. структуру выше + поле `status: "active" | "closed"` +**Response 404:** `{"error": "SESSION_NOT_FOUND", "message": "..."}` + +### DELETE /sessions/{session_id} +Завершает сессию. + +**Response 200:** +```json +{"closed": true} +``` + +--- + ## Messages -Бот не управляет сессиями явно. Отправка сообщения — единственная операция. -Master решает: нужен ли новый контейнер, или разбудить существующий. - -### POST /users/{user_id}/chats/{chat_id}/messages - -Отправляет сообщение пользователя агенту. Master поднимает/размораживает контейнер, -монтирует нужный чат (`C1/`, `C2/`...), запускает агента. +### POST /sessions/{session_id}/messages +Отправляет сообщение и получает ответ агента. **Request:** ```json @@ -83,46 +110,28 @@ Master решает: нужен ли новый контейнер, или ра } ``` ---- - -## Settings - -### GET /users/{user_id}/settings - -Настройки пользователя: скиллы, коннекторы, SOUL, безопасность, план. +### GET /sessions/{session_id}/messages?limit=20&offset=0 +История сообщений сессии. **Response 200:** ```json -{ - "skills": {"web-search": true, "browser": false}, - "connectors": {"gmail": {"connected": true, "email": "user@gmail.com"}}, - "soul": {"name": "Лямбда", "style": "friendly"}, - "safety": {"email-send": true, "file-delete": true}, - "plan": {"name": "Beta", "tokens_used": 800, "tokens_limit": 1000} -} -``` - -### POST /users/{user_id}/settings - -Применяет действие над настройками. - -**Request:** -```json -{ - "action": "toggle_skill", - "payload": {"skill": "browser", "enabled": true} -} -``` - -**Response 200:** -```json -{"ok": true} +[ + { + "message_id": "msg_qwe012", + "user_text": "Привет", + "response": "Привет!", + "tokens_used": 42, + "created_at": "2025-01-15T10:31:00Z" + } +] ``` --- ## Error format +Все ошибки возвращаются в едином формате: + ```json { "error": "ERROR_CODE", @@ -131,13 +140,11 @@ Master решает: нужен ли новый контейнер, или ра } ``` -Коды ошибок: `USER_NOT_FOUND`, `RATE_LIMITED`, `PLATFORM_ERROR`, `CONTAINER_UNAVAILABLE` +Коды ошибок: `SESSION_NOT_FOUND`, `USER_NOT_FOUND`, `RATE_LIMITED`, `PLATFORM_ERROR` --- -## Открытые вопросы к команде платфрмы (SDK) +## TODO (открытые вопросы к команде платформы) -- [ ] Точный формат эндпоинта отправки сообщения — URL, поля -- [ ] Как передавать вложения (файлы, изображения)? Через S3 pre-signed URL или напрямую? -- [ ] Стриминговый ответ (SSE / WebSocket) или только sync? -- [ ] Формат `SettingsAction` — совпадает с нашим или другой? +- [ ] Нужна ли стриминговая передача ответа (SSE / WebSocket)? +- [ ] Как обрабатываются вложения (изображения, файлы)? \ No newline at end of file diff --git a/docs/matrix-prototype.md b/docs/matrix-prototype.md index 5e57c88..6e9b6bd 100644 --- a/docs/matrix-prototype.md +++ b/docs/matrix-prototype.md @@ -65,12 +65,12 @@ Space: «Lambda — {display_name}» 1. Пользователь пишет `!new` или `!new Анализ конкурентов` 2. Бот создаёт новую комнату в Space 3. Приглашает пользователя -4. Пишет приветствие; при первом сообщении платформа автоматически поднимает контейнер +4. Пишет приветствие и создаёт сессию на платформе 5. Пользователь переходит в новую комнату — начинает диалог ### В моке - Space и комнаты создаются реально через matrix-nio -- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...) +- Сессии — через MockPlatformClient - История хранится в Matrix нативно --- @@ -208,13 +208,15 @@ Matrix поддерживает реакции на сообщения (`m.react ### Статус и диагностика ``` -!status — состояние платформы и чатов +!status — состояние агента и платформы +!sessions — список активных сессий !whoami — текущий аккаунт платформы ``` ``` Статус: Платформа: ✅ доступна + Агент: ✅ активен (сессия #abc123) Аккаунт: user@lambda.lab Активных чатов: 3 ``` @@ -228,7 +230,7 @@ Matrix поддерживает реакции на сообщения (`m.react ↓ SpaceSetup → Idle (в комнате Настройки) ↓ - [новая комната] → ChatCreated → Idle (в чате) + [новая комната] → SessionCreated → Idle (в чате) ↓ ReceivingMessage → WaitingResponse → Idle ↓ diff --git a/docs/superpowers/plans/2026-03-29-core-implementation.md b/docs/superpowers/plans/2026-03-29-core-implementation.md deleted file mode 100644 index 10404e6..0000000 --- a/docs/superpowers/plans/2026-03-29-core-implementation.md +++ /dev/null @@ -1,1803 +0,0 @@ -# Core Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Реализовать `core/` и `platform/` — общее ядро для Telegram и Matrix ботов. - -**Architecture:** EventDispatcher в `core/handler.py` маршрутизирует входящие события по типу + ключу к изолированным обработчикам в `core/handlers/*.py`. Три менеджера (`ChatManager`, `AuthManager`, `SettingsManager`) инкапсулируют бизнес-логику и разделяют один `StateStore`. `PlatformClient` — единственная точка взаимодействия с Lambda платформой (сейчас — mock). - -**Tech Stack:** Python 3.11+, dataclasses, Pydantic v2, structlog, pytest + pytest-asyncio (asyncio_mode=auto), SQLite - ---- - -## Карта файлов - -| Файл | Действие | Ответственность | -|------|----------|----------------| -| `platform/interface.py` | Создать | PlatformClient Protocol + Pydantic модели | -| `platform/mock.py` | Создать (из src/) | MockPlatformClient — новый контракт | -| `platform/__init__.py` | Создать | пустой | -| `tests/platform/test_mock.py` | Создать (из tests/) | тесты mock под новый API | -| `core/__init__.py` | Создать | пустой | -| `core/protocol.py` | Создать | все dataclasses (IncomingEvent, OutgoingEvent, ...) | -| `core/store.py` | Создать | StateStore Protocol + InMemoryStore + SQLiteStore | -| `core/chat.py` | Создать | ChatManager — метаданные чатов C1/C2/C3 | -| `core/auth.py` | Создать | AuthManager — auth state machine | -| `core/settings.py` | Создать | SettingsManager — скиллы, коннекторы и т.д. | -| `core/handler.py` | Создать | EventDispatcher — только маршрутизация | -| `core/handlers/__init__.py` | Создать | register_all() — регистрация обработчиков | -| `core/handlers/start.py` | Создать | /start — auth + приветствие | -| `core/handlers/message.py` | Создать | текстовое сообщение + voice fallback | -| `core/handlers/chat.py` | Создать | /new, /rename, /archive, /chats | -| `core/handlers/callback.py` | Создать | confirm, cancel, toggle_skill | -| `core/handlers/settings.py` | Создать | /settings + подменю | -| `tests/core/test_protocol.py` | Создать | smoke-тесты dataclasses | -| `tests/core/test_store.py` | Создать | InMemoryStore + SQLiteStore | -| `tests/core/test_chat.py` | Создать | ChatManager | -| `tests/core/test_auth.py` | Создать | AuthManager | -| `tests/core/test_dispatcher.py` | Создать | EventDispatcher маршрутизация | -| `tests/core/test_voice_slot.py` | Создать | voice fallback behaviour | -| `src/` | Удалить | после переноса в platform/ | - ---- - -## Task 1: Migrate src/ → platform/ - -**Files:** -- Create: `platform/__init__.py` -- Create: `platform/interface.py` -- Create: `platform/mock.py` -- Create: `tests/platform/__init__.py` -- Create: `tests/platform/test_mock.py` -- Delete: `src/` - -- [ ] **Step 1: Создать `platform/__init__.py` и `tests/platform/__init__.py`** - -```bash -mkdir -p platform tests/platform tests/core -touch platform/__init__.py tests/platform/__init__.py tests/core/__init__.py -``` - -- [ ] **Step 2: Создать `platform/interface.py`** - -```python -# 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: ... -``` - -- [ ] **Step 3: Создать `platform/mock.py`** - -```python -# platform/mock.py -from __future__ import annotations - -import asyncio -import random -import uuid -from datetime import datetime -from typing import Any - -import structlog - -from platform.interface import MessageResponse, PlatformError, 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}" - if key not in self._users: - 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] - 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.utcnow().isoformat() + "Z", - }) - - 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) -``` - -- [ ] **Step 4: Создать `tests/platform/test_mock.py`** - -```python -# tests/platform/test_mock.py -import pytest -from platform.mock import MockPlatformClient -from platform.interface import User, MessageResponse, UserSettings -from core.protocol import SettingsAction - - -@pytest.fixture -def client(): - return MockPlatformClient() - - -async def test_get_or_create_user_returns_user(client): - 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): - 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 - - -async def test_send_message_returns_response(client): - 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): - 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): - uid = "usr-1" - action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}) - await client.update_settings(uid, action) - settings = await client.get_settings(uid) - assert settings.skills.get("browser") is True -``` - -- [ ] **Step 5: Запустить тесты — убедиться что проходят** - -```bash -cd /Users/a/MAI/sem2/lambda/surfaces-bot -uv run pytest tests/platform/ -v -``` - -Ожидаем: 5 PASSED - -- [ ] **Step 6: Удалить `src/`** - -```bash -rm -rf src/ -``` - -- [ ] **Step 7: Убедиться что старые тесты не сломали ничего нового** - -```bash -uv run pytest tests/platform/ -v -``` - -Ожидаем: 5 PASSED (старый `tests/test_mock_platform.py` больше не нужен — удалить) - -```bash -rm tests/test_mock_platform.py -``` - -- [ ] **Step 8: Commit** - -```bash -git add platform/ tests/platform/ tests/core/ -git rm -r src/ tests/test_mock_platform.py -git commit -m "feat: migrate src/ to platform/ with new PlatformClient contract" -``` - ---- - -## Task 2: core/protocol.py - -**Files:** -- Create: `core/__init__.py` -- Create: `core/protocol.py` -- Create: `tests/core/test_protocol.py` - -- [ ] **Step 1: Написать тест** - -```python -# 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" -``` - -- [ ] **Step 2: Запустить — убедиться что FAIL** - -```bash -uv run pytest tests/core/test_protocol.py -v -``` - -Ожидаем: `ImportError: No module named 'core'` - -- [ ] **Step 3: Создать `core/__init__.py` и `core/protocol.py`** - -```bash -mkdir -p core/handlers -touch core/__init__.py core/handlers/__init__.py -``` - -```python -# 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 -``` - -- [ ] **Step 4: Запустить тесты** - -```bash -uv run pytest tests/core/test_protocol.py -v -``` - -Ожидаем: 6 PASSED - -- [ ] **Step 5: Commit** - -```bash -git add core/ tests/core/test_protocol.py -git commit -m "feat: add core/protocol.py — IncomingEvent and OutgoingEvent dataclasses" -``` - ---- - -## Task 3: core/store.py - -**Files:** -- Create: `core/store.py` -- Create: `tests/core/test_store.py` - -- [ ] **Step 1: Написать тест** - -```python -# tests/core/test_store.py -import pytest -import tempfile -import os -from core.store import InMemoryStore, SQLiteStore - - -# ── InMemoryStore ────────────────────────────────────────────────────────────── - -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"} - - -# ── SQLiteStore ──────────────────────────────────────────────────────────────── - -@pytest.fixture -def db_path(tmp_path): - return str(tmp_path / "test.db") - - -async def test_sqlite_set_and_get(db_path): - store = SQLiteStore(db_path) - await store.set("k", {"hello": "world"}) - assert await store.get("k") == {"hello": "world"} - - -async def test_sqlite_overwrite(db_path): - store = SQLiteStore(db_path) - await store.set("k", {"v": 1}) - await store.set("k", {"v": 2}) - assert await store.get("k") == {"v": 2} - - -async def test_sqlite_delete(db_path): - store = SQLiteStore(db_path) - await store.set("k", {"v": 1}) - await store.delete("k") - assert await store.get("k") is None - - -async def test_sqlite_keys_prefix(db_path): - store = SQLiteStore(db_path) - 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"} -``` - -- [ ] **Step 2: Запустить — убедиться что FAIL** - -```bash -uv run pytest tests/core/test_store.py -v -``` - -Ожидаем: `ImportError: cannot import name 'InMemoryStore' from 'core.store'` - -- [ ] **Step 3: Создать `core/store.py`** - -```python -# 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] -``` - -- [ ] **Step 4: Запустить тесты** - -```bash -uv run pytest tests/core/test_store.py -v -``` - -Ожидаем: 9 PASSED - -- [ ] **Step 5: Commit** - -```bash -git add core/store.py tests/core/test_store.py -git commit -m "feat: add core/store.py — StateStore, InMemoryStore, SQLiteStore" -``` - ---- - -## Task 4: core/chat.py — ChatManager - -**Files:** -- Create: `core/chat.py` -- Create: `tests/core/test_chat.py` - -- [ ] **Step 1: Написать тест** - -```python -# 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 -``` - -- [ ] **Step 2: Запустить — убедиться что FAIL** - -```bash -uv run pytest tests/core/test_chat.py -v -``` - -Ожидаем: `ImportError: cannot import name 'ChatManager' from 'core.chat'` - -- [ ] **Step 3: Создать `core/chat.py`** - -```python -# core/chat.py -from __future__ import annotations - -from datetime import 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.utcnow(), - ) - 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: - # Try direct key if user_id provided - 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 (slower, use only when user_id unknown) - keys = await self._store.keys("chat:") - for key in keys: - 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 - # Resolve key - keys = await self._store.keys("chat:") - for key in keys: - 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 - keys = await self._store.keys("chat:") - for key in keys: - 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]: - keys = await self._store.keys(f"chat:{user_id}:") - chats = [] - for key in keys: - stored = await self._store.get(key) - if stored and not stored.get("is_archived"): - chats.append(_from_dict(stored)) - return chats -``` - -- [ ] **Step 4: Запустить тесты** - -```bash -uv run pytest tests/core/test_chat.py -v -``` - -Ожидаем: 6 PASSED - -- [ ] **Step 5: Commit** - -```bash -git add core/chat.py tests/core/test_chat.py -git commit -m "feat: add core/chat.py — ChatManager for chat metadata (C1/C2/C3)" -``` - ---- - -## Task 5: core/auth.py — AuthManager - -**Files:** -- Create: `core/auth.py` -- Create: `tests/core/test_auth.py` - -- [ ] **Step 1: Написать тест** - -```python -# 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): - # Mock auto-confirms even without explicit start_flow - flow = await mgr.confirm("u1") - assert flow.state == "confirmed" - assert await mgr.is_authenticated("u1") is True -``` - -- [ ] **Step 2: Запустить — убедиться что FAIL** - -```bash -uv run pytest tests/core/test_auth.py -v -``` - -Ожидаем: `ImportError` - -- [ ] **Step 3: Создать `core/auth.py`** - -```python -# core/auth.py -from __future__ import annotations - -import structlog - -from core.protocol import AuthFlow -from core.store import StateStore - -logger = structlog.get_logger(__name__) - - -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" - - -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"), - ) -``` - -- [ ] **Step 4: Запустить тесты** - -```bash -uv run pytest tests/core/test_auth.py -v -``` - -Ожидаем: 5 PASSED - -- [ ] **Step 5: Commit** - -```bash -git add core/auth.py tests/core/test_auth.py -git commit -m "feat: add core/auth.py — AuthManager state machine" -``` - ---- - -## Task 6: core/settings.py — SettingsManager - -**Files:** -- Create: `core/settings.py` -- Create: `tests/core/test_settings.py` - -- [ ] **Step 1: Написать тест** - -```python -# 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): - # First fetch (caches) - s1 = await mgr.get("u1") - initial = s1.skills.get("browser", False) - - # Toggle - action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": not initial}) - await mgr.apply("u1", action) - - # Cache must be invalid — next get fetches fresh - s2 = await mgr.get("u1") - assert s2.skills.get("browser") == (not initial) -``` - -- [ ] **Step 2: Запустить — убедиться что FAIL** - -```bash -uv run pytest tests/core/test_settings.py -v -``` - -Ожидаем: `ImportError` - -- [ ] **Step 3: Создать `core/settings.py`** - -```python -# 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) -``` - -- [ ] **Step 4: Запустить тесты** - -```bash -uv run pytest tests/core/test_settings.py -v -``` - -Ожидаем: 3 PASSED - -- [ ] **Step 5: Commit** - -```bash -git add core/settings.py tests/core/test_settings.py -git commit -m "feat: add core/settings.py — SettingsManager with cache invalidation" -``` - ---- - -## Task 7: core/handler.py — EventDispatcher - -**Files:** -- Create: `core/handler.py` -- Create: `tests/core/test_dispatcher.py` - -- [ ] **Step 1: Написать тест** - -```python -# 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 platform(): - return MockPlatformClient() - - -@pytest.fixture -def dispatcher(platform): - 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, chat_mgr, auth_mgr, settings_mgr, platform): - 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 len(result) == 1 - 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_by_attachment_type(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" -``` - -- [ ] **Step 2: Запустить — убедиться что FAIL** - -```bash -uv run pytest tests/core/test_dispatcher.py -v -``` - -Ожидаем: `ImportError` - -- [ ] **Step 3: Создать `core/handler.py`** - -```python -# 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 "*" -``` - -- [ ] **Step 4: Запустить тесты** - -```bash -uv run pytest tests/core/test_dispatcher.py -v -``` - -Ожидаем: 5 PASSED - -- [ ] **Step 5: Commit** - -```bash -git add core/handler.py tests/core/test_dispatcher.py -git commit -m "feat: add core/handler.py — EventDispatcher routing by type+key" -``` - ---- - -## Task 8: core/handlers/ — все обработчики - -**Files:** -- Create: `core/handlers/start.py` -- Create: `core/handlers/message.py` -- Create: `core/handlers/chat.py` -- Create: `core/handlers/callback.py` -- Create: `core/handlers/settings.py` -- Create: `tests/core/test_voice_slot.py` - -- [ ] **Step 1: Написать тест voice slot** - -```python -# 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): - # Authenticate user first - 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 -``` - -- [ ] **Step 2: Запустить — убедиться что FAIL** - -```bash -uv run pytest tests/core/test_voice_slot.py -v -``` - -Ожидаем: `ImportError` - -- [ ] **Step 3: Создать `core/handlers/message.py`** - -```python -# 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"), - ] -``` - -- [ ] **Step 4: Запустить voice slot тесты** - -```bash -uv run pytest tests/core/test_voice_slot.py -v -``` - -Ожидаем: 3 PASSED - -- [ ] **Step 5: Создать остальные обработчики** - -```python -# 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)] -``` - -```python -# 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))] -``` - -```python -# 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}.")] -``` - -```python -# 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", payload={}, style="secondary"), - UIButton(label="🧩 Скиллы", action="settings_skills", payload={}, style="secondary"), - UIButton(label="🧠 Личность", action="settings_soul", payload={}, style="secondary"), - UIButton(label="🔒 Безопасность", action="settings_safety", payload={}, style="secondary"), - UIButton(label="💳 Подписка", action="settings_plan", payload={}, 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)] -``` - -- [ ] **Step 6: Обновить `core/handlers/__init__.py` — зарегистрировать все обработчики** - -```python -# 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 (voice falls back here when no 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) -``` - -- [ ] **Step 7: Запустить все тесты** - -```bash -uv run pytest tests/ -v -``` - -Ожидаем: все PASSED - -- [ ] **Step 8: Commit** - -```bash -git add core/handlers/ tests/core/test_voice_slot.py -git commit -m "feat: add core/handlers/ — start, message, chat, callback, settings + voice slot" -``` - ---- - -## Task 9: Integration smoke test - -**Files:** -- Create: `tests/core/test_integration.py` - -- [ ] **Step 1: Написать интеграционный тест** - -```python -# 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, OutgoingTyping, - 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 - 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)) -``` - -- [ ] **Step 2: Запустить интеграционный тест** - -```bash -uv run pytest tests/core/test_integration.py -v -``` - -Ожидаем: 5 PASSED - -- [ ] **Step 3: Запустить все тесты финально** - -```bash -uv run pytest tests/ -v --tb=short -``` - -Ожидаем: все PASSED, 0 errors - -- [ ] **Step 4: Final commit** - -```bash -git add tests/core/test_integration.py -git commit -m "test: add integration smoke test for full dispatcher flow" -``` diff --git a/docs/superpowers/specs/2026-03-28-core-design.md b/docs/superpowers/specs/2026-03-28-core-design.md deleted file mode 100644 index bdeb7f1..0000000 --- a/docs/superpowers/specs/2026-03-28-core-design.md +++ /dev/null @@ -1,257 +0,0 @@ -# Core Design — surfaces-bot - -> **Статус:** approved -> **Дата:** 2026-03-28 -> **Подход:** Registry + Handlers (Подход 2) - ---- - -## Контекст - -Два бота (Telegram, Matrix) разделяют общую бизнес-логику через `core/`. -Цель — написать логику один раз, легко добавлять новые поверхности и новый функционал -без правки центральных файлов. - -### Архитектура платформы (важно для дизайна) - -Lambda Lab 3.0 выделяет каждому пользователю **один LXC-контейнер** с workspace 10 ГБ. -Workspace содержит директории чатов: `C1/`, `C2/`, `C3/` — каждый чат хранит свои файлы и `history.db`. - -**Master** — управляющий процесс платформы — сам решает когда поднять, заморозить или разбудить контейнер. -Бот не управляет lifecycle контейнера — он только передаёт сообщение (`user_id`, `chat_id`, `text`). - -Следствие: **"сессия" и "чат" — разные понятия.** -- Чат (C1/C2/C3) — наша забота, храним метаданные в `StateStore` -- Контейнер (сессия платформы) — забота Master'а, бот об этом не знает - ---- - -## Структура файлов - -``` -core/ - __init__.py - protocol.py — dataclasses: IncomingMessage, IncomingCommand, IncomingCallback, - OutgoingMessage, OutgoingUI, OutgoingNotification, OutgoingTyping, - Attachment, ChatContext, AuthFlow, ConfirmationRequest, - SettingsAction, PaymentRequired - handler.py — EventDispatcher (только маршрутизация, без бизнес-логики) - store.py — StateStore Protocol + InMemoryStore + SQLiteStore - chat.py — ChatManager (бывший SessionManager — управляет чатами, не контейнерами) - auth.py — AuthManager - settings.py — SettingsManager - handlers/ - __init__.py — регистрация всех обработчиков в dispatcher - start.py — /start, !start → AuthFlow + приветствие - chat.py — /new, /rename, /archive, /chats - message.py — текстовое сообщение + voice slot - callback.py — confirm/cancel ConfirmationRequest, toggle_skill - settings.py — все SettingsAction (connectors, skills, soul, safety, plan) - auth.py — auth state machine (pending → confirmed → failed) - -platform/ - interface.py — PlatformClient Protocol + Pydantic модели (User, Session, MessageResponse) - mock.py — MockPlatformClient - -tests/ - core/ - test_dispatcher.py - test_session.py - test_auth.py - test_settings.py - test_voice_slot.py - adapter/ - telegram/ - matrix/ - platform/ - test_mock_platform.py — переезжает из tests/test_mock_platform.py -``` - -**Миграция `src/`:** при реализации — первый шаг: -- `src/shared/models.py` → объединить с `platform/interface.py` -- `src/mock_platform.py` → `platform/mock.py` -- `src/` удалить - ---- - -## EventDispatcher - -`handler.py` — тупой роутер. Маршрутизирует по двум ключам: тип события + команда/действие. - -```python -# Сигнатура регистрации -dispatcher.register(IncomingCommand, "start", start_handler) -dispatcher.register(IncomingCommand, "new", chat_handler.new_chat) -dispatcher.register(IncomingCommand, "archive", chat_handler.archive) -dispatcher.register(IncomingMessage, "*", message_handler) # catch-all -dispatcher.register(IncomingMessage, "audio", voice_handler) # voice slot -dispatcher.register(IncomingCallback, "confirm", callback_handler.confirm) -dispatcher.register(IncomingCallback, "toggle_skill",callback_handler.toggle_skill) - -# Сигнатура любого обработчика -async def handle_xxx( - event: IncomingEvent, - session_mgr: SessionManager, - auth_mgr: AuthManager, - settings_mgr: SettingsManager, - platform: PlatformClient, -) -> list[OutgoingEvent]: - ... -``` - -**Правила маршрутизации:** -- `IncomingCommand` → ключ = `event.command` -- `IncomingCallback` → ключ = `event.action` -- `IncomingMessage` → ключ = тип первого вложения если есть (`"audio"`, `"image"`, ...), иначе `"*"` - -**Добавить новый обработчик** = новый файл в `handlers/` + одна строка `dispatcher.register(...)`. -`handler.py` не трогается. - ---- - -## Голосовые сообщения (voice slot) - -`IncomingMessage` несёт `Attachment(type="audio", ...)`. -Dispatcher маршрутизирует `IncomingMessage` с audio-вложением на `"audio"` ключ. - -**Fallback:** если ключ `"audio"` не зарегистрирован — dispatcher падает на `"*"` catch-all. -`message_handler` (catch-all) проверяет тип вложения и возвращает заглушку: -```python -OutgoingMessage(chat_id=..., text="Голосовые сообщения скоро поддержим.", parse_mode="plain") -``` - -Когда будет готова транскрипция — регистрируем `voice_handler` для ключа `"audio"`. -Dispatcher перестаёт отдавать audio в catch-all. `message_handler` не меняется. - ---- - -## protocol.py — модели данных - -Используем `dataclass` (не Pydantic) — это внутренний язык ядра, валидация не нужна: - -```python -@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 Attachment: - type: str # "image" | "document" | "audio" | "video" - url: str | None = None - content: bytes | None = None - filename: str | None = None - mime_type: str | None = None -``` - -Полный список структур: IncomingMessage, IncomingCommand, IncomingCallback, -OutgoingMessage, OutgoingUI, OutgoingNotification, OutgoingTyping, UIButton, -Attachment, ChatContext, AuthFlow, ConfirmationRequest, SettingsAction, PaymentRequired. - ---- - -## platform/interface.py — модели платформы - -Используем Pydantic — это граница с внешним API где валидация нужна: - -```python -class User(BaseModel): - user_id: str - external_id: str - platform: str - display_name: str | None = None - created_at: datetime - is_new: bool = False - -class PlatformClient(Protocol): - async def get_or_create_user(self, external_id: str, platform: str, display_name: str | None) -> User: ... - - # Master сам решает: поднять контейнер, разбудить, смонтировать нужный чат. - # Бот передаёт только user_id + chat_id — платформа делает остальное. - async def send_message(self, user_id: str, chat_id: str, text: str, attachments: list) -> MessageResponse: ... - - async def get_settings(self, user_id: str) -> UserSettings: ... - async def update_settings(self, user_id: str, action: SettingsAction) -> None: ... -``` - -**Нет явных create/close session** — lifecycle контейнера управляется Master'ом, не ботом. -Когда будет готов реальный SDK Азамата — контракт уточняется, меняется только `platform/mock.py`. - ---- - -## Managers - -Все три менеджера принимают зависимости через конструктор (DI), разделяют один `StateStore`. - -### StateStore (`core/store.py`) - -```python -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 -``` - -Реализации: `InMemoryStore` (тесты), `SQLiteStore` (прод). - -### ChatManager (`core/chat.py`) - -Управляет метаданными чатов (C1/C2/C3). НЕ управляет lifecycle контейнера — это дело Master'а. - -```python -class ChatManager: - def __init__(self, platform: PlatformClient, store: StateStore): ... - async def get_or_create(self, user_id: str, chat_id: str, name: str | None) -> ChatContext: ... - async def rename(self, chat_id: str, name: str) -> ChatContext: ... - async def archive(self, chat_id: str) -> None: ... - async def list_active(self, user_id: str) -> list[ChatContext]: ... -``` - -Метаданные (display_name, platform, surface_ref, is_archived) хранятся в `StateStore`. -Файлы чата и history.db живут в workspace контейнера на стороне платформы — бот их не хранит. - -### AuthManager (`core/auth.py`) - -```python -class AuthManager: - def __init__(self, platform: PlatformClient, store: StateStore): ... - async def start_flow(self, user_id: str, platform: str) -> AuthFlow: ... - async def confirm(self, user_id: str) -> AuthFlow: ... # в моке — автоматически - async def is_authenticated(self, user_id: str) -> bool: ... -``` - -### SettingsManager (`core/settings.py`) - -```python -class SettingsManager: - def __init__(self, platform: PlatformClient, store: StateStore): ... - async def apply(self, user_id: str, action: SettingsAction) -> None: ... - async def get(self, user_id: str) -> UserSettings: ... -``` - ---- - -## Тестирование - -| Слой | Что тестируем | Зависимости | -|------|--------------|-------------| -| `tests/core/` | обработчики, менеджеры, dispatcher | MockPlatformClient + InMemoryStore | -| `tests/adapter/telegram/` | конвертацию aiogram → IncomingEvent | без платформы | -| `tests/adapter/matrix/` | конвертацию matrix-nio → IncomingEvent | без платформы | -| `tests/platform/` | MockPlatformClient | без ядра | - -**Voice slot test:** `IncomingMessage` с `audio`-вложением при незарегистрированном `voice_handler` -возвращает `OutgoingMessage` с текстом-заглушкой. После регистрации — маршрутизируется корректно. - ---- - -## Принципы расширяемости - -1. **Новая команда** — новый файл `handlers/xxx.py` + `dispatcher.register(...)` -2. **Новый тип вложения** — новый ключ в register, остальное не меняется -3. **Новая поверхность** — новый `adapter/xxx/` с конвертером, core/ не трогается -4. **Замена mock на SDK** — только `platform/mock.py`, всё остальное не меняется diff --git a/docs/surface-protocol.md b/docs/surface-protocol.md index ca66000..eb2069b 100644 --- a/docs/surface-protocol.md +++ b/docs/surface-protocol.md @@ -23,12 +23,10 @@ Surface Protocol — это общий язык между адаптерами surfaces-bot/ core/ protocol.py — все унифицированные структуры данных - handler.py — EventDispatcher: IncomingEvent → OutgoingEvent - handlers/ — обработчики по типам событий (start, message, chat, settings, callback) - store.py — StateStore Protocol + InMemoryStore + SQLiteStore - chat.py — ChatManager: метаданные чатов C1/C2/C3 - auth.py — AuthManager, AuthFlow - settings.py — SettingsManager, SettingsAction + handler.py — логика: IncomingEvent → OutgoingEvent + session.py — управление сессиями и чатами + auth.py — AuthFlow + settings.py — SettingsAction, управление настройками adapter/ telegram/ — aiogram адаптер @@ -37,6 +35,7 @@ surfaces-bot/ matrix/ — matrix-nio адаптер converter.py — matrix-nio Event → IncomingEvent, OutgoingEvent → Matrix API bot.py — точка входа, клиент + _template.py — шаблон для новой поверхности platform/ interface.py — Protocol: PlatformClient @@ -170,15 +169,16 @@ class OutgoingTyping: Унифицированные события для управления чатами и подключением. ### ChatContext -Метаданные чата — общие для всех поверхностей. Хранятся ботом, lifecycle контейнера управляет платформа (Master). +Состояние чата — общее для всех поверхностей. ```python @dataclass class ChatContext: - chat_id: str # "C1" | "C2" — ID чата в воркспейсе платформы + chat_id: str # "C1" | "C2" — ID в воркспейсе платформы display_name: str # «Чат 1» | «Анализ рынка» platform: str surface_ref: str # room_id в Matrix | topic_id в Telegram + session_id: str | None # активная сессия платформы created_at: datetime is_archived: bool ``` @@ -281,7 +281,8 @@ class MySurfaceAdapter: Любая новая поверхность получает без дополнительного кода: -- управление чатами (`ChatContext`, C1/C2/C3) +- управление сессиями (создание, переключение, закрытие) +- управление чатами (`ChatContext`) - аутентификацию (`AuthFlow`) - подтверждение действий (`ConfirmationRequest`) - все настройки (коннекторы, скиллы, SOUL, безопасность, подписка) @@ -296,17 +297,15 @@ class MySurfaceAdapter: ```python class PlatformClient(Protocol): - async def get_or_create_user(self, external_id: str, platform: str, - display_name: str | None = None) -> User: ... - async def send_message(self, user_id: str, chat_id: str, text: str, - attachments: list | None = None) -> MessageResponse: ... + async def get_or_create_user(self, user_id: str, platform: str) -> User: ... + async def create_session(self, user_id: str, chat_id: str) -> Session: ... + async def send_message(self, session_id: str, text: str, attachments: list) -> AgentResponse: ... + async def close_session(self, session_id: str) -> None: ... + async def get_chat_history(self, user_id: str, chat_id: str) -> list[Message]: ... async def get_settings(self, user_id: str) -> UserSettings: ... - async def update_settings(self, user_id: str, action: Any) -> None: ... + async def update_settings(self, user_id: str, action: SettingsAction) -> None: ... ``` -Бот **не управляет lifecycle контейнеров** — это делает Master (платформа). -Бот передаёт `user_id` + `chat_id` + текст; Master сам решает нужно ли поднять контейнер, смонтировать `C1/`/`C2/`, запустить агента. - `MockPlatformClient` реализует этот протокол сейчас. Реальный SDK — тоже реализует этот протокол, заменяя один файл. Адаптеры поверхностей и ядро не меняются вообще. diff --git a/docs/telegram-prototype.md b/docs/telegram-prototype.md index c58a1e5..e867f99 100644 --- a/docs/telegram-prototype.md +++ b/docs/telegram-prototype.md @@ -51,11 +51,11 @@ 1. Пользователь пишет `/new` или нажимает кнопку 2. Бот спрашивает название (опционально, можно пропустить) 3. Бот создаёт новую тему в группе: «Чат 1», «Чат 2» и т.д. -4. Бот отправляет в новую тему приветствие; при первом сообщении платформа автоматически поднимает контейнер +4. Бот отправляет в новую тему приветствие и создаёт сессию на платформе ### В моке - Группа и темы создаются реально через Bot API -- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...) +- Сессии на платформе — через MockPlatformClient - История в темах хранится нативно в Telegram, ничего не нужно делать --- diff --git a/docs/user-flow.md b/docs/user-flow.md index efe22f1..30fa15c 100644 --- a/docs/user-flow.md +++ b/docs/user-flow.md @@ -11,7 +11,7 @@ sequenceDiagram actor User participant Bot as Telegram/Matrix Bot - participant Platform as Lambda Platform (Master) + participant Platform as Lambda Platform User->>Bot: /start Bot->>Platform: GET /users/{tg_id}?platform=telegram @@ -23,13 +23,20 @@ sequenceDiagram Bot->>User: Добро пожаловать обратно end - loop Диалог (бот не управляет сессиями — Master делает это автоматически) - User->>Bot: Сообщение в чат C1/C2/... - Bot->>Platform: POST /users/{user_id}/chats/{chat_id}/messages - Note over Platform: Master поднимает контейнер,
монтирует нужный чат, запускает агента - Platform-->>Bot: {message_id, response, tokens_used} + User->>Bot: Любое сообщение + Bot->>Platform: POST /sessions (создаём сессию) + Platform-->>Bot: {session_id, agent_id} + + loop Диалог + User->>Bot: Сообщение + Bot->>Platform: POST /sessions/{id}/messages + Platform-->>Bot: {response} Bot->>User: Ответ агента end + + User->>Bot: /end или таймаут + Bot->>Platform: DELETE /sessions/{id} + Bot->>User: Сессия завершена ``` --- @@ -38,19 +45,15 @@ sequenceDiagram ```mermaid stateDiagram-v2 - [*] --> Unauthenticated: первый контакт + [*] --> Idle: /start - Unauthenticated --> Idle: /start (auth confirmed) - - Idle --> WaitingResponse: сообщение пользователя - WaitingResponse --> Idle: ответ получен - WaitingResponse --> Error: ошибка платформы - - Idle --> Idle: /new (создан новый чат) - Idle --> ConfirmAction: агент запрашивает подтверждение - ConfirmAction --> Idle: подтверждено / отменено + Idle --> InSession: любое сообщение + InSession --> InSession: сообщение пользователя + InSession --> Idle: /end + InSession --> Error: ошибка платформы Error --> Idle: /start + Error --> InSession: retry ``` --- diff --git a/platform/__init__.py b/platform/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/platform/interface.py b/platform/interface.py deleted file mode 100644 index 95d56c6..0000000 --- a/platform/interface.py +++ /dev/null @@ -1,59 +0,0 @@ -# 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: ... diff --git a/platform/mock.py b/platform/mock.py deleted file mode 100644 index 96326d7..0000000 --- a/platform/mock.py +++ /dev/null @@ -1,137 +0,0 @@ -# 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) diff --git a/pyproject.toml b/pyproject.toml index 8f4978b..8ad9f92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["setuptools>=68", "setuptools-scm", "wheel"] -build-backend = "setuptools.build_meta" +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.backends.legacy:build" [project] name = "surfaces-bot" @@ -29,7 +29,6 @@ dev = [ [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] -pythonpath = ["."] [tool.ruff] line-length = 100 diff --git a/tests/core/test_auth.py b/tests/core/test_auth.py deleted file mode 100644 index 9c02e7a..0000000 --- a/tests/core/test_auth.py +++ /dev/null @@ -1,38 +0,0 @@ -# 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 diff --git a/tests/core/test_chat.py b/tests/core/test_chat.py deleted file mode 100644 index 83d2252..0000000 --- a/tests/core/test_chat.py +++ /dev/null @@ -1,53 +0,0 @@ -# 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 diff --git a/tests/core/test_dispatcher.py b/tests/core/test_dispatcher.py deleted file mode 100644 index 08309dc..0000000 --- a/tests/core/test_dispatcher.py +++ /dev/null @@ -1,85 +0,0 @@ -# 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" diff --git a/tests/core/test_integration.py b/tests/core/test_integration.py deleted file mode 100644 index 40eb1f5..0000000 --- a/tests/core/test_integration.py +++ /dev/null @@ -1,85 +0,0 @@ -# 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)) diff --git a/tests/core/test_protocol.py b/tests/core/test_protocol.py deleted file mode 100644 index bf5e82a..0000000 --- a/tests/core/test_protocol.py +++ /dev/null @@ -1,42 +0,0 @@ -# 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" diff --git a/tests/core/test_settings.py b/tests/core/test_settings.py deleted file mode 100644 index b491ab9..0000000 --- a/tests/core/test_settings.py +++ /dev/null @@ -1,32 +0,0 @@ -# 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) diff --git a/tests/core/test_store.py b/tests/core/test_store.py deleted file mode 100644 index 2eb1fa2..0000000 --- a/tests/core/test_store.py +++ /dev/null @@ -1,58 +0,0 @@ -# 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"} diff --git a/tests/core/test_voice_slot.py b/tests/core/test_voice_slot.py deleted file mode 100644 index 00cd976..0000000 --- a/tests/core/test_voice_slot.py +++ /dev/null @@ -1,49 +0,0 @@ -# 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 diff --git a/tests/platform/test_mock.py b/tests/platform/test_mock.py deleted file mode 100644 index 03771ae..0000000 --- a/tests/platform/test_mock.py +++ /dev/null @@ -1,45 +0,0 @@ -# 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