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

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