Compare commits

..

6 commits

Author SHA1 Message Date
4f5c5679d5 docs: sync all markdown with current architecture
Remove session management concepts (no create_session/close_session/
DELETE /sessions — Master handles container lifecycle automatically).
Update PlatformClient contract, ChatContext, project structure tree,
and FSM diagrams across all docs to match the implemented core/.

- README.md: fix core/ structure tree + PlatformClient snippet
- docs/surface-protocol.md: remove session.py/_template.py, fix
  ChatContext (drop session_id), fix PlatformClient contract, fix
  "free features" list
- docs/telegram-prototype.md: remove "создаёт сессию на платформе"
- docs/matrix-prototype.md: same + remove !sessions, fix FSM
  (SessionCreated → ChatCreated), fix status block
- docs/user-flow.md: rewrite sequence diagram to POST /users/{id}/
  chats/{id}/messages; update FSM states
2026-03-29 21:42:02 +03:00
36730ae716 feat: implement core/ and platform/ with full test coverage
- platform/interface.py: PlatformClient Protocol + Pydantic models (User,
  MessageResponse, UserSettings) — no explicit session management, Master
  handles container lifecycle
- platform/mock.py: MockPlatformClient with simulated latency, [MOCK]
  responses, is_new correctly True only on first creation
- core/protocol.py: unified dataclasses for all events and responses
  (IncomingMessage/Command/Callback, OutgoingMessage/UI/Notification,
  AuthFlow, ChatContext, SettingsAction, etc.)
- core/store.py: StateStore Protocol + InMemoryStore (tests) + SQLiteStore
  (prod) with JSON serialization
- core/chat.py: ChatManager — chat metadata (C1/C2/C3), not container
  lifecycle (that's the platform's job)
- core/auth.py: AuthManager — start_flow / confirm / is_authenticated
- core/settings.py: SettingsManager — get/apply with store cache
- core/handler.py: EventDispatcher — registry-based routing with keys
  (command name, action name, attachment type, "*" catch-all)
- core/handlers/: register_all() + start/new/message/callback/settings
  handlers; voice slot falls back to stub text until voice_handler added
- conftest.py: sys.path fix so local platform/ shadows stdlib platform
- docs/api-contract.md: rewritten for Lambda Lab 3.0 container model

46 tests passing, 0 warnings.
2026-03-29 21:42:02 +03:00
944c383552 fix: add setuptools-scm to build-system requires 2026-03-29 21:42:02 +03:00
ce8af9fa41 docs: add core implementation plan 2026-03-29 21:42:02 +03:00
53685747f6 docs: update core design and api-contract with platform container architecture 2026-03-29 21:42:02 +03:00
8e955045b2 docs: add core design spec (Registry + Handlers approach) 2026-03-29 21:42:02 +03:00
34 changed files with 3485 additions and 119 deletions

View file

@ -29,10 +29,12 @@
surfaces-bot/ surfaces-bot/
core/ — общее ядро, не зависит от транспорта core/ — общее ядро, не зависит от транспорта
protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...) protocol.py — унифицированные структуры (IncomingMessage, OutgoingUI, ...)
handler.py — логика: IncomingEvent → OutgoingEvent handler.py — EventDispatcher: IncomingEvent → OutgoingEvent
session.py — управление сессиями и чатами handlers/ — обработчики по типам событий
auth.py — аутентификация store.py — StateStore Protocol + InMemoryStore + SQLiteStore
settings.py — коннекторы, скиллы, SOUL, безопасность chat.py — ChatManager: метаданные чатов C1/C2/C3
auth.py — AuthManager: аутентификация
settings.py — SettingsManager: коннекторы, скиллы, SOUL, безопасность
adapter/ adapter/
telegram/ — aiogram 3.x адаптер telegram/ — aiogram 3.x адаптер
@ -75,14 +77,15 @@ surfaces-bot/
```python ```python
class PlatformClient(Protocol): class PlatformClient(Protocol):
async def get_or_create_user(...) -> User: ... async def get_or_create_user(self, external_id: str, platform: str, ...) -> User: ...
async def create_session(...) -> Session: ... async def send_message(self, user_id: str, chat_id: str, text: str, ...) -> MessageResponse: ...
async def send_message(...) -> AgentResponse: ... async def get_settings(self, user_id: str) -> UserSettings: ...
async def close_session(...) -> None: ... async def update_settings(self, user_id: str, action: Any) -> None: ...
async def get_settings(...) -> UserSettings: ...
async def update_settings(...) -> None: ...
``` ```
Бот не управляет lifecycle контейнеров — это делает Master (платформа).
Бот передаёт `user_id` + `chat_id` + сообщение; платформа сама решает нужно ли поднять контейнер.
Сейчас: `MockPlatformClient` в `platform/mock.py`. Сейчас: `MockPlatformClient` в `platform/mock.py`.
Когда SDK готов: добавляем `SdkPlatformClient`, меняем одну строку в DI. Адаптеры и ядро не трогаем. Когда SDK готов: добавляем `SdkPlatformClient`, меняем одну строку в DI. Адаптеры и ядро не трогаем.

