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_BOT_TOKEN=real_max_token
|
||||||
MAX_API_URL=https://platform-api.max.ru
|
MAX_API_URL=https://platform-api.max.ru
|
||||||
MAX_AGENT_REGISTRY_PATH=/app/config/max-agents.yaml
|
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.chat import ChatManager
|
||||||
from core.handler import EventDispatcher
|
from core.handler import EventDispatcher
|
||||||
from core.handlers import register_all
|
from core.handlers import register_all
|
||||||
from core.protocol import Attachment, IncomingCommand, OutgoingEvent, OutgoingMessage
|
from core.protocol import (
|
||||||
from core.protocol import OutgoingNotification, OutgoingTyping, OutgoingUI
|
Attachment,
|
||||||
|
IncomingCommand,
|
||||||
|
IncomingMessage,
|
||||||
|
OutgoingEvent,
|
||||||
|
OutgoingMessage,
|
||||||
|
OutgoingNotification,
|
||||||
|
OutgoingTyping,
|
||||||
|
OutgoingUI
|
||||||
|
)
|
||||||
from core.settings import SettingsManager
|
from core.settings import SettingsManager
|
||||||
from core.store import InMemoryStore, StateStore
|
from core.store import InMemoryStore, StateStore
|
||||||
from sdk.interface import PlatformClient, PlatformError
|
from sdk.interface import PlatformClient, PlatformError
|
||||||
from sdk.prototype_state import PrototypeStateStore
|
from sdk.prototype_state import PrototypeStateStore
|
||||||
from sdk.real import RealPlatformClient
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
@ -131,6 +138,7 @@ class MaxBotApp:
|
||||||
except (AgentRegistryError, OSError) as exc:
|
except (AgentRegistryError, OSError) as exc:
|
||||||
raise RuntimeError("failed to load MAX agent registry") from exc
|
raise RuntimeError("failed to load MAX agent registry") from exc
|
||||||
|
|
||||||
|
|
||||||
self.chat_store = ChatStore()
|
self.chat_store = ChatStore()
|
||||||
self.max_chat_handler = MaxChatHandler(self.chat_store)
|
self.max_chat_handler = MaxChatHandler(self.chat_store)
|
||||||
self.attach_handler = AttachmentHandler(self.chat_store)
|
self.attach_handler = AttachmentHandler(self.chat_store)
|
||||||
|
|
@ -138,6 +146,11 @@ class MaxBotApp:
|
||||||
self.core_store: StateStore = InMemoryStore()
|
self.core_store: StateStore = InMemoryStore()
|
||||||
self.prototype_state = PrototypeStateStore()
|
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] = {}
|
delegates: dict[str, RealPlatformClient] = {}
|
||||||
for agent in self.registry.agents:
|
for agent in self.registry.agents:
|
||||||
base_raw = agent.base_url.strip() if agent.base_url else agent_base_url
|
base_raw = agent.base_url.strip() if agent.base_url else agent_base_url
|
||||||
|
|
@ -148,12 +161,20 @@ class MaxBotApp:
|
||||||
platform="max",
|
platform="max",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not delegates:
|
||||||
|
raise RuntimeError("No agents configured for real backend")
|
||||||
|
|
||||||
default_client = next(iter(delegates.values()))
|
default_client = next(iter(delegates.values()))
|
||||||
self.platform: RoutedMaxPlatformClient = RoutedMaxPlatformClient(
|
self.platform: RoutedMaxPlatformClient = RoutedMaxPlatformClient(
|
||||||
chat_store=self.chat_store,
|
chat_store=self.chat_store,
|
||||||
delegates=delegates,
|
delegates=delegates,
|
||||||
default_client=default_client,
|
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.chat_mgr = ChatManager(self.platform, self.core_store)
|
||||||
self.auth_mgr = AuthManager(self.platform, self.core_store)
|
self.auth_mgr = AuthManager(self.platform, self.core_store)
|
||||||
|
|
@ -244,6 +265,10 @@ class MaxBotApp:
|
||||||
return refreshed
|
return refreshed
|
||||||
|
|
||||||
async def process_message_created(self, payload: dict) -> None:
|
async def process_message_created(self, payload: dict) -> None:
|
||||||
|
logger.info(
|
||||||
|
"DEBUG_PAYLOAD",
|
||||||
|
body=payload.get("message", {}).get("body"),
|
||||||
|
)
|
||||||
message = payload.get("message")
|
message = payload.get("message")
|
||||||
if not isinstance(message, dict):
|
if not isinstance(message, dict):
|
||||||
return
|
return
|
||||||
|
|
@ -289,7 +314,9 @@ class MaxBotApp:
|
||||||
attachments_core = await self._materialize_attachments(room, attachments_core, raw_meta)
|
attachments_core = await self._materialize_attachments(room, attachments_core, raw_meta)
|
||||||
|
|
||||||
if attachments_core and not text:
|
if attachments_core and not text:
|
||||||
|
logger.info("QUEUE_ADD", chat_key=chat_key, count=len(attachments_core))
|
||||||
for att in 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"))
|
self.chat_store.stage_attachment(chat_key, (att.workspace_path or "", att.filename or "file"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -345,6 +372,7 @@ class MaxBotApp:
|
||||||
|
|
||||||
async def _handle_local_attachment_command(self, incoming: IncomingCommand, chat_key: str) -> str:
|
async def _handle_local_attachment_command(self, incoming: IncomingCommand, chat_key: str) -> str:
|
||||||
if incoming.command == "list":
|
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_list(chat_key)
|
||||||
return self.attach_handler.handle_remove(chat_key, incoming.args[0] if incoming.args else "")
|
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})"
|
deeplink_note = f" (payload: {dl})"
|
||||||
|
|
||||||
welcome = (
|
welcome = (
|
||||||
"Здравствуйте, я помогу с задачами Lambda. "
|
"Здравствуйте, я бот Lambda, который готов помочь с задачами."
|
||||||
f"Отправьте текст или файл.{deeplink_note}"
|
f"Отправьте текст или файл.{deeplink_note}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ def incoming_from_text_commands(
|
||||||
|
|
||||||
raw = stripped[1:]
|
raw = stripped[1:]
|
||||||
parts = raw.split(maxsplit=1)
|
parts = raw.split(maxsplit=1)
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
name = (parts[0] or "").lower()
|
name = (parts[0] or "").lower()
|
||||||
tail = parts[1] if len(parts) > 1 else ""
|
tail = parts[1] if len(parts) > 1 else ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,63 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from pathlib import Path
|
import re
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
import httpx
|
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:
|
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(
|
async def save_incoming_from_url(
|
||||||
*,
|
*,
|
||||||
api: MaxBotApi,
|
api: MaxBotApi, # Теперь тип известен
|
||||||
workspace_root: Path,
|
workspace_root: Path,
|
||||||
filename: str,
|
filename: str,
|
||||||
url: str,
|
url: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
"""Скачивает файл по URL и сохраняет в workspace."""
|
||||||
data = await api.download_file(url)
|
data = await api.download_file(url)
|
||||||
workspace_root.mkdir(parents=True, exist_ok=True)
|
workspace_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Используем добавленную функцию
|
||||||
relative_path, absolute_path = build_agent_workspace_path(
|
relative_path, absolute_path = build_agent_workspace_path(
|
||||||
workspace_root=workspace_root,
|
workspace_root=workspace_root,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
)
|
)
|
||||||
|
|
||||||
absolute_path.parent.mkdir(parents=True, exist_ok=True)
|
absolute_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
absolute_path.write_bytes(data)
|
absolute_path.write_bytes(data)
|
||||||
return relative_path
|
return relative_path
|
||||||
|
|
||||||
|
|
||||||
async def upload_file_as_attachment(
|
async def upload_file_as_attachment(
|
||||||
api: MaxBotApi,
|
api: MaxBotApi, # Теперь тип известен
|
||||||
*,
|
*,
|
||||||
filename: str,
|
filename: str,
|
||||||
content: bytes,
|
content: bytes,
|
||||||
upload_type: str,
|
upload_type: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
"""Загружает файл в MAX для отправки пользователю."""
|
||||||
meta = await api.get_upload_url(upload_type)
|
meta = await api.get_upload_url(upload_type)
|
||||||
upload_url = meta.get("url")
|
upload_url = meta.get("url")
|
||||||
token = meta.get("token")
|
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:
|
def read_workspace_bytes(workspace_path: str | Path, *, agent_workspace: str) -> bytes:
|
||||||
|
"""Читает файл из workspace по относительному пути."""
|
||||||
root = Path(agent_workspace)
|
root = Path(agent_workspace)
|
||||||
|
# Теперь эта функция определена выше
|
||||||
resolved = resolve_workspace_attachment_path(root, str(workspace_path))
|
resolved = resolve_workspace_attachment_path(root, str(workspace_path))
|
||||||
return resolved.read_bytes()
|
return resolved.read_bytes()
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Help text for MAX surface (single dialog, slash commands)."""
|
"""Help text for MAX surface (single dialog, slash commands)."""
|
||||||
|
|
||||||
HELP_TEXT = """
|
HELP_TEXT = """
|
||||||
Команды (/ как в Telegram):
|
Команды:
|
||||||
|
|
||||||
/start — начать
|
/start — начать
|
||||||
/help — эта справка
|
/help — эта справка
|
||||||
|
|
@ -16,9 +16,6 @@ HELP_TEXT = """
|
||||||
Подтверждения агента:
|
Подтверждения агента:
|
||||||
|
|
||||||
/yes / /no
|
/yes / /no
|
||||||
|
|
||||||
Команды вида /new, /chats, /rename, /archive в MAX не нужны —
|
|
||||||
у вас один диалог с ботом; контекст сбрасывайте через /clear.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,3 +40,6 @@ target-version = "py311"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = ["E", "F", "I", "UP", "B"]
|
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