diff --git a/.env.example b/.env.example index 27fe5dd..4037b6d 100644 --- a/.env.example +++ b/.env.example @@ -34,4 +34,5 @@ SURFACES_BOT_STATE_VOLUME=surfaces-bot-state # MAX Surface MAX_BOT_TOKEN=real_max_token MAX_API_URL=https://platform-api.max.ru -MAX_AGENT_REGISTRY_PATH=/app/config/max-agents.yaml \ No newline at end of file +MAX_AGENT_REGISTRY_PATH=/app/config/max-agents.yaml +MAX_PLATFORM_BACKEND=mock # ← ДОБАВИТЬ: "mock" для локалки, "real" для прода \ No newline at end of file diff --git a/adapter/max/bot.py b/adapter/max/bot.py index 0200d3f..5bd9e61 100644 --- a/adapter/max/bot.py +++ b/adapter/max/bot.py @@ -33,13 +33,20 @@ from core.auth import AuthManager from core.chat import ChatManager from core.handler import EventDispatcher from core.handlers import register_all -from core.protocol import Attachment, IncomingCommand, OutgoingEvent, OutgoingMessage -from core.protocol import OutgoingNotification, OutgoingTyping, OutgoingUI +from core.protocol import ( + Attachment, + IncomingCommand, + IncomingMessage, + OutgoingEvent, + OutgoingMessage, + OutgoingNotification, + OutgoingTyping, + OutgoingUI +) from core.settings import SettingsManager from core.store import InMemoryStore, StateStore from sdk.interface import PlatformClient, PlatformError from sdk.prototype_state import PrototypeStateStore -from sdk.real import RealPlatformClient logger = structlog.get_logger(__name__) @@ -130,6 +137,7 @@ class MaxBotApp: self.registry: AgentRegistry = load_from_env() except (AgentRegistryError, OSError) as exc: raise RuntimeError("failed to load MAX agent registry") from exc + self.chat_store = ChatStore() self.max_chat_handler = MaxChatHandler(self.chat_store) @@ -138,22 +146,35 @@ class MaxBotApp: self.core_store: StateStore = InMemoryStore() self.prototype_state = PrototypeStateStore() - delegates: dict[str, RealPlatformClient] = {} - for agent in self.registry.agents: - base_raw = agent.base_url.strip() if agent.base_url else agent_base_url - delegates[agent.agent_id] = RealPlatformClient( - agent_id=agent.agent_id, - agent_base_url=base_raw, - prototype_state=self.prototype_state, - platform="max", - ) + backend_mode = os.environ.get("MAX_PLATFORM_BACKEND", "mock").strip().lower() + logger.info("max_platform_backend_selected", backend=backend_mode) - default_client = next(iter(delegates.values())) - self.platform: RoutedMaxPlatformClient = RoutedMaxPlatformClient( - chat_store=self.chat_store, - delegates=delegates, - default_client=default_client, - ) + if backend_mode == "real": + from sdk.real import RealPlatformClient + delegates: dict[str, RealPlatformClient] = {} + for agent in self.registry.agents: + base_raw = agent.base_url.strip() if agent.base_url else agent_base_url + delegates[agent.agent_id] = RealPlatformClient( + agent_id=agent.agent_id, + agent_base_url=base_raw, + prototype_state=self.prototype_state, + platform="max", + ) + + if not delegates: + raise RuntimeError("No agents configured for real backend") + + default_client = next(iter(delegates.values())) + self.platform: RoutedMaxPlatformClient = RoutedMaxPlatformClient( + chat_store=self.chat_store, + delegates=delegates, + default_client=default_client, + ) + else: + # Mock backend for local development/testing + logger.warning("max_using_mock_backend", note="No real agent connection") + from sdk.mock import MockPlatformClient + self.platform = MockPlatformClient() # type: ignore[assignment] self.chat_mgr = ChatManager(self.platform, self.core_store) self.auth_mgr = AuthManager(self.platform, self.core_store) @@ -244,6 +265,10 @@ class MaxBotApp: return refreshed async def process_message_created(self, payload: dict) -> None: + logger.info( + "DEBUG_PAYLOAD", + body=payload.get("message", {}).get("body"), + ) message = payload.get("message") if not isinstance(message, dict): return @@ -289,7 +314,9 @@ class MaxBotApp: attachments_core = await self._materialize_attachments(room, attachments_core, raw_meta) if attachments_core and not text: + logger.info("QUEUE_ADD", chat_key=chat_key, count=len(attachments_core)) for att in attachments_core: + logger.info("QUEUE_ITEM", filename=att.filename) self.chat_store.stage_attachment(chat_key, (att.workspace_path or "", att.filename or "file")) return @@ -345,6 +372,7 @@ class MaxBotApp: async def _handle_local_attachment_command(self, incoming: IncomingCommand, chat_key: str) -> str: if incoming.command == "list": + logger.info("QUEUE_LIST_REQUEST", chat_key=chat_key) return self.attach_handler.handle_list(chat_key) return self.attach_handler.handle_remove(chat_key, incoming.args[0] if incoming.args else "") @@ -463,7 +491,7 @@ class MaxBotApp: deeplink_note = f" (payload: {dl})" welcome = ( - "Здравствуйте, я помогу с задачами Lambda. " + "Здравствуйте, я бот Lambda, который готов помочь с задачами." f"Отправьте текст или файл.{deeplink_note}" ) diff --git a/adapter/max/converter.py b/adapter/max/converter.py index 758e2b1..f7d2584 100644 --- a/adapter/max/converter.py +++ b/adapter/max/converter.py @@ -47,6 +47,8 @@ def incoming_from_text_commands( raw = stripped[1:] parts = raw.split(maxsplit=1) + if not parts: + return None name = (parts[0] or "").lower() tail = parts[1] if len(parts) > 1 else "" diff --git a/adapter/max/files.py b/adapter/max/files.py index 8e70718..47433be 100644 --- a/adapter/max/files.py +++ b/adapter/max/files.py @@ -2,9 +2,63 @@ from __future__ import annotations import mimetypes -from pathlib import Path +import re +from pathlib import Path, PurePosixPath import httpx +from adapter.max.api_client import MaxBotApi + + +def _sanitize_filename(value: str) -> str: + """Безопасное имя файла с поддержкой кириллицы.""" + filename = PurePosixPath(str(value).replace("\\", "/")).name.strip() + cleaned = re.sub(r'[\x00-\x1f\x7f<>"|?*]+', "_", filename) + cleaned = cleaned.strip(" .") + return cleaned or "attachment.bin" + + +def _with_copy_index(filename: str, index: int) -> str: + """Generate filename with copy index: file.txt -> file (1).txt""" + path = Path(filename) + suffix = path.suffix + stem = path.stem if suffix else filename + return f"{stem} ({index}){suffix}" + + +def _unique_workspace_relative_path(workspace_root: Path, filename: str) -> tuple[str, Path]: + """Generate unique filename if file already exists in workspace.""" + safe_name = _sanitize_filename(filename) + candidate = workspace_root / safe_name + + if not candidate.exists(): + return safe_name, candidate + + index = 1 + while True: + indexed_name = _with_copy_index(safe_name, index) + candidate = workspace_root / indexed_name + if not candidate.exists(): + return indexed_name, candidate + index += 1 + + +def build_agent_workspace_path( + *, + workspace_root: Path, + filename: str, +) -> tuple[str, Path]: + """Saves user files directly to {workspace_root}/{filename}.""" + return _unique_workspace_relative_path(workspace_root, filename) + + +def resolve_workspace_attachment_path(workspace_root: Path, workspace_path: str) -> Path: + """Resolve relative workspace path to absolute Path object.""" + path = Path(workspace_path) + if path.is_absolute(): + return path + return workspace_root / path + +# === Конец вспомогательных функций === def guess_upload_type(mime_type: str | None, *, attachment_type: str) -> str: @@ -26,29 +80,34 @@ def guess_upload_type(mime_type: str | None, *, attachment_type: str) -> str: async def save_incoming_from_url( *, - api: MaxBotApi, + api: MaxBotApi, # Теперь тип известен workspace_root: Path, filename: str, url: str, ) -> str: + """Скачивает файл по URL и сохраняет в workspace.""" data = await api.download_file(url) workspace_root.mkdir(parents=True, exist_ok=True) + + # Используем добавленную функцию relative_path, absolute_path = build_agent_workspace_path( workspace_root=workspace_root, filename=filename, ) + absolute_path.parent.mkdir(parents=True, exist_ok=True) absolute_path.write_bytes(data) return relative_path async def upload_file_as_attachment( - api: MaxBotApi, + api: MaxBotApi, # Теперь тип известен *, filename: str, content: bytes, upload_type: str, ) -> dict: + """Загружает файл в MAX для отправки пользователю.""" meta = await api.get_upload_url(upload_type) upload_url = meta.get("url") token = meta.get("token") @@ -83,6 +142,8 @@ def guess_mimetype(filename: str) -> str: def read_workspace_bytes(workspace_path: str | Path, *, agent_workspace: str) -> bytes: + """Читает файл из workspace по относительному пути.""" root = Path(agent_workspace) + # Теперь эта функция определена выше resolved = resolve_workspace_attachment_path(root, str(workspace_path)) - return resolved.read_bytes() + return resolved.read_bytes() \ No newline at end of file diff --git a/adapter/max/handlers/help.py b/adapter/max/handlers/help.py index cad3e32..b066119 100644 --- a/adapter/max/handlers/help.py +++ b/adapter/max/handlers/help.py @@ -1,7 +1,7 @@ """Help text for MAX surface (single dialog, slash commands).""" HELP_TEXT = """ -Команды (/ как в Telegram): +Команды: /start — начать /help — эта справка @@ -16,9 +16,6 @@ HELP_TEXT = """ Подтверждения агента: /yes / /no - -Команды вида /new, /chats, /rename, /archive в MAX не нужны — -у вас один диалог с ботом; контекст сбрасывайте через /clear. """ diff --git a/pyproject.toml b/pyproject.toml index 73dfbd7..27fc01a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,3 +40,6 @@ target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "UP", "B"] + +[tool.setuptools.packages.find] +include = ["core*", "adapter*", "sdk*"]