13
conftest.py Normal file
View file

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

52
core/auth.py Normal file
View file

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

111
core/chat.py Normal file
View file

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

71
core/handler.py Normal file
View file

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

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

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

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

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

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

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

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

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

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

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

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

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

124
core/protocol.py Normal file
View file

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

29
core/settings.py Normal file
View file

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

73
core/store.py Normal file
View file

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

View file

@ -1,11 +1,18 @@
# API Contract — Lambda Platform # API Contract — Lambda Platform
> **Статус:** ЧЕРНОВИК — проектируем сами, не ждём SDK > **Статус:** ЧЕРНОВИК — проектируем сами, уточняем с Азаматом когда SDK будет готов
> **Автор:** @architect > **Последнее обновление:** 2026-03-29
> **Последнее обновление:** заполнить дату
Это описание того, что нам нужно от платформы. ---
`MockPlatformClient` реализует этот контракт. При подключении реального SDK — только он меняется.
## Архитектурный контекст
Каждому пользователю выделяется **один LXC-контейнер** с workspace 10 ГБ.
Workspace содержит директории чатов: `C1/`, `C2/`, `C3/` — файлы + `history.db` в каждом.
**Master** управляет lifecycle контейнера (запуск, заморозка, пробуждение).
Бот **не управляет lifecycle** — он передаёт `user_id` + `chat_id` + сообщение.
Master сам решает: нужно ли поднять контейнер, смонтировать нужный чат, запустить агента.
--- ---
@ -28,6 +35,7 @@ Authorization: Bearer {SERVICE_TOKEN}
## Users ## Users
### GET /users/{external_id}?platform={platform} ### GET /users/{external_id}?platform={platform}
Получает или создаёт пользователя. Получает или создаёт пользователя.
**Query params:** **Query params:**
@ -47,50 +55,15 @@ Authorization: Bearer {SERVICE_TOKEN}
--- ---
## Sessions
### POST /sessions
Создаёт новую сессию с AI-агентом.
**Request:**
```json
{
"user_id": "usr_abc123",
"platform": "telegram",
"context": {}
}
```
**Response 201:**
```json
{
"session_id": "ses_xyz789",
"agent_id": "agt_def456",
"created_at": "2025-01-15T10:30:00Z",
"expires_at": "2025-01-16T10:30:00Z"
}
```
### GET /sessions/{session_id}
Получает информацию о сессии.
**Response 200:** — см. структуру выше + поле `status: "active" | "closed"`
**Response 404:** `{"error": "SESSION_NOT_FOUND", "message": "..."}`
### DELETE /sessions/{session_id}
Завершает сессию.
**Response 200:**
```json
{"closed": true}
```
---
## Messages ## Messages
### POST /sessions/{session_id}/messages Бот не управляет сессиями явно. Отправка сообщения — единственная операция.
Отправляет сообщение и получает ответ агента. Master решает: нужен ли новый контейнер, или разбудить существующий.
### POST /users/{user_id}/chats/{chat_id}/messages
Отправляет сообщение пользователя агенту. Master поднимает/размораживает контейнер,
монтирует нужный чат (`C1/`, `C2/`...), запускает агента.
**Request:** **Request:**
```json ```json
@ -110,28 +83,46 @@ Authorization: Bearer {SERVICE_TOKEN}
} }
``` ```
### GET /sessions/{session_id}/messages?limit=20&offset=0 ---
История сообщений сессии.
## Settings
### GET /users/{user_id}/settings
Настройки пользователя: скиллы, коннекторы, SOUL, безопасность, план.
**Response 200:** **Response 200:**
```json ```json
[ {
{ "skills": {"web-search": true, "browser": false},
"message_id": "msg_qwe012", "connectors": {"gmail": {"connected": true, "email": "user@gmail.com"}},
"user_text": "Привет", "soul": {"name": "Лямбда", "style": "friendly"},
"response": "Привет!", "safety": {"email-send": true, "file-delete": true},
"tokens_used": 42, "plan": {"name": "Beta", "tokens_used": 800, "tokens_limit": 1000}
"created_at": "2025-01-15T10:31:00Z" }
} ```
]
### POST /users/{user_id}/settings
Применяет действие над настройками.
**Request:**
```json
{
"action": "toggle_skill",
"payload": {"skill": "browser", "enabled": true}
}
```
**Response 200:**
```json
{"ok": true}
``` ```
--- ---
## Error format ## Error format
Все ошибки возвращаются в едином формате:
```json ```json
{ {
"error": "ERROR_CODE", "error": "ERROR_CODE",
@ -140,11 +131,13 @@ Authorization: Bearer {SERVICE_TOKEN}
} }
``` ```
Коды ошибок: `SESSION_NOT_FOUND`, `USER_NOT_FOUND`, `RATE_LIMITED`, `PLATFORM_ERROR` Коды ошибок: `USER_NOT_FOUND`, `RATE_LIMITED`, `PLATFORM_ERROR`, `CONTAINER_UNAVAILABLE`
--- ---
## TODO (открытые вопросы к команде платформы) ## Открытые вопросы к команде платфрмы (SDK)
- [ ] Нужна ли стриминговая передача ответа (SSE / WebSocket)? - [ ] Точный формат эндпоинта отправки сообщения — URL, поля
- [ ] Как обрабатываются вложения (изображения, файлы)? - [ ] Как передавать вложения (файлы, изображения)? Через S3 pre-signed URL или напрямую?
- [ ] Стриминговый ответ (SSE / WebSocket) или только sync?
- [ ] Формат `SettingsAction` — совпадает с нашим или другой?

View file

@ -65,12 +65,12 @@ Space: «Lambda — {display_name}»
1. Пользователь пишет `!new` или `!new Анализ конкурентов` 1. Пользователь пишет `!new` или `!new Анализ конкурентов`
2. Бот создаёт новую комнату в Space 2. Бот создаёт новую комнату в Space
3. Приглашает пользователя 3. Приглашает пользователя
4. Пишет приветствие и создаёт сессию на платформе 4. Пишет приветствие; при первом сообщении платформа автоматически поднимает контейнер
5. Пользователь переходит в новую комнату — начинает диалог 5. Пользователь переходит в новую комнату — начинает диалог
### В моке ### В моке
- Space и комнаты создаются реально через matrix-nio - Space и комнаты создаются реально через matrix-nio
- Сессии — через MockPlatformClient - Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...)
- История хранится в Matrix нативно - История хранится в Matrix нативно
--- ---
@ -208,15 +208,13 @@ Matrix поддерживает реакции на сообщения (`m.react
### Статус и диагностика ### Статус и диагностика
``` ```
!status — состояние агента и платформы !status — состояние платформы и чатов
!sessions — список активных сессий
!whoami — текущий аккаунт платформы !whoami — текущий аккаунт платформы
``` ```
``` ```
Статус: Статус:
Платформа: ✅ доступна Платформа: ✅ доступна
Агент: ✅ активен (сессия #abc123)
Аккаунт: user@lambda.lab Аккаунт: user@lambda.lab
Активных чатов: 3 Активных чатов: 3
``` ```
@ -230,7 +228,7 @@ Matrix поддерживает реакции на сообщения (`m.react
SpaceSetup → Idle (в комнате Настройки) SpaceSetup → Idle (в комнате Настройки)
[новая комната] → SessionCreated → Idle (в чате) [новая комната] → ChatCreated → Idle (в чате)
ReceivingMessage → WaitingResponse → Idle ReceivingMessage → WaitingResponse → Idle

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,257 @@
# Core Design — surfaces-bot
> **Статус:** approved
> **Дата:** 2026-03-28
> **Подход:** Registry + Handlers (Подход 2)
---
## Контекст
Два бота (Telegram, Matrix) разделяют общую бизнес-логику через `core/`.
Цель — написать логику один раз, легко добавлять новые поверхности и новый функционал
без правки центральных файлов.
### Архитектура платформы (важно для дизайна)
Lambda Lab 3.0 выделяет каждому пользователю **один LXC-контейнер** с workspace 10 ГБ.
Workspace содержит директории чатов: `C1/`, `C2/`, `C3/` — каждый чат хранит свои файлы и `history.db`.
**Master** — управляющий процесс платформы — сам решает когда поднять, заморозить или разбудить контейнер.
Бот не управляет lifecycle контейнера — он только передаёт сообщение (`user_id`, `chat_id`, `text`).
Следствие: **"сессия" и "чат" — разные понятия.**
- Чат (C1/C2/C3) — наша забота, храним метаданные в `StateStore`
- Контейнер (сессия платформы) — забота Master'а, бот об этом не знает
---
## Структура файлов
```
core/
__init__.py
protocol.py — dataclasses: IncomingMessage, IncomingCommand, IncomingCallback,
OutgoingMessage, OutgoingUI, OutgoingNotification, OutgoingTyping,
Attachment, ChatContext, AuthFlow, ConfirmationRequest,
SettingsAction, PaymentRequired
handler.py — EventDispatcher (только маршрутизация, без бизнес-логики)
store.py — StateStore Protocol + InMemoryStore + SQLiteStore
chat.py — ChatManager (бывший SessionManager — управляет чатами, не контейнерами)
auth.py — AuthManager
settings.py — SettingsManager
handlers/
__init__.py — регистрация всех обработчиков в dispatcher
start.py — /start, !start → AuthFlow + приветствие
chat.py — /new, /rename, /archive, /chats
message.py — текстовое сообщение + voice slot
callback.py — confirm/cancel ConfirmationRequest, toggle_skill
settings.py — все SettingsAction (connectors, skills, soul, safety, plan)
auth.py — auth state machine (pending → confirmed → failed)
platform/
interface.py — PlatformClient Protocol + Pydantic модели (User, Session, MessageResponse)
mock.py — MockPlatformClient
tests/
core/
test_dispatcher.py
test_session.py
test_auth.py
test_settings.py
test_voice_slot.py
adapter/
telegram/
matrix/
platform/
test_mock_platform.py — переезжает из tests/test_mock_platform.py
```
**Миграция `src/`:** при реализации — первый шаг:
- `src/shared/models.py` → объединить с `platform/interface.py`
- `src/mock_platform.py``platform/mock.py`
- `src/` удалить
---
## EventDispatcher
`handler.py` — тупой роутер. Маршрутизирует по двум ключам: тип события + команда/действие.
```python
# Сигнатура регистрации
dispatcher.register(IncomingCommand, "start", start_handler)
dispatcher.register(IncomingCommand, "new", chat_handler.new_chat)
dispatcher.register(IncomingCommand, "archive", chat_handler.archive)
dispatcher.register(IncomingMessage, "*", message_handler) # catch-all
dispatcher.register(IncomingMessage, "audio", voice_handler) # voice slot
dispatcher.register(IncomingCallback, "confirm", callback_handler.confirm)
dispatcher.register(IncomingCallback, "toggle_skill",callback_handler.toggle_skill)
# Сигнатура любого обработчика
async def handle_xxx(
event: IncomingEvent,
session_mgr: SessionManager,
auth_mgr: AuthManager,
settings_mgr: SettingsManager,
platform: PlatformClient,
) -> list[OutgoingEvent]:
...
```
**Правила маршрутизации:**
- `IncomingCommand` → ключ = `event.command`
- `IncomingCallback` → ключ = `event.action`
- `IncomingMessage` → ключ = тип первого вложения если есть (`"audio"`, `"image"`, ...), иначе `"*"`
**Добавить новый обработчик** = новый файл в `handlers/` + одна строка `dispatcher.register(...)`.
`handler.py` не трогается.
---
## Голосовые сообщения (voice slot)
`IncomingMessage` несёт `Attachment(type="audio", ...)`.
Dispatcher маршрутизирует `IncomingMessage` с audio-вложением на `"audio"` ключ.
**Fallback:** если ключ `"audio"` не зарегистрирован — dispatcher падает на `"*"` catch-all.
`message_handler` (catch-all) проверяет тип вложения и возвращает заглушку:
```python
OutgoingMessage(chat_id=..., text="Голосовые сообщения скоро поддержим.", parse_mode="plain")
```
Когда будет готова транскрипция — регистрируем `voice_handler` для ключа `"audio"`.
Dispatcher перестаёт отдавать audio в catch-all. `message_handler` не меняется.
---
## protocol.py — модели данных
Используем `dataclass` (не Pydantic) — это внутренний язык ядра, валидация не нужна:
```python
@dataclass
class IncomingMessage:
user_id: str
platform: str
chat_id: str
text: str
attachments: list[Attachment] = field(default_factory=list)
reply_to: str | None = None
@dataclass
class Attachment:
type: str # "image" | "document" | "audio" | "video"
url: str | None = None
content: bytes | None = None
filename: str | None = None
mime_type: str | None = None
```
Полный список структур: IncomingMessage, IncomingCommand, IncomingCallback,
OutgoingMessage, OutgoingUI, OutgoingNotification, OutgoingTyping, UIButton,
Attachment, ChatContext, AuthFlow, ConfirmationRequest, SettingsAction, PaymentRequired.
---
## platform/interface.py — модели платформы
Используем Pydantic — это граница с внешним API где валидация нужна:
```python
class User(BaseModel):
user_id: str
external_id: str
platform: str
display_name: str | None = None
created_at: datetime
is_new: bool = False
class PlatformClient(Protocol):
async def get_or_create_user(self, external_id: str, platform: str, display_name: str | None) -> User: ...
# Master сам решает: поднять контейнер, разбудить, смонтировать нужный чат.
# Бот передаёт только user_id + chat_id — платформа делает остальное.
async def send_message(self, user_id: str, chat_id: str, text: str, attachments: list) -> MessageResponse: ...
async def get_settings(self, user_id: str) -> UserSettings: ...
async def update_settings(self, user_id: str, action: SettingsAction) -> None: ...
```
**Нет явных create/close session** — lifecycle контейнера управляется Master'ом, не ботом.
Когда будет готов реальный SDK Азамата — контракт уточняется, меняется только `platform/mock.py`.
---
## Managers
Все три менеджера принимают зависимости через конструктор (DI), разделяют один `StateStore`.
### StateStore (`core/store.py`)
```python
class StateStore(Protocol):
async def get(self, key: str) -> dict | None
async def set(self, key: str, value: dict) -> None
async def delete(self, key: str) -> None
```
Реализации: `InMemoryStore` (тесты), `SQLiteStore` (прод).
### ChatManager (`core/chat.py`)
Управляет метаданными чатов (C1/C2/C3). НЕ управляет lifecycle контейнера — это дело Master'а.
```python
class ChatManager:
def __init__(self, platform: PlatformClient, store: StateStore): ...
async def get_or_create(self, user_id: str, chat_id: str, name: str | None) -> ChatContext: ...
async def rename(self, chat_id: str, name: str) -> ChatContext: ...
async def archive(self, chat_id: str) -> None: ...
async def list_active(self, user_id: str) -> list[ChatContext]: ...
```
Метаданные (display_name, platform, surface_ref, is_archived) хранятся в `StateStore`.
Файлы чата и history.db живут в workspace контейнера на стороне платформы — бот их не хранит.
### AuthManager (`core/auth.py`)
```python
class AuthManager:
def __init__(self, platform: PlatformClient, store: StateStore): ...
async def start_flow(self, user_id: str, platform: str) -> AuthFlow: ...
async def confirm(self, user_id: str) -> AuthFlow: ... # в моке — автоматически
async def is_authenticated(self, user_id: str) -> bool: ...
```
### SettingsManager (`core/settings.py`)
```python
class SettingsManager:
def __init__(self, platform: PlatformClient, store: StateStore): ...
async def apply(self, user_id: str, action: SettingsAction) -> None: ...
async def get(self, user_id: str) -> UserSettings: ...
```
---
## Тестирование
| Слой | Что тестируем | Зависимости |
|------|--------------|-------------|
| `tests/core/` | обработчики, менеджеры, dispatcher | MockPlatformClient + InMemoryStore |
| `tests/adapter/telegram/` | конвертацию aiogram → IncomingEvent | без платформы |
| `tests/adapter/matrix/` | конвертацию matrix-nio → IncomingEvent | без платформы |
| `tests/platform/` | MockPlatformClient | без ядра |
**Voice slot test:** `IncomingMessage` с `audio`-вложением при незарегистрированном `voice_handler`
возвращает `OutgoingMessage` с текстом-заглушкой. После регистрации — маршрутизируется корректно.
---
## Принципы расширяемости
1. **Новая команда** — новый файл `handlers/xxx.py` + `dispatcher.register(...)`
2. **Новый тип вложения** — новый ключ в register, остальное не меняется
3. **Новая поверхность** — новый `adapter/xxx/` с конвертером, core/ не трогается
4. **Замена mock на SDK** — только `platform/mock.py`, всё остальное не меняется

View file

@ -23,10 +23,12 @@ Surface Protocol — это общий язык между адаптерами
surfaces-bot/ surfaces-bot/
core/ core/
protocol.py — все унифицированные структуры данных protocol.py — все унифицированные структуры данных
handler.py — логика: IncomingEvent → OutgoingEvent handler.py — EventDispatcher: IncomingEvent → OutgoingEvent
session.py — управление сессиями и чатами handlers/ — обработчики по типам событий (start, message, chat, settings, callback)
auth.py — AuthFlow store.py — StateStore Protocol + InMemoryStore + SQLiteStore
settings.py — SettingsAction, управление настройками chat.py — ChatManager: метаданные чатов C1/C2/C3
auth.py — AuthManager, AuthFlow
settings.py — SettingsManager, SettingsAction
adapter/ adapter/
telegram/ — aiogram адаптер telegram/ — aiogram адаптер
@ -35,7 +37,6 @@ surfaces-bot/
matrix/ — matrix-nio адаптер matrix/ — matrix-nio адаптер
converter.py — matrix-nio Event → IncomingEvent, OutgoingEvent → Matrix API converter.py — matrix-nio Event → IncomingEvent, OutgoingEvent → Matrix API
bot.py — точка входа, клиент bot.py — точка входа, клиент
_template.py — шаблон для новой поверхности
platform/ platform/
interface.py — Protocol: PlatformClient interface.py — Protocol: PlatformClient
@ -169,16 +170,15 @@ class OutgoingTyping:
Унифицированные события для управления чатами и подключением. Унифицированные события для управления чатами и подключением.
### ChatContext ### ChatContext
Состояние чата — общее для всех поверхностей. Метаданные чата — общие для всех поверхностей. Хранятся ботом, lifecycle контейнера управляет платформа (Master).
```python ```python
@dataclass @dataclass
class ChatContext: class ChatContext:
chat_id: str # "C1" | "C2" — ID в воркспейсе платформы chat_id: str # "C1" | "C2" — ID чата в воркспейсе платформы
display_name: str # «Чат 1» | «Анализ рынка» display_name: str # «Чат 1» | «Анализ рынка»
platform: str platform: str
surface_ref: str # room_id в Matrix | topic_id в Telegram surface_ref: str # room_id в Matrix | topic_id в Telegram
session_id: str | None # активная сессия платформы
created_at: datetime created_at: datetime
is_archived: bool is_archived: bool
``` ```
@ -281,8 +281,7 @@ class MySurfaceAdapter:
Любая новая поверхность получает без дополнительного кода: Любая новая поверхность получает без дополнительного кода:
- управление сессиями (создание, переключение, закрытие) - управление чатами (`ChatContext`, C1/C2/C3)
- управление чатами (`ChatContext`)
- аутентификацию (`AuthFlow`) - аутентификацию (`AuthFlow`)
- подтверждение действий (`ConfirmationRequest`) - подтверждение действий (`ConfirmationRequest`)
- все настройки (коннекторы, скиллы, SOUL, безопасность, подписка) - все настройки (коннекторы, скиллы, SOUL, безопасность, подписка)
@ -297,15 +296,17 @@ class MySurfaceAdapter:
```python ```python
class PlatformClient(Protocol): class PlatformClient(Protocol):
async def get_or_create_user(self, user_id: str, platform: str) -> User: ... async def get_or_create_user(self, external_id: str, platform: str,
async def create_session(self, user_id: str, chat_id: str) -> Session: ... display_name: str | None = None) -> User: ...
async def send_message(self, session_id: str, text: str, attachments: list) -> AgentResponse: ... async def send_message(self, user_id: str, chat_id: str, text: str,
async def close_session(self, session_id: str) -> None: ... attachments: list | None = None) -> MessageResponse: ...
async def get_chat_history(self, user_id: str, chat_id: str) -> list[Message]: ...
async def get_settings(self, user_id: str) -> UserSettings: ... async def get_settings(self, user_id: str) -> UserSettings: ...
async def update_settings(self, user_id: str, action: SettingsAction) -> None: ... async def update_settings(self, user_id: str, action: Any) -> None: ...
``` ```
Бот **не управляет lifecycle контейнеров** — это делает Master (платформа).
Бот передаёт `user_id` + `chat_id` + текст; Master сам решает нужно ли поднять контейнер, смонтировать `C1/`/`C2/`, запустить агента.
`MockPlatformClient` реализует этот протокол сейчас. `MockPlatformClient` реализует этот протокол сейчас.
Реальный SDK — тоже реализует этот протокол, заменяя один файл. Реальный SDK — тоже реализует этот протокол, заменяя один файл.
Адаптеры поверхностей и ядро не меняются вообще. Адаптеры поверхностей и ядро не меняются вообще.

View file

@ -51,11 +51,11 @@
1. Пользователь пишет `/new` или нажимает кнопку 1. Пользователь пишет `/new` или нажимает кнопку
2. Бот спрашивает название (опционально, можно пропустить) 2. Бот спрашивает название (опционально, можно пропустить)
3. Бот создаёт новую тему в группе: «Чат 1», «Чат 2» и т.д. 3. Бот создаёт новую тему в группе: «Чат 1», «Чат 2» и т.д.
4. Бот отправляет в новую тему приветствие и создаёт сессию на платформе 4. Бот отправляет в новую тему приветствие; при первом сообщении платформа автоматически поднимает контейнер
### В моке ### В моке
- Группа и темы создаются реально через Bot API - Группа и темы создаются реально через Bot API
- Сессии на платформе — через MockPlatformClient - Сообщения передаются в MockPlatformClient с `chat_id` (C1, C2...)
- История в темах хранится нативно в Telegram, ничего не нужно делать - История в темах хранится нативно в Telegram, ничего не нужно делать
--- ---

View file

@ -11,7 +11,7 @@
sequenceDiagram sequenceDiagram
actor User actor User
participant Bot as Telegram/Matrix Bot participant Bot as Telegram/Matrix Bot
participant Platform as Lambda Platform participant Platform as Lambda Platform (Master)
User->>Bot: /start User->>Bot: /start
Bot->>Platform: GET /users/{tg_id}?platform=telegram Bot->>Platform: GET /users/{tg_id}?platform=telegram
@ -23,20 +23,13 @@ sequenceDiagram
Bot->>User: Добро пожаловать обратно Bot->>User: Добро пожаловать обратно
end end
User->>Bot: Любое сообщение loop Диалог (бот не управляет сессиями — Master делает это автоматически)
Bot->>Platform: POST /sessions (создаём сессию) User->>Bot: Сообщение в чат C1/C2/...
Platform-->>Bot: {session_id, agent_id} Bot->>Platform: POST /users/{user_id}/chats/{chat_id}/messages
Note over Platform: Master поднимает контейнер,<br/>монтирует нужный чат, запускает агента
loop Диалог Platform-->>Bot: {message_id, response, tokens_used}
User->>Bot: Сообщение
Bot->>Platform: POST /sessions/{id}/messages
Platform-->>Bot: {response}
Bot->>User: Ответ агента Bot->>User: Ответ агента
end end
User->>Bot: /end или таймаут
Bot->>Platform: DELETE /sessions/{id}
Bot->>User: Сессия завершена
``` ```
--- ---
@ -45,15 +38,19 @@ sequenceDiagram
```mermaid ```mermaid
stateDiagram-v2 stateDiagram-v2
[*] --> Idle: /start [*] --> Unauthenticated: первый контакт
Idle --> InSession: любое сообщение Unauthenticated --> Idle: /start (auth confirmed)
InSession --> InSession: сообщение пользователя
InSession --> Idle: /end Idle --> WaitingResponse: сообщение пользователя
WaitingResponse --> Idle: ответ получен
WaitingResponse --> Error: ошибка платформы
Idle --> Idle: /new (создан новый чат)
Idle --> ConfirmAction: агент запрашивает подтверждение
ConfirmAction --> Idle: подтверждено / отменено
InSession --> Error: ошибка платформы
Error --> Idle: /start Error --> Idle: /start
Error --> InSession: retry
``` ```
--- ---

0
platform/__init__.py Normal file
View file

59
platform/interface.py Normal file
View file

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

137
platform/mock.py Normal file
View file

@ -0,0 +1,137 @@
# platform/mock.py
from __future__ import annotations
import asyncio
import random
import uuid
from datetime import UTC, datetime
from typing import Any
import structlog
from platform.interface import MessageResponse, User, UserSettings
logger = structlog.get_logger(__name__)
class MockPlatformClient:
"""
Заглушка SDK платформы Lambda.
Реализует PlatformClient Protocol. При подключении реального SDK
заменяется только этот файл core/ и адаптеры не трогаются.
Ключевое отличие от реальной платформы: не управляет lifecycle контейнера.
Master делает это сам при получении send_message.
"""
def __init__(self) -> None:
self._users: dict[str, dict] = {}
self._messages: dict[str, list] = {} # "{user_id}:{chat_id}" → messages
self._settings: dict[str, dict] = {}
logger.info("MockPlatformClient initialized")
async def get_or_create_user(
self,
external_id: str,
platform: str,
display_name: str | None = None,
) -> User:
await self._latency()
key = f"{platform}:{external_id}"
is_new = key not in self._users
if is_new:
self._users[key] = {
"user_id": f"usr-{platform}-{external_id}",
"external_id": external_id,
"platform": platform,
"display_name": display_name,
"created_at": "2025-01-01T00:00:00Z",
"is_new": True,
}
data = {**self._users[key], "is_new": is_new}
return User(**data)
async def send_message(
self,
user_id: str,
chat_id: str,
text: str,
attachments: list | None = None,
) -> MessageResponse:
await self._latency(200, 600)
key = f"{user_id}:{chat_id}"
if key not in self._messages:
self._messages[key] = []
message_id = str(uuid.uuid4())
preview = text[:50] + ("..." if len(text) > 50 else "")
response = f"[MOCK] Ответ на: «{preview}»"
self._messages[key].append({
"message_id": message_id,
"user_text": text,
"response": response,
"tokens_used": len(text.split()) * 2,
"finished": True,
"created_at": datetime.now(UTC).isoformat(),
})
logger.info("Message sent", user_id=user_id, chat_id=chat_id, message_id=message_id)
return MessageResponse(
message_id=message_id,
response=response,
tokens_used=len(text.split()) * 2,
finished=True,
)
async def get_settings(self, user_id: str) -> UserSettings:
await self._latency()
stored = self._settings.get(user_id, {})
return UserSettings(
skills=stored.get("skills", {
"web-search": True,
"fetch-url": True,
"email": False,
"browser": False,
"image-gen": False,
"files": True,
}),
connectors=stored.get("connectors", {}),
soul=stored.get("soul", {"name": "Лямбда", "style": "friendly"}),
safety=stored.get("safety", {
"email-send": True,
"file-delete": True,
"social-post": True,
}),
plan=stored.get("plan", {
"name": "Beta",
"tokens_used": 0,
"tokens_limit": 1000,
}),
)
async def update_settings(self, user_id: str, action: Any) -> None:
await self._latency()
settings = self._settings.setdefault(user_id, {})
if action.action == "toggle_skill":
skills = settings.setdefault("skills", {})
skills[action.payload["skill"]] = action.payload.get("enabled", True)
elif action.action == "set_soul":
soul = settings.setdefault("soul", {})
soul[action.payload["field"]] = action.payload["value"]
elif action.action == "set_safety":
safety = settings.setdefault("safety", {})
safety[action.payload["trigger"]] = action.payload.get("enabled", True)
logger.info("Settings updated", user_id=user_id, action=action.action)
def get_stats(self) -> dict:
return {
"total_users": len(self._users),
"total_messages": sum(len(msgs) for msgs in self._messages.values()),
}
async def _latency(self, min_ms: int = 10, max_ms: int = 80) -> None:
await asyncio.sleep(random.randint(min_ms, max_ms) / 1000)

View file

@ -1,6 +1,6 @@
[build-system] [build-system]
requires = ["setuptools>=68", "wheel"] requires = ["setuptools>=68", "setuptools-scm", "wheel"]
build-backend = "setuptools.backends.legacy:build" build-backend = "setuptools.build_meta"
[project] [project]
name = "surfaces-bot" name = "surfaces-bot"
@ -29,6 +29,7 @@ dev = [
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["."]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100

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

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

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

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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