diff --git a/README.md b/README.md index 23ae9ba..aedfc16 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,12 @@ surfaces-bot/ core/ — общее ядро, не зависит от транспорта protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...) - handler.py — логика: IncomingEvent → OutgoingEvent - session.py — управление сессиями и чатами - auth.py — аутентификация - settings.py — коннекторы, скиллы, SOUL, безопасность + 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, безопасность adapter/ telegram/ — aiogram 3.x адаптер @@ -75,14 +77,15 @@ surfaces-bot/ ```python class PlatformClient(Protocol): - 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: ... + 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: ... ``` +Бот не управляет lifecycle контейнеров — это делает Master (платформа). +Бот передаёт `user_id` + `chat_id` + сообщение; платформа сама решает нужно ли поднять контейнер. + Сейчас: `MockPlatformClient` в `platform/mock.py`. Когда SDK готов: добавляем `SdkPlatformClient`, меняем одну строку в DI. Адаптеры и ядро не трогаем. diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..60f4b07 --- /dev/null +++ b/conftest.py @@ -0,0 +1,13 @@ +import sys +from pathlib import Path + +# Insert project root at position 0 so our local `platform/` package +# shadows the Python stdlib `platform` module. +root = str(Path(__file__).parent) +sys.path.insert(0, root) + +# If stdlib platform was already cached, remove it so Python re-resolves +# to our local package. +stdlib_platform = sys.modules.get("platform") +if stdlib_platform is not None and not hasattr(stdlib_platform, "mock"): + del sys.modules["platform"] diff --git a/core/auth.py b/core/auth.py new file mode 100644 index 0000000..688f1cd --- /dev/null +++ b/core/auth.py @@ -0,0 +1,52 @@ +# core/auth.py +from __future__ import annotations + +import structlog + +from core.protocol import AuthFlow +from core.store import StateStore + +logger = structlog.get_logger(__name__) + + +def _to_dict(flow: AuthFlow) -> dict: + return { + "user_id": flow.user_id, + "platform": flow.platform, + "state": flow.state, + "platform_user_id": flow.platform_user_id, + } + + +def _from_dict(d: dict) -> AuthFlow: + return AuthFlow( + user_id=d["user_id"], + platform=d["platform"], + state=d["state"], + platform_user_id=d.get("platform_user_id"), + ) + + +class AuthManager: + def __init__(self, platform: object, store: StateStore) -> None: + self._store = store + + async def start_flow(self, user_id: str, platform: str) -> AuthFlow: + flow = AuthFlow(user_id=user_id, platform=platform, state="pending") + await self._store.set(f"auth:{user_id}", _to_dict(flow)) + return flow + + async def confirm(self, user_id: str) -> AuthFlow: + """В моке — автоматическое подтверждение. В реальном SDK — валидация кода.""" + stored = await self._store.get(f"auth:{user_id}") + if not stored: + stored = {"user_id": user_id, "platform": "unknown", "state": "pending", "platform_user_id": None} + + stored["state"] = "confirmed" + stored["platform_user_id"] = f"plt_{user_id}" + await self._store.set(f"auth:{user_id}", stored) + return _from_dict(stored) + + async def is_authenticated(self, user_id: str) -> bool: + stored = await self._store.get(f"auth:{user_id}") + return stored is not None and stored.get("state") == "confirmed" diff --git a/core/chat.py b/core/chat.py new file mode 100644 index 0000000..d05a274 --- /dev/null +++ b/core/chat.py @@ -0,0 +1,111 @@ +# core/chat.py +from __future__ import annotations + +from datetime import UTC, datetime + +import structlog + +from core.protocol import ChatContext +from core.store import StateStore + +logger = structlog.get_logger(__name__) + + +def _to_dict(ctx: ChatContext) -> dict: + return { + "chat_id": ctx.chat_id, + "display_name": ctx.display_name, + "platform": ctx.platform, + "surface_ref": ctx.surface_ref, + "created_at": ctx.created_at.isoformat(), + "is_archived": ctx.is_archived, + } + + +def _from_dict(d: dict) -> ChatContext: + return ChatContext( + chat_id=d["chat_id"], + display_name=d["display_name"], + platform=d["platform"], + surface_ref=d["surface_ref"], + created_at=datetime.fromisoformat(d["created_at"]), + is_archived=d.get("is_archived", False), + ) + + +class ChatManager: + """ + Управляет метаданными чатов (C1/C2/C3 в workspace пользователя). + НЕ управляет lifecycle контейнера — это дело Master'а на стороне платформы. + """ + + def __init__(self, platform: object, store: StateStore) -> None: + self._store = store + + def _key(self, user_id: str, chat_id: str) -> str: + return f"chat:{user_id}:{chat_id}" + + async def get_or_create( + self, + user_id: str, + chat_id: str, + platform: str, + surface_ref: str, + name: str | None = None, + ) -> ChatContext: + key = self._key(user_id, chat_id) + stored = await self._store.get(key) + if stored: + return _from_dict(stored) + + ctx = ChatContext( + chat_id=chat_id, + display_name=name or f"Чат {chat_id}", + platform=platform, + surface_ref=surface_ref, + created_at=datetime.now(UTC), + ) + await self._store.set(key, _to_dict(ctx)) + logger.info("Chat created", chat_id=chat_id, user_id=user_id) + return ctx + + async def get(self, chat_id: str, user_id: str | None = None) -> ChatContext | None: + if user_id: + stored = await self._store.get(self._key(user_id, chat_id)) + return _from_dict(stored) if stored else None + # Scan by chat_id suffix when user_id unknown (slower) + for key in await self._store.keys("chat:"): + if key.endswith(f":{chat_id}"): + stored = await self._store.get(key) + if stored: + return _from_dict(stored) + return None + + async def rename(self, chat_id: str, name: str, user_id: str | None = None) -> ChatContext: + ctx = await self.get(chat_id, user_id) + if not ctx: + raise ValueError(f"Chat {chat_id} not found") + ctx.display_name = name + for key in await self._store.keys("chat:"): + if key.endswith(f":{chat_id}"): + await self._store.set(key, _to_dict(ctx)) + break + return ctx + + async def archive(self, chat_id: str, user_id: str | None = None) -> None: + ctx = await self.get(chat_id, user_id) + if not ctx: + raise ValueError(f"Chat {chat_id} not found") + ctx.is_archived = True + for key in await self._store.keys("chat:"): + if key.endswith(f":{chat_id}"): + await self._store.set(key, _to_dict(ctx)) + break + + async def list_active(self, user_id: str) -> list[ChatContext]: + chats = [] + for key in await self._store.keys(f"chat:{user_id}:"): + stored = await self._store.get(key) + if stored and not stored.get("is_archived"): + chats.append(_from_dict(stored)) + return chats diff --git a/core/handler.py b/core/handler.py new file mode 100644 index 0000000..f6dd5bd --- /dev/null +++ b/core/handler.py @@ -0,0 +1,71 @@ +# core/handler.py +from __future__ import annotations + +from typing import Awaitable, Callable + +import structlog + +from core.auth import AuthManager +from core.chat import ChatManager +from core.protocol import ( + IncomingCallback, + IncomingCommand, + IncomingEvent, + IncomingMessage, + OutgoingEvent, +) +from core.settings import SettingsManager +from platform.interface import PlatformClient + +logger = structlog.get_logger(__name__) + +HandlerFn = Callable[..., Awaitable[list[OutgoingEvent]]] + + +class EventDispatcher: + def __init__( + self, + platform: PlatformClient, + chat_mgr: ChatManager, + auth_mgr: AuthManager, + settings_mgr: SettingsManager, + ) -> None: + self._platform = platform + self._chat_mgr = chat_mgr + self._auth_mgr = auth_mgr + self._settings_mgr = settings_mgr + self._handlers: dict[type, dict[str, HandlerFn]] = { + IncomingCommand: {}, + IncomingMessage: {}, + IncomingCallback: {}, + } + + def register(self, event_type: type, key: str, handler: HandlerFn) -> None: + self._handlers[event_type][key] = handler + + async def dispatch(self, event: IncomingEvent) -> list[OutgoingEvent]: + event_type = type(event) + handlers = self._handlers.get(event_type, {}) + key = self._routing_key(event) + handler = handlers.get(key) or handlers.get("*") + + if handler is None: + logger.warning("No handler registered", event_type=event_type.__name__, key=key) + return [] + + return await handler( + event=event, + chat_mgr=self._chat_mgr, + auth_mgr=self._auth_mgr, + settings_mgr=self._settings_mgr, + platform=self._platform, + ) + + def _routing_key(self, event: IncomingEvent) -> str: + if isinstance(event, IncomingCommand): + return event.command + if isinstance(event, IncomingCallback): + return event.action + if isinstance(event, IncomingMessage) and event.attachments: + return event.attachments[0].type + return "*" diff --git a/core/handlers/__init__.py b/core/handlers/__init__.py new file mode 100644 index 0000000..9ed15d9 --- /dev/null +++ b/core/handlers/__init__.py @@ -0,0 +1,24 @@ +# core/handlers/__init__.py +from __future__ import annotations + +from core.handler import EventDispatcher +from core.protocol import IncomingCallback, IncomingCommand, IncomingMessage +from core.handlers import callback, chat, message, settings, start + + +def register_all(dispatcher: EventDispatcher) -> None: + # Commands + dispatcher.register(IncomingCommand, "start", start.handle_start) + dispatcher.register(IncomingCommand, "new", chat.handle_new_chat) + dispatcher.register(IncomingCommand, "rename", chat.handle_rename) + dispatcher.register(IncomingCommand, "archive", chat.handle_archive) + dispatcher.register(IncomingCommand, "chats", chat.handle_list_chats) + dispatcher.register(IncomingCommand, "settings", settings.handle_settings) + + # Messages — catch-all (audio falls back here until voice_handler registered) + dispatcher.register(IncomingMessage, "*", message.handle_message) + + # Callbacks + dispatcher.register(IncomingCallback, "confirm", callback.handle_confirm) + dispatcher.register(IncomingCallback, "cancel", callback.handle_cancel) + dispatcher.register(IncomingCallback, "toggle_skill", callback.handle_toggle_skill) diff --git a/core/handlers/callback.py b/core/handlers/callback.py new file mode 100644 index 0000000..28b7659 --- /dev/null +++ b/core/handlers/callback.py @@ -0,0 +1,25 @@ +# core/handlers/callback.py +from __future__ import annotations + +from core.protocol import IncomingCallback, OutgoingMessage, SettingsAction + + +async def handle_confirm(event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr) -> list: + action_id = event.payload.get("action_id", "unknown") + return [OutgoingMessage(chat_id=event.chat_id, text=f"Действие подтверждено (id: {action_id}).")] + + +async def handle_cancel(event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr) -> list: + action_id = event.payload.get("action_id", "unknown") + return [OutgoingMessage(chat_id=event.chat_id, text=f"Действие отменено (id: {action_id}).")] + + +async def handle_toggle_skill(event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr) -> list: + skill = event.payload.get("skill") + enabled = event.payload.get("enabled", True) + if not skill: + return [OutgoingMessage(chat_id=event.chat_id, text="Ошибка: не указан навык.")] + action = SettingsAction(action="toggle_skill", payload={"skill": skill, "enabled": enabled}) + await settings_mgr.apply(event.user_id, action) + state = "включён" if enabled else "выключен" + return [OutgoingMessage(chat_id=event.chat_id, text=f"Навык {skill} {state}.")] diff --git a/core/handlers/chat.py b/core/handlers/chat.py new file mode 100644 index 0000000..8e32468 --- /dev/null +++ b/core/handlers/chat.py @@ -0,0 +1,38 @@ +# core/handlers/chat.py +from __future__ import annotations + +from core.protocol import IncomingCommand, OutgoingMessage + + +async def handle_new_chat(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list: + if not await auth_mgr.is_authenticated(event.user_id): + return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")] + name = " ".join(event.args) if event.args else None + ctx = await chat_mgr.get_or_create( + user_id=event.user_id, + chat_id=event.chat_id, + platform=event.platform, + surface_ref=event.chat_id, + name=name, + ) + return [OutgoingMessage(chat_id=event.chat_id, text=f"Создан чат: {ctx.display_name}")] + + +async def handle_rename(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list: + if not event.args: + return [OutgoingMessage(chat_id=event.chat_id, text="Укажите название: /rename Название")] + ctx = await chat_mgr.rename(event.chat_id, " ".join(event.args)) + return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")] + + +async def handle_archive(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list: + await chat_mgr.archive(event.chat_id) + return [OutgoingMessage(chat_id=event.chat_id, text="Чат архивирован.")] + + +async def handle_list_chats(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list: + chats = await chat_mgr.list_active(event.user_id) + if not chats: + return [OutgoingMessage(chat_id=event.chat_id, text="Нет активных чатов.")] + lines = [f"• {c.display_name} ({c.chat_id})" for c in chats] + return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))] diff --git a/core/handlers/message.py b/core/handlers/message.py new file mode 100644 index 0000000..e1475ef --- /dev/null +++ b/core/handlers/message.py @@ -0,0 +1,29 @@ +# core/handlers/message.py +from __future__ import annotations + +from core.protocol import IncomingMessage, OutgoingMessage, OutgoingTyping + + +async def handle_message(event: IncomingMessage, auth_mgr, platform, chat_mgr, settings_mgr) -> list: + if not await auth_mgr.is_authenticated(event.user_id): + return [OutgoingMessage(chat_id=event.chat_id, text="Введите /start чтобы начать.")] + + # Voice slot fallback: audio attachment without registered voice_handler + if event.attachments and event.attachments[0].type == "audio": + return [OutgoingMessage( + chat_id=event.chat_id, + text="Голосовые сообщения скоро поддержим.", + parse_mode="plain", + )] + + response = await platform.send_message( + user_id=event.user_id, + chat_id=event.chat_id, + text=event.text, + attachments=[], + ) + + return [ + OutgoingTyping(chat_id=event.chat_id, is_typing=False), + OutgoingMessage(chat_id=event.chat_id, text=response.response, parse_mode="markdown"), + ] diff --git a/core/handlers/settings.py b/core/handlers/settings.py new file mode 100644 index 0000000..b1fae5d --- /dev/null +++ b/core/handlers/settings.py @@ -0,0 +1,25 @@ +# core/handlers/settings.py +from __future__ import annotations + +from core.protocol import IncomingCommand, OutgoingMessage, OutgoingUI, UIButton + + +async def handle_settings(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list: + return [OutgoingUI( + chat_id=event.chat_id, + text="⚙️ Настройки", + buttons=[ + UIButton(label="🔗 Коннекторы", action="settings_connectors", style="secondary"), + UIButton(label="🧩 Скиллы", action="settings_skills", style="secondary"), + UIButton(label="🧠 Личность", action="settings_soul", style="secondary"), + UIButton(label="🔒 Безопасность", action="settings_safety", style="secondary"), + UIButton(label="💳 Подписка", action="settings_plan", style="secondary"), + ], + )] + + +async def handle_settings_skills(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list: + s = await settings_mgr.get(event.user_id) + lines = [("✅" if on else "❌") + f" {name}" for name, on in s.skills.items()] + text = "🧩 Скиллы\n\n" + ("\n".join(lines) or "Нет доступных скиллов") + return [OutgoingMessage(chat_id=event.chat_id, text=text)] diff --git a/core/handlers/start.py b/core/handlers/start.py new file mode 100644 index 0000000..578c899 --- /dev/null +++ b/core/handlers/start.py @@ -0,0 +1,16 @@ +# core/handlers/start.py +from __future__ import annotations + +from core.protocol import IncomingCommand, OutgoingMessage + + +async def handle_start(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list: + user = await platform.get_or_create_user(event.user_id, event.platform) + await auth_mgr.confirm(event.user_id) + name = user.display_name or event.user_id + text = ( + f"Добро пожаловать, {name}! Я агент Lambda. Напишите что-нибудь чтобы начать." + if user.is_new + else f"С возвращением, {name}!" + ) + return [OutgoingMessage(chat_id=event.chat_id, text=text)] diff --git a/core/protocol.py b/core/protocol.py new file mode 100644 index 0000000..02a9f4a --- /dev/null +++ b/core/protocol.py @@ -0,0 +1,124 @@ +# core/protocol.py +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime + + +@dataclass +class Attachment: + type: str # "image" | "document" | "audio" | "video" + url: str | None = None + content: bytes | None = None + filename: str | None = None + mime_type: str | None = None + + +@dataclass +class IncomingMessage: + user_id: str + platform: str + chat_id: str + text: str + attachments: list[Attachment] = field(default_factory=list) + reply_to: str | None = None + + +@dataclass +class IncomingCommand: + user_id: str + platform: str + chat_id: str + command: str + args: list[str] = field(default_factory=list) + + +@dataclass +class IncomingCallback: + user_id: str + platform: str + chat_id: str + action: str + payload: dict = field(default_factory=dict) + + +@dataclass +class OutgoingMessage: + chat_id: str + text: str + parse_mode: str = "plain" + attachments: list[Attachment] = field(default_factory=list) + reply_to: str | None = None + + +@dataclass +class UIButton: + label: str + action: str + payload: dict = field(default_factory=dict) + style: str = "secondary" # "primary" | "danger" | "secondary" + + +@dataclass +class OutgoingUI: + chat_id: str + text: str + buttons: list[UIButton] = field(default_factory=list) + + +@dataclass +class OutgoingNotification: + chat_id: str + text: str + level: str = "info" # "info" | "warning" | "success" | "error" + + +@dataclass +class OutgoingTyping: + chat_id: str + is_typing: bool + + +@dataclass +class ChatContext: + chat_id: str + display_name: str + platform: str + surface_ref: str + created_at: datetime + is_archived: bool = False + + +@dataclass +class AuthFlow: + user_id: str + platform: str + state: str # "pending" | "code_sent" | "confirmed" | "failed" + platform_user_id: str | None = None + + +@dataclass +class ConfirmationRequest: + action_id: str + chat_id: str + description: str + risk_level: str # "low" | "medium" | "high" + expires_at: datetime + + +@dataclass +class SettingsAction: + action: str + payload: dict = field(default_factory=dict) + + +@dataclass +class PaymentRequired: + user_id: str + reason: str + current_plan: str + + +# Type aliases +IncomingEvent = IncomingMessage | IncomingCommand | IncomingCallback +OutgoingEvent = OutgoingMessage | OutgoingUI | OutgoingNotification | OutgoingTyping diff --git a/core/settings.py b/core/settings.py new file mode 100644 index 0000000..ba72a89 --- /dev/null +++ b/core/settings.py @@ -0,0 +1,29 @@ +# core/settings.py +from __future__ import annotations + +import structlog + +from core.protocol import SettingsAction +from core.store import StateStore +from platform.interface import PlatformClient, UserSettings + +logger = structlog.get_logger(__name__) + + +class SettingsManager: + def __init__(self, platform: PlatformClient, store: StateStore) -> None: + self._platform = platform + self._store = store + + async def get(self, user_id: str) -> UserSettings: + cached = await self._store.get(f"settings:{user_id}") + if cached: + return UserSettings(**cached) + settings = await self._platform.get_settings(user_id) + await self._store.set(f"settings:{user_id}", settings.model_dump()) + return settings + + async def apply(self, user_id: str, action: SettingsAction) -> None: + await self._platform.update_settings(user_id, action) + await self._store.delete(f"settings:{user_id}") # invalidate cache + logger.info("Settings applied", user_id=user_id, action=action.action) diff --git a/core/store.py b/core/store.py new file mode 100644 index 0000000..c1402bc --- /dev/null +++ b/core/store.py @@ -0,0 +1,73 @@ +# core/store.py +from __future__ import annotations + +import json +import sqlite3 +from typing import Protocol + + +class StateStore(Protocol): + async def get(self, key: str) -> dict | None: ... + async def set(self, key: str, value: dict) -> None: ... + async def delete(self, key: str) -> None: ... + async def keys(self, prefix: str) -> list[str]: ... + + +class InMemoryStore: + def __init__(self) -> None: + self._data: dict[str, dict] = {} + + async def get(self, key: str) -> dict | None: + return self._data.get(key) + + async def set(self, key: str, value: dict) -> None: + self._data[key] = value + + async def delete(self, key: str) -> None: + self._data.pop(key, None) + + async def keys(self, prefix: str) -> list[str]: + return [k for k in self._data if k.startswith(prefix)] + + +class SQLiteStore: + def __init__(self, db_path: str) -> None: + self._db_path = db_path + self._init_db() + + def _init_db(self) -> None: + conn = sqlite3.connect(self._db_path) + conn.execute( + "CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)" + ) + conn.commit() + conn.close() + + async def get(self, key: str) -> dict | None: + conn = sqlite3.connect(self._db_path) + row = conn.execute("SELECT value FROM kv WHERE key = ?", (key,)).fetchone() + conn.close() + return json.loads(row[0]) if row else None + + async def set(self, key: str, value: dict) -> None: + conn = sqlite3.connect(self._db_path) + conn.execute( + "INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)", + (key, json.dumps(value, default=str)), + ) + conn.commit() + conn.close() + + async def delete(self, key: str) -> None: + conn = sqlite3.connect(self._db_path) + conn.execute("DELETE FROM kv WHERE key = ?", (key,)) + conn.commit() + conn.close() + + async def keys(self, prefix: str) -> list[str]: + conn = sqlite3.connect(self._db_path) + rows = conn.execute( + "SELECT key FROM kv WHERE key LIKE ?", (prefix + "%",) + ).fetchall() + conn.close() + return [row[0] for row in rows] diff --git a/docs/api-contract.md b/docs/api-contract.md index a578f08..10fd899 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -1,11 +1,18 @@ # API Contract — Lambda Platform -> **Статус:** ЧЕРНОВИК — проектируем сами, не ждём SDK -> **Автор:** @architect -> **Последнее обновление:** заполнить дату +> **Статус:** ЧЕРНОВИК — проектируем сами, уточняем с Азаматом когда SDK будет готов +> **Последнее обновление:** 2026-03-29 -Это описание того, что нам нужно от платформы. -`MockPlatformClient` реализует этот контракт. При подключении реального SDK — только он меняется. +--- + +## Архитектурный контекст + +Каждому пользователю выделяется **один LXC-контейнер** с workspace 10 ГБ. +Workspace содержит директории чатов: `C1/`, `C2/`, `C3/` — файлы + `history.db` в каждом. + +**Master** управляет lifecycle контейнера (запуск, заморозка, пробуждение). +Бот **не управляет lifecycle** — он передаёт `user_id` + `chat_id` + сообщение. +Master сам решает: нужно ли поднять контейнер, смонтировать нужный чат, запустить агента. --- @@ -28,6 +35,7 @@ Authorization: Bearer {SERVICE_TOKEN} ## Users ### GET /users/{external_id}?platform={platform} + Получает или создаёт пользователя. **Query params:** @@ -47,50 +55,15 @@ 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 -### POST /sessions/{session_id}/messages -Отправляет сообщение и получает ответ агента. +Бот не управляет сессиями явно. Отправка сообщения — единственная операция. +Master решает: нужен ли новый контейнер, или разбудить существующий. + +### POST /users/{user_id}/chats/{chat_id}/messages + +Отправляет сообщение пользователя агенту. Master поднимает/размораживает контейнер, +монтирует нужный чат (`C1/`, `C2/`...), запускает агента. **Request:** ```json @@ -110,28 +83,46 @@ Authorization: Bearer {SERVICE_TOKEN} } ``` -### GET /sessions/{session_id}/messages?limit=20&offset=0 -История сообщений сессии. +--- + +## Settings + +### GET /users/{user_id}/settings + +Настройки пользователя: скиллы, коннекторы, SOUL, безопасность, план. **Response 200:** ```json -[ - { - "message_id": "msg_qwe012", - "user_text": "Привет", - "response": "Привет!", - "tokens_used": 42, - "created_at": "2025-01-15T10:31:00Z" - } -] +{ + "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} ``` --- ## Error format -Все ошибки возвращаются в едином формате: - ```json { "error": "ERROR_CODE", @@ -140,11 +131,13 @@ Authorization: Bearer {SERVICE_TOKEN} } ``` -Коды ошибок: `SESSION_NOT_FOUND`, `USER_NOT_FOUND`, `RATE_LIMITED`, `PLATFORM_ERROR` +Коды ошибок: `USER_NOT_FOUND`, `RATE_LIMITED`, `PLATFORM_ERROR`, `CONTAINER_UNAVAILABLE` --- -## TODO (открытые вопросы к команде платформы) +## Открытые вопросы к команде платфрмы (SDK) -- [ ] Нужна ли стриминговая передача ответа (SSE / WebSocket)? -- [ ] Как обрабатываются вложения (изображения, файлы)? \ No newline at end of file +- [ ] Точный формат эндпоинта отправки сообщения — URL, поля +- [ ] Как передавать вложения (файлы, изображения)? Через S3 pre-signed URL или напрямую? +- [ ] Стриминговый ответ (SSE / WebSocket) или только sync? +- [ ] Формат `SettingsAction` — совпадает с нашим или другой? diff --git a/docs/matrix-prototype.md b/docs/matrix-prototype.md index 6e9b6bd..5e57c88 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 +- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...) - История хранится в Matrix нативно --- @@ -208,15 +208,13 @@ Matrix поддерживает реакции на сообщения (`m.react ### Статус и диагностика ``` -!status — состояние агента и платформы -!sessions — список активных сессий +!status — состояние платформы и чатов !whoami — текущий аккаунт платформы ``` ``` Статус: Платформа: ✅ доступна - Агент: ✅ активен (сессия #abc123) Аккаунт: user@lambda.lab Активных чатов: 3 ``` @@ -230,7 +228,7 @@ Matrix поддерживает реакции на сообщения (`m.react ↓ SpaceSetup → Idle (в комнате Настройки) ↓ - [новая комната] → SessionCreated → Idle (в чате) + [новая комната] → ChatCreated → 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 new file mode 100644 index 0000000..10404e6 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-core-implementation.md @@ -0,0 +1,1803 @@ +# 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 new file mode 100644 index 0000000..bdeb7f1 --- /dev/null +++ b/docs/superpowers/specs/2026-03-28-core-design.md @@ -0,0 +1,257 @@ +# 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 eb2069b..ca66000 100644 --- a/docs/surface-protocol.md +++ b/docs/surface-protocol.md @@ -23,10 +23,12 @@ Surface Protocol — это общий язык между адаптерами surfaces-bot/ core/ protocol.py — все унифицированные структуры данных - handler.py — логика: IncomingEvent → OutgoingEvent - session.py — управление сессиями и чатами - auth.py — AuthFlow - settings.py — SettingsAction, управление настройками + 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 adapter/ telegram/ — aiogram адаптер @@ -35,7 +37,6 @@ surfaces-bot/ matrix/ — matrix-nio адаптер converter.py — matrix-nio Event → IncomingEvent, OutgoingEvent → Matrix API bot.py — точка входа, клиент - _template.py — шаблон для новой поверхности platform/ interface.py — Protocol: PlatformClient @@ -169,16 +170,15 @@ 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,8 +281,7 @@ class MySurfaceAdapter: Любая новая поверхность получает без дополнительного кода: -- управление сессиями (создание, переключение, закрытие) -- управление чатами (`ChatContext`) +- управление чатами (`ChatContext`, C1/C2/C3) - аутентификацию (`AuthFlow`) - подтверждение действий (`ConfirmationRequest`) - все настройки (коннекторы, скиллы, SOUL, безопасность, подписка) @@ -297,15 +296,17 @@ class MySurfaceAdapter: ```python class PlatformClient(Protocol): - 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_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_settings(self, user_id: str) -> UserSettings: ... - async def update_settings(self, user_id: str, action: SettingsAction) -> None: ... + async def update_settings(self, user_id: str, action: Any) -> 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 e867f99..c58a1e5 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 +- Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...) - История в темах хранится нативно в Telegram, ничего не нужно делать --- diff --git a/docs/user-flow.md b/docs/user-flow.md index 30fa15c..efe22f1 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 + participant Platform as Lambda Platform (Master) User->>Bot: /start Bot->>Platform: GET /users/{tg_id}?platform=telegram @@ -23,20 +23,13 @@ sequenceDiagram Bot->>User: Добро пожаловать обратно end - User->>Bot: Любое сообщение - Bot->>Platform: POST /sessions (создаём сессию) - Platform-->>Bot: {session_id, agent_id} - - loop Диалог - User->>Bot: Сообщение - Bot->>Platform: POST /sessions/{id}/messages - Platform-->>Bot: {response} + 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} Bot->>User: Ответ агента end - - User->>Bot: /end или таймаут - Bot->>Platform: DELETE /sessions/{id} - Bot->>User: Сессия завершена ``` --- @@ -45,15 +38,19 @@ sequenceDiagram ```mermaid stateDiagram-v2 - [*] --> Idle: /start + [*] --> Unauthenticated: первый контакт - Idle --> InSession: любое сообщение - InSession --> InSession: сообщение пользователя - InSession --> Idle: /end + Unauthenticated --> Idle: /start (auth confirmed) + + Idle --> WaitingResponse: сообщение пользователя + WaitingResponse --> Idle: ответ получен + WaitingResponse --> Error: ошибка платформы + + Idle --> Idle: /new (создан новый чат) + Idle --> ConfirmAction: агент запрашивает подтверждение + ConfirmAction --> Idle: подтверждено / отменено - InSession --> Error: ошибка платформы Error --> Idle: /start - Error --> InSession: retry ``` --- diff --git a/platform/__init__.py b/platform/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/platform/interface.py b/platform/interface.py new file mode 100644 index 0000000..95d56c6 --- /dev/null +++ b/platform/interface.py @@ -0,0 +1,59 @@ +# platform/interface.py +from __future__ import annotations + +from datetime import datetime +from typing import Any, Protocol + +from pydantic import BaseModel + + +class User(BaseModel): + user_id: str + external_id: str + platform: str + display_name: str | None = None + created_at: datetime + is_new: bool = False + + +class MessageResponse(BaseModel): + message_id: str + response: str + tokens_used: int + finished: bool + + +class UserSettings(BaseModel): + skills: dict[str, bool] = {} + connectors: dict[str, dict] = {} + soul: dict[str, str] = {} + safety: dict[str, bool] = {} + plan: dict[str, Any] = {} + + +class PlatformError(Exception): + def __init__(self, message: str, code: str = "PLATFORM_ERROR"): + super().__init__(message) + self.code = code + + +class PlatformClient(Protocol): + async def get_or_create_user( + self, + external_id: str, + platform: str, + display_name: str | None = None, + ) -> User: ... + + # Master manages container lifecycle — bot only sends user_id + chat_id. + async def send_message( + self, + user_id: str, + chat_id: str, + text: str, + attachments: list | None = None, + ) -> MessageResponse: ... + + async def get_settings(self, user_id: str) -> UserSettings: ... + + async def update_settings(self, user_id: str, action: Any) -> None: ... diff --git a/platform/mock.py b/platform/mock.py new file mode 100644 index 0000000..96326d7 --- /dev/null +++ b/platform/mock.py @@ -0,0 +1,137 @@ +# platform/mock.py +from __future__ import annotations + +import asyncio +import random +import uuid +from datetime import UTC, datetime +from typing import Any + +import structlog + +from platform.interface import MessageResponse, User, UserSettings + +logger = structlog.get_logger(__name__) + + +class MockPlatformClient: + """ + Заглушка SDK платформы Lambda. + + Реализует PlatformClient Protocol. При подключении реального SDK + заменяется только этот файл — core/ и адаптеры не трогаются. + + Ключевое отличие от реальной платформы: не управляет lifecycle контейнера. + Master делает это сам при получении send_message. + """ + + def __init__(self) -> None: + self._users: dict[str, dict] = {} + self._messages: dict[str, list] = {} # "{user_id}:{chat_id}" → messages + self._settings: dict[str, dict] = {} + logger.info("MockPlatformClient initialized") + + async def get_or_create_user( + self, + external_id: str, + platform: str, + display_name: str | None = None, + ) -> User: + await self._latency() + key = f"{platform}:{external_id}" + is_new = key not in self._users + if is_new: + self._users[key] = { + "user_id": f"usr-{platform}-{external_id}", + "external_id": external_id, + "platform": platform, + "display_name": display_name, + "created_at": "2025-01-01T00:00:00Z", + "is_new": True, + } + data = {**self._users[key], "is_new": is_new} + return User(**data) + + async def send_message( + self, + user_id: str, + chat_id: str, + text: str, + attachments: list | None = None, + ) -> MessageResponse: + await self._latency(200, 600) + key = f"{user_id}:{chat_id}" + if key not in self._messages: + self._messages[key] = [] + + message_id = str(uuid.uuid4()) + preview = text[:50] + ("..." if len(text) > 50 else "") + response = f"[MOCK] Ответ на: «{preview}»" + + self._messages[key].append({ + "message_id": message_id, + "user_text": text, + "response": response, + "tokens_used": len(text.split()) * 2, + "finished": True, + "created_at": datetime.now(UTC).isoformat(), + }) + + logger.info("Message sent", user_id=user_id, chat_id=chat_id, message_id=message_id) + return MessageResponse( + message_id=message_id, + response=response, + tokens_used=len(text.split()) * 2, + finished=True, + ) + + async def get_settings(self, user_id: str) -> UserSettings: + await self._latency() + stored = self._settings.get(user_id, {}) + return UserSettings( + skills=stored.get("skills", { + "web-search": True, + "fetch-url": True, + "email": False, + "browser": False, + "image-gen": False, + "files": True, + }), + connectors=stored.get("connectors", {}), + soul=stored.get("soul", {"name": "Лямбда", "style": "friendly"}), + safety=stored.get("safety", { + "email-send": True, + "file-delete": True, + "social-post": True, + }), + plan=stored.get("plan", { + "name": "Beta", + "tokens_used": 0, + "tokens_limit": 1000, + }), + ) + + async def update_settings(self, user_id: str, action: Any) -> None: + await self._latency() + settings = self._settings.setdefault(user_id, {}) + + if action.action == "toggle_skill": + skills = settings.setdefault("skills", {}) + skills[action.payload["skill"]] = action.payload.get("enabled", True) + elif action.action == "set_soul": + soul = settings.setdefault("soul", {}) + soul[action.payload["field"]] = action.payload["value"] + elif action.action == "set_safety": + safety = settings.setdefault("safety", {}) + safety[action.payload["trigger"]] = action.payload.get("enabled", True) + + logger.info("Settings updated", user_id=user_id, action=action.action) + + def get_stats(self) -> dict: + return { + "total_users": len(self._users), + "total_messages": sum(len(msgs) for msgs in self._messages.values()), + } + + async def _latency(self, min_ms: int = 10, max_ms: int = 80) -> None: + await asyncio.sleep(random.randint(min_ms, max_ms) / 1000) diff --git a/pyproject.toml b/pyproject.toml index 8ad9f92..8f4978b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] -requires = ["setuptools>=68", "wheel"] -build-backend = "setuptools.backends.legacy:build" +requires = ["setuptools>=68", "setuptools-scm", "wheel"] +build-backend = "setuptools.build_meta" [project] name = "surfaces-bot" @@ -29,6 +29,7 @@ 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 new file mode 100644 index 0000000..9c02e7a --- /dev/null +++ b/tests/core/test_auth.py @@ -0,0 +1,38 @@ +# tests/core/test_auth.py +import pytest +from core.auth import AuthManager +from core.store import InMemoryStore +from platform.mock import MockPlatformClient + + +@pytest.fixture +def mgr(): + return AuthManager(MockPlatformClient(), InMemoryStore()) + + +async def test_not_authenticated_initially(mgr): + assert await mgr.is_authenticated("u1") is False + + +async def test_start_flow_returns_pending(mgr): + flow = await mgr.start_flow("u1", "telegram") + assert flow.state == "pending" + assert flow.user_id == "u1" + + +async def test_confirm_sets_confirmed(mgr): + await mgr.start_flow("u1", "telegram") + flow = await mgr.confirm("u1") + assert flow.state == "confirmed" + + +async def test_is_authenticated_after_confirm(mgr): + await mgr.start_flow("u1", "telegram") + await mgr.confirm("u1") + assert await mgr.is_authenticated("u1") is True + + +async def test_confirm_without_start_flow(mgr): + flow = await mgr.confirm("new_user") + assert flow.state == "confirmed" + assert await mgr.is_authenticated("new_user") is True diff --git a/tests/core/test_chat.py b/tests/core/test_chat.py new file mode 100644 index 0000000..83d2252 --- /dev/null +++ b/tests/core/test_chat.py @@ -0,0 +1,53 @@ +# tests/core/test_chat.py +import pytest +from core.chat import ChatManager +from core.store import InMemoryStore +from platform.mock import MockPlatformClient + + +@pytest.fixture +def mgr(): + return ChatManager(MockPlatformClient(), InMemoryStore()) + + +async def test_get_or_create_new_chat(mgr): + ctx = await mgr.get_or_create("u1", "C1", "telegram", "topic-123") + assert ctx.chat_id == "C1" + assert ctx.platform == "telegram" + assert ctx.is_archived is False + + +async def test_get_or_create_idempotent(mgr): + c1 = await mgr.get_or_create("u1", "C1", "telegram", "t1") + c2 = await mgr.get_or_create("u1", "C1", "telegram", "t1") + assert c1.chat_id == c2.chat_id + assert c1.display_name == c2.display_name + + +async def test_get_or_create_with_custom_name(mgr): + ctx = await mgr.get_or_create("u1", "C1", "telegram", "t1", name="Анализ рынка") + assert ctx.display_name == "Анализ рынка" + + +async def test_rename_chat(mgr): + await mgr.get_or_create("u1", "C1", "telegram", "t1") + ctx = await mgr.rename("C1", "Новое название") + assert ctx.display_name == "Новое название" + + +async def test_archive_chat(mgr): + await mgr.get_or_create("u1", "C1", "telegram", "t1") + await mgr.archive("C1") + ctx = await mgr.get("C1") + assert ctx is not None + assert ctx.is_archived is True + + +async def test_list_active_excludes_archived(mgr): + await mgr.get_or_create("u1", "C1", "telegram", "t1") + await mgr.get_or_create("u1", "C2", "telegram", "t2") + await mgr.archive("C2") + chats = await mgr.list_active("u1") + ids = [c.chat_id for c in chats] + assert "C1" in ids + assert "C2" not in ids diff --git a/tests/core/test_dispatcher.py b/tests/core/test_dispatcher.py new file mode 100644 index 0000000..08309dc --- /dev/null +++ b/tests/core/test_dispatcher.py @@ -0,0 +1,85 @@ +# tests/core/test_dispatcher.py +import pytest +from core.handler import EventDispatcher +from core.protocol import ( + IncomingCommand, IncomingMessage, IncomingCallback, + OutgoingMessage, Attachment, +) +from core.chat import ChatManager +from core.auth import AuthManager +from core.settings import SettingsManager +from core.store import InMemoryStore +from platform.mock import MockPlatformClient + + +@pytest.fixture +def dispatcher(): + platform = MockPlatformClient() + store = InMemoryStore() + return EventDispatcher( + platform=platform, + chat_mgr=ChatManager(platform, store), + auth_mgr=AuthManager(platform, store), + settings_mgr=SettingsManager(platform, store), + ) + + +async def test_dispatch_command_to_handler(dispatcher): + called_with = {} + + async def my_handler(event, **kwargs): + called_with["event"] = event + return [OutgoingMessage(chat_id=event.chat_id, text="ok")] + + dispatcher.register(IncomingCommand, "ping", my_handler) + cmd = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="ping") + result = await dispatcher.dispatch(cmd) + + assert called_with["event"] is cmd + assert result[0].text == "ok" + + +async def test_dispatch_unknown_command_returns_empty(dispatcher): + cmd = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="unknown") + result = await dispatcher.dispatch(cmd) + assert result == [] + + +async def test_dispatch_message_to_catchall(dispatcher): + async def catch_all(event, **kwargs): + return [OutgoingMessage(chat_id=event.chat_id, text="caught")] + + dispatcher.register(IncomingMessage, "*", catch_all) + msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="hello") + result = await dispatcher.dispatch(msg) + assert result[0].text == "caught" + + +async def test_dispatch_routes_audio_before_catchall(dispatcher): + async def audio_handler(event, **kwargs): + return [OutgoingMessage(chat_id=event.chat_id, text="audio")] + + async def catch_all(event, **kwargs): + return [OutgoingMessage(chat_id=event.chat_id, text="text")] + + dispatcher.register(IncomingMessage, "audio", audio_handler) + dispatcher.register(IncomingMessage, "*", catch_all) + + audio_msg = IncomingMessage( + user_id="u1", platform="telegram", chat_id="C1", text="", + attachments=[Attachment(type="audio")], + ) + text_msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="hi") + + assert (await dispatcher.dispatch(audio_msg))[0].text == "audio" + assert (await dispatcher.dispatch(text_msg))[0].text == "text" + + +async def test_dispatch_callback_by_action(dispatcher): + async def confirm_handler(event, **kwargs): + return [OutgoingMessage(chat_id=event.chat_id, text="confirmed")] + + dispatcher.register(IncomingCallback, "confirm", confirm_handler) + cb = IncomingCallback(user_id="u1", platform="telegram", chat_id="C1", action="confirm") + result = await dispatcher.dispatch(cb) + assert result[0].text == "confirmed" diff --git a/tests/core/test_integration.py b/tests/core/test_integration.py new file mode 100644 index 0000000..40eb1f5 --- /dev/null +++ b/tests/core/test_integration.py @@ -0,0 +1,85 @@ +# tests/core/test_integration.py +""" +Smoke test: полный цикл через dispatcher + реальные managers + MockPlatformClient. +Имитирует что делает адаптер (Telegram или Matrix) при получении события. +""" +import pytest +from platform.mock import MockPlatformClient +from core.store import InMemoryStore +from core.chat import ChatManager +from core.auth import AuthManager +from core.settings import SettingsManager +from core.handler import EventDispatcher +from core.handlers import register_all +from core.protocol import ( + IncomingCommand, IncomingMessage, IncomingCallback, + OutgoingMessage, OutgoingUI, + Attachment, SettingsAction, +) + + +@pytest.fixture +def dispatcher(): + platform = MockPlatformClient() + store = InMemoryStore() + d = EventDispatcher( + platform=platform, + chat_mgr=ChatManager(platform, store), + auth_mgr=AuthManager(platform, store), + settings_mgr=SettingsManager(platform, store), + ) + register_all(d) + return d + + +async def test_full_flow_start_then_message(dispatcher): + start = IncomingCommand(user_id="tg_123", platform="telegram", chat_id="C1", command="start") + result = await dispatcher.dispatch(start) + assert any(isinstance(r, OutgoingMessage) for r in result) + + msg = IncomingMessage(user_id="tg_123", platform="telegram", chat_id="C1", text="Привет!") + result = await dispatcher.dispatch(msg) + texts = [r.text for r in result if isinstance(r, OutgoingMessage)] + assert any("[MOCK]" in t for t in texts) + + +async def test_new_chat_command(dispatcher): + start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start") + await dispatcher.dispatch(start) + + new = IncomingCommand(user_id="u1", platform="matrix", chat_id="C2", command="new", args=["Анализ"]) + result = await dispatcher.dispatch(new) + assert any("Анализ" in r.text for r in result if isinstance(r, OutgoingMessage)) + + +async def test_settings_menu(dispatcher): + start = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="start") + await dispatcher.dispatch(start) + + s = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="settings") + result = await dispatcher.dispatch(s) + assert any(isinstance(r, OutgoingUI) for r in result) + + +async def test_voice_message_fallback(dispatcher): + start = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="start") + await dispatcher.dispatch(start) + + voice = IncomingMessage( + user_id="u1", platform="telegram", chat_id="C1", text="", + attachments=[Attachment(type="audio")], + ) + result = await dispatcher.dispatch(voice) + assert any("голосов" in r.text.lower() for r in result if isinstance(r, OutgoingMessage)) + + +async def test_toggle_skill_callback(dispatcher): + start = IncomingCommand(user_id="u1", platform="telegram", chat_id="C1", command="start") + await dispatcher.dispatch(start) + + cb = IncomingCallback( + user_id="u1", platform="telegram", chat_id="C1", + action="toggle_skill", payload={"skill": "browser", "enabled": True}, + ) + result = await dispatcher.dispatch(cb) + assert any("browser" in r.text for r in result if isinstance(r, OutgoingMessage)) diff --git a/tests/core/test_protocol.py b/tests/core/test_protocol.py new file mode 100644 index 0000000..bf5e82a --- /dev/null +++ b/tests/core/test_protocol.py @@ -0,0 +1,42 @@ +# tests/core/test_protocol.py +from datetime import datetime +from core.protocol import ( + Attachment, IncomingMessage, IncomingCommand, IncomingCallback, + OutgoingMessage, OutgoingUI, OutgoingTyping, OutgoingNotification, + UIButton, ChatContext, AuthFlow, ConfirmationRequest, SettingsAction, PaymentRequired, +) + + +def test_incoming_message_defaults(): + msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="hi") + assert msg.attachments == [] + assert msg.reply_to is None + + +def test_attachment_audio(): + a = Attachment(type="audio", filename="voice.ogg", mime_type="audio/ogg") + assert a.type == "audio" + assert a.url is None + + +def test_incoming_command_defaults(): + cmd = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="new") + assert cmd.args == [] + + +def test_outgoing_message_defaults(): + msg = OutgoingMessage(chat_id="C1", text="hello") + assert msg.parse_mode == "plain" + assert msg.attachments == [] + + +def test_ui_button_defaults(): + btn = UIButton(label="OK", action="confirm") + assert btn.style == "secondary" + assert btn.payload == {} + + +def test_settings_action(): + action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}) + assert action.action == "toggle_skill" + assert action.payload["skill"] == "browser" diff --git a/tests/core/test_settings.py b/tests/core/test_settings.py new file mode 100644 index 0000000..b491ab9 --- /dev/null +++ b/tests/core/test_settings.py @@ -0,0 +1,32 @@ +# tests/core/test_settings.py +import pytest +from core.settings import SettingsManager +from core.store import InMemoryStore +from core.protocol import SettingsAction +from platform.mock import MockPlatformClient + + +@pytest.fixture +def mgr(): + return SettingsManager(MockPlatformClient(), InMemoryStore()) + + +async def test_get_returns_defaults(mgr): + settings = await mgr.get("u1") + assert "web-search" in settings.skills + + +async def test_apply_toggle_skill(mgr): + action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}) + await mgr.apply("u1", action) + settings = await mgr.get("u1") + assert settings.skills.get("browser") is True + + +async def test_apply_invalidates_cache(mgr): + s1 = await mgr.get("u1") + initial = s1.skills.get("browser", False) + action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": not initial}) + await mgr.apply("u1", action) + s2 = await mgr.get("u1") + assert s2.skills.get("browser") == (not initial) diff --git a/tests/core/test_store.py b/tests/core/test_store.py new file mode 100644 index 0000000..2eb1fa2 --- /dev/null +++ b/tests/core/test_store.py @@ -0,0 +1,58 @@ +# tests/core/test_store.py +from core.store import InMemoryStore, SQLiteStore + + +async def test_inmemory_get_missing_returns_none(): + store = InMemoryStore() + assert await store.get("missing") is None + + +async def test_inmemory_set_and_get(): + store = InMemoryStore() + await store.set("k", {"x": 1}) + assert await store.get("k") == {"x": 1} + + +async def test_inmemory_delete(): + store = InMemoryStore() + await store.set("k", {"x": 1}) + await store.delete("k") + assert await store.get("k") is None + + +async def test_inmemory_keys_prefix(): + store = InMemoryStore() + await store.set("chat:u1:C1", {"a": 1}) + await store.set("chat:u1:C2", {"b": 2}) + await store.set("auth:u1", {"c": 3}) + keys = await store.keys("chat:u1:") + assert set(keys) == {"chat:u1:C1", "chat:u1:C2"} + + +async def test_sqlite_set_and_get(tmp_path): + store = SQLiteStore(str(tmp_path / "test.db")) + await store.set("k", {"hello": "world"}) + assert await store.get("k") == {"hello": "world"} + + +async def test_sqlite_overwrite(tmp_path): + store = SQLiteStore(str(tmp_path / "test.db")) + await store.set("k", {"v": 1}) + await store.set("k", {"v": 2}) + assert await store.get("k") == {"v": 2} + + +async def test_sqlite_delete(tmp_path): + store = SQLiteStore(str(tmp_path / "test.db")) + await store.set("k", {"v": 1}) + await store.delete("k") + assert await store.get("k") is None + + +async def test_sqlite_keys_prefix(tmp_path): + store = SQLiteStore(str(tmp_path / "test.db")) + await store.set("chat:u1:C1", {}) + await store.set("chat:u1:C2", {}) + await store.set("auth:u1", {}) + keys = await store.keys("chat:u1:") + assert set(keys) == {"chat:u1:C1", "chat:u1:C2"} diff --git a/tests/core/test_voice_slot.py b/tests/core/test_voice_slot.py new file mode 100644 index 0000000..00cd976 --- /dev/null +++ b/tests/core/test_voice_slot.py @@ -0,0 +1,49 @@ +# tests/core/test_voice_slot.py +import pytest +from core.protocol import IncomingMessage, Attachment, OutgoingMessage +from core.handlers.message import handle_message +from core.store import InMemoryStore +from core.auth import AuthManager +from core.chat import ChatManager +from core.settings import SettingsManager +from platform.mock import MockPlatformClient + + +@pytest.fixture +def deps(): + platform = MockPlatformClient() + store = InMemoryStore() + auth_mgr = AuthManager(platform, store) + return dict( + platform=platform, + chat_mgr=ChatManager(platform, store), + auth_mgr=auth_mgr, + settings_mgr=SettingsManager(platform, store), + ) + + +async def test_voice_message_returns_stub(deps): + await deps["auth_mgr"].confirm("u1") + msg = IncomingMessage( + user_id="u1", platform="telegram", chat_id="C1", text="", + attachments=[Attachment(type="audio", filename="voice.ogg")], + ) + result = await handle_message(event=msg, **deps) + assert len(result) == 1 + assert isinstance(result[0], OutgoingMessage) + assert "голосов" in result[0].text.lower() + + +async def test_text_message_calls_platform(deps): + await deps["auth_mgr"].confirm("u1") + msg = IncomingMessage(user_id="u1", platform="telegram", chat_id="C1", text="Привет!") + result = await handle_message(event=msg, **deps) + texts = [r.text for r in result if isinstance(r, OutgoingMessage)] + assert any("[MOCK]" in t for t in texts) + + +async def test_unauthenticated_user_gets_start_prompt(deps): + msg = IncomingMessage(user_id="new_user", platform="telegram", chat_id="C1", text="hello") + result = await handle_message(event=msg, **deps) + assert len(result) == 1 + assert "/start" in result[0].text diff --git a/tests/platform/test_mock.py b/tests/platform/test_mock.py new file mode 100644 index 0000000..03771ae --- /dev/null +++ b/tests/platform/test_mock.py @@ -0,0 +1,45 @@ +# tests/platform/test_mock.py +from platform.mock import MockPlatformClient +from platform.interface import User, MessageResponse, UserSettings +from core.protocol import SettingsAction + + +async def test_get_or_create_user_returns_user(): + client = MockPlatformClient() + user = await client.get_or_create_user("12345", "telegram", "Иван") + assert isinstance(user, User) + assert user.external_id == "12345" + assert user.platform == "telegram" + assert user.is_new is True + + +async def test_get_or_create_user_idempotent(): + client = MockPlatformClient() + u1 = await client.get_or_create_user("42", "matrix") + u2 = await client.get_or_create_user("42", "matrix") + assert u1.user_id == u2.user_id + assert u2.is_new is False + + +async def test_send_message_returns_response(): + client = MockPlatformClient() + user = await client.get_or_create_user("u1", "telegram") + result = await client.send_message(user.user_id, "C1", "Привет!") + assert isinstance(result, MessageResponse) + assert result.finished is True + assert len(result.response) > 0 + + +async def test_get_settings_returns_defaults(): + client = MockPlatformClient() + settings = await client.get_settings("usr-telegram-42") + assert isinstance(settings, UserSettings) + assert "web-search" in settings.skills + + +async def test_update_settings_toggle_skill(): + client = MockPlatformClient() + action = SettingsAction(action="toggle_skill", payload={"skill": "browser", "enabled": True}) + await client.update_settings("usr-1", action) + settings = await client.get_settings("usr-1") + assert settings.skills.get("browser") is True