feat(max-bot): implement mock-based message flow with WIP file queue

- MAX API integration (long polling)
- MockPlatformClient for agent simulation
- File download & workspace storage
- Basic commands: /help, /start
- Attachment queue: add works, list/remove need testing

[WIP: attachment queue commands]
[MOCK-ONLY: requires real agent for production]
This commit is contained in:
Александра Пронина 2026-05-25 16:51:48 +03:00
parent 961ee7bb0b
commit b74277a189
6 changed files with 120 additions and 28 deletions

View file

@ -35,3 +35,4 @@ SURFACES_BOT_STATE_VOLUME=surfaces-bot-state
MAX_BOT_TOKEN=real_max_token
MAX_API_URL=https://platform-api.max.ru
MAX_AGENT_REGISTRY_PATH=/app/config/max-agents.yaml
MAX_PLATFORM_BACKEND=mock # ← ДОБАВИТЬ: "mock" для локалки, "real" для прода

View file

@ -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__)
@ -131,6 +138,7 @@ class MaxBotApp:
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)
self.attach_handler = AttachmentHandler(self.chat_store)
@ -138,6 +146,11 @@ class MaxBotApp:
self.core_store: StateStore = InMemoryStore()
self.prototype_state = PrototypeStateStore()
backend_mode = os.environ.get("MAX_PLATFORM_BACKEND", "mock").strip().lower()
logger.info("max_platform_backend_selected", backend=backend_mode)
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
@ -148,12 +161,20 @@ class MaxBotApp:
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}"
)

View file

@ -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 ""

View file

@ -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()

View file

@ -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.
"""

View file

@ -40,3 +40,6 @@ target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[tool.setuptools.packages.find]
include = ["core*", "adapter*", "sdk*"]