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