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:
parent
961ee7bb0b
commit
b74277a189
6 changed files with 120 additions and 28 deletions
|
|
@ -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" для прода
|
||||
|
|
@ -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}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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.
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -40,3 +40,6 @@ target-version = "py311"
|
|||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["core*", "adapter*", "sdk*"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue