54 KiB
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
mkdir -p platform tests/platform tests/core
touch platform/__init__.py tests/platform/__init__.py tests/core/__init__.py
- Step 2: Создать
platform/interface.py
# 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
# 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
# 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: Запустить тесты — убедиться что проходят
cd /Users/a/MAI/sem2/lambda/surfaces-bot
uv run pytest tests/platform/ -v
Ожидаем: 5 PASSED
- Step 6: Удалить
src/
rm -rf src/
- Step 7: Убедиться что старые тесты не сломали ничего нового
uv run pytest tests/platform/ -v
Ожидаем: 5 PASSED (старый tests/test_mock_platform.py больше не нужен — удалить)
rm tests/test_mock_platform.py
- Step 8: Commit
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: Написать тест
# 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
uv run pytest tests/core/test_protocol.py -v
Ожидаем: ImportError: No module named 'core'
- Step 3: Создать
core/__init__.pyиcore/protocol.py
mkdir -p core/handlers
touch core/__init__.py core/handlers/__init__.py
# 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: Запустить тесты
uv run pytest tests/core/test_protocol.py -v
Ожидаем: 6 PASSED
- Step 5: Commit
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: Написать тест
# 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
uv run pytest tests/core/test_store.py -v
Ожидаем: ImportError: cannot import name 'InMemoryStore' from 'core.store'
- Step 3: Создать
core/store.py
# 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: Запустить тесты
uv run pytest tests/core/test_store.py -v
Ожидаем: 9 PASSED
- Step 5: Commit
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: Написать тест
# 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
uv run pytest tests/core/test_chat.py -v
Ожидаем: ImportError: cannot import name 'ChatManager' from 'core.chat'
- Step 3: Создать
core/chat.py
# 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: Запустить тесты
uv run pytest tests/core/test_chat.py -v
Ожидаем: 6 PASSED
- Step 5: Commit
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: Написать тест
# 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
uv run pytest tests/core/test_auth.py -v
Ожидаем: ImportError
- Step 3: Создать
core/auth.py
# 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: Запустить тесты
uv run pytest tests/core/test_auth.py -v
Ожидаем: 5 PASSED
- Step 5: Commit
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: Написать тест
# 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
uv run pytest tests/core/test_settings.py -v
Ожидаем: ImportError
- Step 3: Создать
core/settings.py
# 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: Запустить тесты
uv run pytest tests/core/test_settings.py -v
Ожидаем: 3 PASSED
- Step 5: Commit
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: Написать тест
# 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
uv run pytest tests/core/test_dispatcher.py -v
Ожидаем: ImportError
- Step 3: Создать
core/handler.py
# 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: Запустить тесты
uv run pytest tests/core/test_dispatcher.py -v
Ожидаем: 5 PASSED
- Step 5: Commit
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
# 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
uv run pytest tests/core/test_voice_slot.py -v
Ожидаем: ImportError
- Step 3: Создать
core/handlers/message.py
# 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 тесты
uv run pytest tests/core/test_voice_slot.py -v
Ожидаем: 3 PASSED
- Step 5: Создать остальные обработчики
# 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)]
# 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))]
# 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}.")]
# 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— зарегистрировать все обработчики
# 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: Запустить все тесты
uv run pytest tests/ -v
Ожидаем: все PASSED
- Step 8: Commit
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: Написать интеграционный тест
# 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: Запустить интеграционный тест
uv run pytest tests/core/test_integration.py -v
Ожидаем: 5 PASSED
- Step 3: Запустить все тесты финально
uv run pytest tests/ -v --tb=short
Ожидаем: все PASSED, 0 errors
- Step 4: Final commit
git add tests/core/test_integration.py
git commit -m "test: add integration smoke test for full dispatcher flow"