# 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" ```