fix max-bot, add tests

This commit is contained in:
Александра Пронина 2026-05-15 10:22:43 +03:00
parent 7abbaf7e7a
commit 2ad1438e1c
17 changed files with 1621 additions and 494 deletions

View file

@ -1,50 +1,141 @@
"""Agent registry for MAX surface."""
import os
import yaml
from typing import List, Optional
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
import yaml
@dataclass
class AgentConfig:
id: str
class AgentRegistryError(ValueError):
pass
@dataclass(frozen=True)
class AgentDefinition:
agent_id: str
label: str
base_url: str
workspace_path: str
base_url: str = field(default="")
workspace_path: str = field(default="")
@dataclass(frozen=True)
class AgentAssignment:
agent_id: str | None
source: Literal["configured", "default", "none"]
@property
def is_default(self) -> bool:
return self.source == "default"
@dataclass
class AgentRegistry:
agents: List[AgentConfig] = field(default_factory=list)
"""Same contract as Matrix agent registry: user_agents maps MAX user_id string -> agent_id."""
def get_agent_for_user(self, user_id: str) -> AgentConfig:
return self.agents[0]
def __init__(
self,
agents: list[AgentDefinition],
user_agents: Mapping[str, str] | None = None,
) -> None:
self.agents = tuple(agents)
self._by_id = {agent.agent_id: agent for agent in self.agents}
self._user_agents: dict[str, str] = dict(user_agents or {})
def get_agent_by_id(self, agent_id: str) -> Optional[AgentConfig]:
for agent in self.agents:
if agent.id == agent_id:
return agent
return None
def get(self, agent_id: str) -> AgentDefinition:
try:
return self._by_id[agent_id]
except KeyError as exc:
raise AgentRegistryError(f"unknown agent id: {agent_id}") from exc
def get_agent_id_for_user(self, max_user_id: str) -> str | None:
return self._user_agents.get(max_user_id)
def resolve_agent_for_user(self, max_user_id: str) -> AgentAssignment:
agent_id = self.get_agent_id_for_user(max_user_id)
if agent_id is not None:
return AgentAssignment(agent_id=agent_id, source="configured")
if self.agents:
return AgentAssignment(agent_id=self.agents[0].agent_id, source="default")
return AgentAssignment(agent_id=None, source="none")
def load_agent_registry(path: str) -> AgentRegistry:
with open(path, "r") as f:
data = yaml.safe_load(f)
def _required_text(entry: Mapping[str, object], key: str) -> str:
value = entry.get(key)
if not isinstance(value, str):
raise AgentRegistryError("each agent entry requires id and label")
text = value.strip()
if not text:
raise AgentRegistryError("each agent entry requires id and label")
return text
registry = AgentRegistry()
for a in data.get("agents", []):
registry.agents.append(AgentConfig(
id=a["id"],
label=a.get("label", ""),
base_url=a["base_url"],
workspace_path=a["workspace_path"],
))
return registry
def _optional_text(entry: Mapping[str, object], key: str) -> str:
value = entry.get(key)
if value is None:
return ""
if not isinstance(value, str):
raise AgentRegistryError(f"agent entry field '{key}' must be a string")
return value.strip()
def _load_registry_data(path: str | Path) -> dict[str, object]:
try:
raw = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
except yaml.YAMLError as exc:
raise AgentRegistryError("invalid agent registry YAML") from exc
if raw is None:
return {}
if not isinstance(raw, Mapping):
raise AgentRegistryError("agent registry must be a mapping with an agents list")
return dict(raw)
def load_agent_registry(path: str | Path) -> AgentRegistry:
raw = _load_registry_data(path)
entries = raw.get("agents")
if not isinstance(entries, list) or not entries:
raise AgentRegistryError("agents registry must contain a non-empty agents list")
agents: list[AgentDefinition] = []
seen: set[str] = set()
for entry in entries:
if not isinstance(entry, Mapping):
raise AgentRegistryError("each agent entry requires id and label")
agent_id = _required_text(entry, "id")
label = _required_text(entry, "label")
base_url = _optional_text(entry, "base_url")
workspace_path = _optional_text(entry, "workspace_path")
if agent_id in seen:
raise AgentRegistryError(f"duplicate agent id: {agent_id}")
seen.add(agent_id)
agents.append(
AgentDefinition(
agent_id=agent_id,
label=label,
base_url=base_url,
workspace_path=workspace_path,
)
)
user_agents = raw.get("user_agents")
if user_agents is not None:
if not isinstance(user_agents, Mapping):
raise AgentRegistryError("user_agents must be a mapping of user id strings to agent ids")
normalized: dict[str, str] = {}
for uid, aid in user_agents.items():
if not isinstance(uid, str) or not isinstance(aid, str):
raise AgentRegistryError("user_agents keys and values must be strings")
normalized[uid.strip()] = aid.strip()
user_agents_map: Mapping[str, str] = normalized
else:
user_agents_map = {}
return AgentRegistry(agents=agents, user_agents=user_agents_map)
def load_from_env() -> AgentRegistry:
path = os.environ.get(
"MAX_AGENT_REGISTRY_PATH",
"/app/config/max-agents.yaml",
)
return load_agent_registry(path)
import os
path = os.environ.get("MAX_AGENT_REGISTRY_PATH", "/app/config/max-agents.yaml")
return load_agent_registry(path)

153
adapter/max/api_client.py Normal file
View file

@ -0,0 +1,153 @@
"""HTTP client for MAX Bot API (platform-api.max.ru)."""
from __future__ import annotations
import logging
from typing import Any
import httpx
logger = logging.getLogger(__name__)
class MaxApiError(Exception):
def __init__(self, status: int, payload: Any):
super().__init__(f"MAX API error {status}: {payload}")
self.status = status
self.payload = payload
class MaxBotApi:
"""
Minimal async client. Auth: raw token in Authorization header (same as official TS SDK).
"""
def __init__(self, token: str, base_url: str = "https://platform-api.max.ru") -> None:
self._token = token
self._base = base_url.rstrip("/")
self._client = httpx.AsyncClient(
base_url=self._base,
headers={"Authorization": token},
timeout=httpx.Timeout(120.0, connect=30.0),
)
async def aclose(self) -> None:
await self._client.aclose()
async def _request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json: Any | None = None,
) -> Any:
response = await self._client.request(method, path, params=params, json=json)
payload: Any
try:
payload = response.json()
except Exception:
payload = response.text
if response.status_code >= 400:
if isinstance(payload, dict):
raise MaxApiError(
response.status_code,
{"code": payload.get("code"), "message": payload.get("message", payload)},
)
raise MaxApiError(response.status_code, payload)
return payload
async def get_me(self) -> dict[str, Any]:
data = await self._request("GET", "/me")
return dict(data) if isinstance(data, dict) else {}
async def get_updates(
self,
*,
marker: int | None = None,
limit: int = 100,
timeout: int = 30,
types: list[str] | None = None,
) -> tuple[list[dict[str, Any]], int | None]:
params: dict[str, Any] = {"limit": limit, "timeout": timeout}
if marker is not None:
params["marker"] = marker
if types:
params["types"] = ",".join(types)
data = await self._request("GET", "/updates", params=params)
if not isinstance(data, dict):
return [], None
raw_updates = data.get("updates") or []
updates = [u for u in raw_updates if isinstance(u, dict)]
marker_out = data.get("marker")
return updates, marker_out if isinstance(marker_out, int) else None
async def send_message_to_chat(
self,
chat_id: int,
*,
text: str | None = None,
attachments: list[dict[str, Any]] | None = None,
fmt: str | None = None,
) -> dict[str, Any]:
params: dict[str, Any] = {"chat_id": chat_id}
body: dict[str, Any] = {}
if text is not None:
body["text"] = text
if attachments is not None:
body["attachments"] = attachments
if fmt:
body["format"] = fmt
return await self._request("POST", "/messages", params=params, json=body)
async def send_message_to_user(
self,
user_id: int,
*,
text: str | None = None,
attachments: list[dict[str, Any]] | None = None,
fmt: str | None = None,
) -> dict[str, Any]:
params: dict[str, Any] = {"user_id": user_id}
body: dict[str, Any] = {}
if text is not None:
body["text"] = text
if attachments is not None:
body["attachments"] = attachments
if fmt:
body["format"] = fmt
return await self._request("POST", "/messages", params=params, json=body)
async def send_chat_action(self, chat_id: int, action: str) -> Any:
return await self._request(
"POST",
f"/chats/{chat_id}/actions",
json={"action": action},
)
async def get_upload_url(self, upload_type: str) -> dict[str, Any]:
data = await self._request("POST", "/uploads", params={"type": upload_type})
return dict(data) if isinstance(data, dict) else {}
async def answer_callback(
self,
callback_id: str,
*,
message: dict[str, Any] | None = None,
notification: str | None = None,
) -> Any:
body: dict[str, Any] = {}
if message is not None:
body["message"] = message
if notification is not None:
body["notification"] = notification
return await self._request(
"POST",
"/answers",
params={"callback_id": callback_id},
json=body if body else {},
)
async def download_file(self, url: str) -> bytes:
response = await self._client.get(url)
response.raise_for_status()
return response.content

View file

@ -1,98 +1,97 @@
"""MAX surface bot runtime."""
"""MAX messenger surface — runtime using official MAX Bot API (long polling)."""
from __future__ import annotations
import os
import asyncio
import logging
import os
import re
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
import aiohttp
import httpx
import structlog
from dotenv import load_dotenv
from adapter.max.agent_registry import load_from_env, AgentRegistry
from adapter.max.agent_registry import AgentRegistry, AgentRegistryError, load_from_env
from adapter.max.api_client import MaxApiError, MaxBotApi
from adapter.max.converter import (
max_message_to_incoming,
max_attachment_to_internal,
collect_max_attachments,
incoming_from_message_callback_payload,
incoming_from_text_commands,
)
from adapter.max.files import (
guess_upload_type,
read_workspace_bytes,
save_incoming_from_url,
upload_file_as_attachment,
)
from adapter.max.files import FileHandler
from adapter.max.handlers.chat import ChatHandler as MaxChatHandler
from adapter.max.handlers.attachments import AttachmentHandler
from adapter.max.handlers.help import get_help
from adapter.max.handlers.chat import ChatHandler as MaxChatHandler
from adapter.max.handlers.commands import register_max_handlers
from adapter.max.store import ChatStore, RoomMeta
from core.chat import ChatManager
from core.auth import AuthManager
from core.chat import ChatManager
from core.handler import EventDispatcher
from core.protocol import (
Attachment,
IncomingMessage,
IncomingCommand,
IncomingCallback,
OutgoingEvent,
OutgoingMessage,
OutgoingNotification,
OutgoingTyping,
OutgoingUI,
)
from core.handlers import register_all
from core.protocol import Attachment, IncomingCommand, OutgoingEvent, OutgoingMessage
from core.protocol import OutgoingNotification, OutgoingTyping, OutgoingUI
from core.settings import SettingsManager
from core.store import InMemoryStore, StateStore
from sdk.interface import (
MessageChunk,
MessageResponse,
PlatformClient,
PlatformError,
User,
UserSettings,
)
from sdk.interface import PlatformClient, PlatformError
from sdk.prototype_state import PrototypeStateStore
from sdk.real import RealPlatformClient
logger = structlog.get_logger(__name__)
MAX_TEXT_CHARS = 4000
_POLL_TYPES_DEFAULT = ["message_created", "message_callback", "bot_started"]
load_dotenv(Path(__file__).resolve().parents[2] / ".env")
def _normalize_agent_base_url(url: str) -> str:
parsed = urlsplit(url)
path = re.sub(r"(?:/v1)?/agent_ws(?:/[^/]+)?/?$", "", parsed.path.rstrip("/"))
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
def _agent_base_url_from_env() -> str:
if base_url := os.environ.get("AGENT_BASE_URL"):
return base_url
if ws_url := os.environ.get("AGENT_WS_URL"):
return _normalize_agent_base_url(ws_url)
return "http://127.0.0.1:8000"
# ---------------------------------------------------------------------------
# Routed MAX platform client — копия логики RoutedPlatformClient из Matrix
# ---------------------------------------------------------------------------
class RoutedMaxPlatformClient(PlatformClient):
"""Маршрутизирует запросы к нужному агенту на основе chat_id."""
"""Routes agent WS calls based on ChatStore mapping (same idea as RoutedPlatformClient)."""
def __init__(self, *, store: ChatStore, delegates: dict[str, PlatformClient]):
def __init__(
self, *, chat_store: ChatStore, delegates: dict[str, PlatformClient], default_client: PlatformClient
):
if not delegates:
raise ValueError("RoutedMaxPlatformClient requires at least one delegate")
self._store = store
self._store = chat_store
self._delegates = dict(delegates)
self._default_client = next(iter(self._delegates.values()))
self._default_client = default_client
async def get_or_create_user(
self, external_id: str, platform: str, display_name: str | None = None
) -> User:
):
return await self._default_client.get_or_create_user(
external_id=external_id, platform=platform, display_name=display_name
)
async def send_message(
self,
user_id: str,
chat_id: str,
text: str,
attachments: list[Attachment] | None = None,
) -> MessageResponse:
async def send_message(self, user_id: str, chat_id: str, text: str, attachments=None):
delegate, platform_chat_id = await self._resolve_delegate(user_id, chat_id)
return await delegate.send_message(user_id, platform_chat_id, text, attachments)
async def stream_message(
self,
user_id: str,
chat_id: str,
text: str,
attachments: list[Attachment] | None = None,
):
async def stream_message(self, user_id: str, chat_id: str, text: str, attachments=None):
delegate, platform_chat_id = await self._resolve_delegate(user_id, chat_id)
async for chunk in delegate.stream_message(user_id, platform_chat_id, text, attachments):
yield chunk
async def get_settings(self, user_id: str) -> UserSettings:
async def get_settings(self, user_id: str):
return await self._default_client.get_settings(user_id)
async def update_settings(self, user_id: str, action) -> None:
@ -104,9 +103,7 @@ class RoutedMaxPlatformClient(PlatformClient):
if callable(close_fn):
await close_fn()
async def _resolve_delegate(
self, user_id: str, local_chat_id: str
) -> tuple[PlatformClient, str]:
async def _resolve_delegate(self, user_id: str, local_chat_id: str):
room = self._store.get_room_by_platform_chat_id(local_chat_id)
if room is None:
raise PlatformError(f"unknown chat id: {local_chat_id}", code="CHAT_NOT_FOUND")
@ -114,11 +111,6 @@ class RoutedMaxPlatformClient(PlatformClient):
agent_id = room.agent_id
platform_chat_id = room.platform_chat_id
if not agent_id or not platform_chat_id:
raise PlatformError(
f"routing incomplete for chat: {local_chat_id}", code="ROUTE_INCOMPLETE"
)
delegate = self._delegates.get(str(agent_id))
if delegate is None:
raise PlatformError(f"unknown agent id: {agent_id}", code="AGENT_NOT_FOUND")
@ -126,53 +118,46 @@ class RoutedMaxPlatformClient(PlatformClient):
return delegate, str(platform_chat_id)
# ---------------------------------------------------------------------------
# MAX Surface
# ---------------------------------------------------------------------------
class MaxSurface:
def __init__(self):
# Env
class MaxBotApp:
def __init__(self) -> None:
self.token = os.environ["MAX_BOT_TOKEN"]
self.api_url = os.environ.get("MAX_API_URL", "https://api.max.ru/v1")
self.workspace_dir = os.environ.get("SURFACES_WORKSPACE_DIR", "/agents")
self.agent_base_url = os.environ.get("AGENT_BASE_URL", "")
api_base = os.environ.get("MAX_API_URL", "https://platform-api.max.ru").strip().rstrip("/")
self.api = MaxBotApi(self.token, base_url=api_base)
self.surfaces_workspace = Path(os.environ.get("SURFACES_WORKSPACE_DIR", "/agents"))
agent_base_url = _agent_base_url_from_env()
# Registry
self.registry: AgentRegistry = load_from_env()
try:
self.registry: AgentRegistry = load_from_env()
except (AgentRegistryError, OSError) as exc:
raise RuntimeError("failed to load MAX agent registry") from exc
# MAX-specific store for chat ↔ agent mapping
self.store = ChatStore()
self.files = FileHandler(self.workspace_dir)
self.max_chat_handler = MaxChatHandler(self.store)
self.attach_handler = AttachmentHandler(self.store)
self.chat_store = ChatStore()
self.max_chat_handler = MaxChatHandler(self.chat_store)
self.attach_handler = AttachmentHandler(self.chat_store)
# Core store (in-memory, lost on restart — OK for MVP)
self.core_store: StateStore = InMemoryStore()
self.prototype_state = PrototypeStateStore()
# Platform client per agent
delegates: dict[str, PlatformClient] = {}
delegates: dict[str, RealPlatformClient] = {}
for agent in self.registry.agents:
base = self.agent_base_url or agent.base_url.rstrip("/")
delegates[agent.id] = RealPlatformClient(
agent_id=agent.id,
agent_base_url=base,
prototype_state=None,
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",
)
# Routed platform
self.platform = RoutedMaxPlatformClient(
store=self.store,
default_client = next(iter(delegates.values()))
self.platform: RoutedMaxPlatformClient = RoutedMaxPlatformClient(
chat_store=self.chat_store,
delegates=delegates,
default_client=default_client,
)
# Core managers
self.chat_mgr = ChatManager(self.platform, self.core_store)
self.auth_mgr = AuthManager(self.platform, self.core_store)
self.settings_mgr = SettingsManager(self.platform, self.core_store)
# Event dispatcher — это и есть "ядро"
self.dispatcher = EventDispatcher(
platform=self.platform,
chat_mgr=self.chat_mgr,
@ -180,291 +165,454 @@ class MaxSurface:
settings_mgr=self.settings_mgr,
)
# HTTP session for MAX API
self.session: aiohttp.ClientSession | None = None
# ------------------------------------------------------------------
# Long polling
# ------------------------------------------------------------------
async def start(self):
self.session = aiohttp.ClientSession(
headers={"Authorization": f"Bearer {self.token}"}
register_all(self.dispatcher)
register_max_handlers(
self.dispatcher,
chat_store=self.chat_store,
max_chat_handler=self.max_chat_handler,
prototype_state=self.prototype_state,
)
logger.info("max_surface_starting", api_url=self.api_url)
offset = 0
while True:
try:
updates = await self._get_updates(offset)
for update in updates:
offset = update["update_id"] + 1
await self._process_update(update)
except Exception as e:
logger.error("max_poll_error", error=str(e))
await asyncio.sleep(5)
poll_types = os.environ.get("MAX_UPDATE_TYPES", "").strip()
self.update_types = (
[t.strip() for t in poll_types.split(",") if t.strip()]
if poll_types
else list(_POLL_TYPES_DEFAULT)
)
async def _get_updates(self, offset: int) -> list:
async with self.session.get(
f"{self.api_url}/updates",
params={"offset": offset, "timeout": 30},
) as resp:
data = await resp.json()
return data.get("result", [])
self._marker: int | None = None
self.bot_user_ids: set[int] = set()
logging.basicConfig(level=logging.INFO)
async def _process_update(self, update: dict) -> None:
if "message" in update:
await self._handle_message(update["message"])
elif "callback_query" in update:
await self._handle_callback(update["callback_query"])
async def bootstrap_identity(self) -> None:
me = await self.api.get_me()
uid = me.get("user_id")
if isinstance(uid, int):
self.bot_user_ids.add(uid)
# ------------------------------------------------------------------
# Message handling
# ------------------------------------------------------------------
async def ensure_user(self, max_user_id: str, *, display_name: str | None) -> None:
await self.platform.get_or_create_user(max_user_id, "max", display_name=display_name)
await self.auth_mgr.confirm(max_user_id)
async def _handle_message(self, message: dict) -> None:
text = message.get("text", "") or message.get("caption", "")
user_id = str(message["from"]["id"])
chat_id = str(message["chat"]["id"])
async def _resolve_room(
self,
*,
max_chat_key: str,
max_user_id: str,
) -> RoomMeta:
room = self.chat_store.get_room_by_max_chat_id(max_chat_key)
if room is not None:
return room
# Ensure room exists
room = self.store.get_room_by_max_chat_id(chat_id)
if room is None:
agent = self.registry.get_agent_for_user(user_id)
platform_chat_id = self.max_chat_handler.handle_new(
max_chat_id=chat_id,
user_id=user_id,
agent_id=agent.id,
)
room = self.store.get_room_by_max_chat_id(chat_id)
else:
agent = self.registry.get_agent_by_id(room.agent_id)
assignment = self.registry.resolve_agent_for_user(max_user_id)
if assignment.agent_id is None:
raise RuntimeError("no agents configured")
# Handle attachments
attachments = []
if "attachment" in message:
att = message["attachment"]
internal_att = max_attachment_to_internal(
filename=att["filename"],
mime_type=att.get("mime_type", "application/octet-stream"),
download_url=att["download_url"],
)
attachments.append(internal_att)
ws_path = ""
try:
ws_path = self.registry.get(assignment.agent_id).workspace_path
except AgentRegistryError:
pass
workspace_path = await self.files.download_attachment(
download_url=att["download_url"],
filename=att["filename"],
agent_workspace=agent.workspace_path,
headers={"Authorization": f"Bearer {self.token}"},
)
self.store.stage_attachment(chat_id, (workspace_path, att["filename"]))
pid = self.max_chat_handler.handle_new(
max_chat_id=max_chat_key,
user_id=max_user_id,
agent_id=assignment.agent_id,
name="Чат 1",
workspace_path=ws_path,
)
# File-only message → stage and return
if attachments and not text:
await self.chat_mgr.get_or_create(
user_id=max_user_id,
chat_id=pid,
platform="max",
surface_ref=max_chat_key,
name="Чат 1",
)
refreshed = self.chat_store.get_room_by_max_chat_id(max_chat_key)
if refreshed is None:
raise RuntimeError("max room bootstrap failed")
logger.info(
"max_chat_bootstrapped",
max_chat_key=max_chat_key,
platform_chat_id=pid,
agent_id=assignment.agent_id,
)
return refreshed
async def process_message_created(self, payload: dict) -> None:
message = payload.get("message")
if not isinstance(message, dict):
return
# Merge staged attachments
queued = self.store.pop_attachments(chat_id)
if queued:
for ws_path, filename in queued:
attachments.append(
sender = message.get("sender") or {}
if not isinstance(sender, dict):
return
uid = sender.get("user_id")
if isinstance(uid, int):
uid_s = str(uid)
else:
return
if sender.get("is_bot"):
return
recipient = message.get("recipient") or {}
chat_id_numeric = recipient.get("chat_id")
if chat_id_numeric is None or not isinstance(chat_id_numeric, int):
dialog_uid = recipient.get("user_id")
if isinstance(dialog_uid, int):
chat_key = str(dialog_uid)
else:
return
else:
chat_key = str(chat_id_numeric)
await self.ensure_user(uid_s, display_name=sender.get("first_name"))
room = await self._resolve_room(
max_chat_key=chat_key,
max_user_id=uid_s,
)
body = message.get("body") or {}
text = ""
if isinstance(body, dict):
raw_txt = body.get("text")
text = raw_txt.strip() if isinstance(raw_txt, str) else ""
attachments_core, raw_meta = collect_max_attachments(body) if isinstance(body, dict) else ([], [])
attachments_core = await self._materialize_attachments(room, attachments_core, raw_meta)
if attachments_core and not text:
for att in attachments_core:
self.chat_store.stage_attachment(chat_key, (att.workspace_path or "", att.filename or "file"))
return
queued = self.chat_store.pop_attachments(chat_key)
merged = list(attachments_core)
for ws_path, fname in queued:
if ws_path:
merged.append(
Attachment(
type="document",
filename=filename,
filename=fname,
workspace_path=ws_path,
)
)
# Convert to incoming event
incoming = max_message_to_incoming(
incoming = incoming_from_text_commands(
text=text,
user_id=user_id,
chat_id=room.platform_chat_id,
attachments=attachments,
max_user_id=uid_s,
platform_chat_id=room.platform_chat_id,
attachments=merged,
)
# Surface-level commands
if isinstance(incoming, IncomingCommand):
response_text = await self._handle_surface_command(
incoming, max_chat_id=chat_id, user_id=user_id, agent=agent
)
if response_text:
await self._send_message(chat_id, response_text)
return
if isinstance(incoming, IncomingMessage):
if not incoming.text.strip() and not incoming.attachments:
return
if isinstance(incoming, IncomingCommand):
if incoming.command in {"list", "remove"}:
reply = await self._handle_local_attachment_command(incoming, chat_key)
await self._send_lines(int(chat_key), reply)
return
# Dispatch to core
try:
outgoing_events = await self.dispatcher.dispatch(incoming)
outgoing = await self.dispatcher.dispatch(incoming)
except PlatformError as exc:
logger.warning(
"max_dispatch_platform_error",
user_id=user_id,
chat_id=chat_id,
code=exc.code,
error=str(exc),
)
outgoing_events = [
logger.warning("max_dispatch_error", code=exc.code, err=str(exc))
outgoing = [
OutgoingMessage(
chat_id=room.platform_chat_id,
text="Сервис временно недоступен. Попробуйте ещё раз позже.",
text="Сервис временно недоступен. Попробуйте позже.",
)
]
# Send outgoing events back to MAX
for event in outgoing_events:
await self._send_outgoing(chat_id, event, agent.workspace_path)
if not outgoing and isinstance(incoming, IncomingCommand):
outgoing = [
OutgoingMessage(
chat_id=room.platform_chat_id,
text="Неизвестная команда. Введите /help.",
),
]
# ------------------------------------------------------------------
# Callbacks
# ------------------------------------------------------------------
await self._send_outgoing(int(chat_key), outgoing, room)
async def _handle_callback(self, callback: dict) -> None:
user_id = str(callback["from"]["id"])
chat_id = str(callback["message"]["chat"]["id"])
message_id = str(callback["message"]["message_id"])
data = callback.get("data", "")
async def _handle_local_attachment_command(self, incoming: IncomingCommand, chat_key: str) -> str:
if incoming.command == "list":
return self.attach_handler.handle_list(chat_key)
return self.attach_handler.handle_remove(chat_key, incoming.args[0] if incoming.args else "")
room = self.store.get_room_by_max_chat_id(chat_id)
async def _materialize_attachments(
self,
room: RoomMeta,
attachments: list[Attachment],
raw_meta: list[dict],
) -> list[Attachment]:
workspace = Path(room.workspace_path or str(self.surfaces_workspace))
out: list[Attachment] = []
for att, _meta in zip(attachments, raw_meta, strict=False):
if not att.url:
out.append(att)
continue
try:
rel = await save_incoming_from_url(
api=self.api,
workspace_root=workspace,
filename=att.filename or "file.bin",
url=att.url,
)
except (httpx.HTTPError, OSError) as exc:
logger.warning("max_attachment_download_failed", error=str(exc))
out.append(att)
continue
out.append(
Attachment(
type=att.type,
filename=att.filename,
mime_type=att.mime_type,
workspace_path=rel,
url=att.url,
)
)
return out
async def process_message_callback(self, payload: dict) -> None:
cb = payload.get("callback") or {}
if not isinstance(cb, dict):
return
callback_id = cb.get("callback_id")
user_blob = cb.get("user") or {}
uid = user_blob.get("user_id") if isinstance(user_blob, dict) else None
uid_s = str(uid) if isinstance(uid, int) else None
msg = payload.get("message") or {}
recipient = msg.get("recipient") or {} if isinstance(msg, dict) else {}
cc = recipient.get("chat_id")
if isinstance(cc, int):
chat_key = str(cc)
elif isinstance(uid_s, str):
chat_key = uid_s
else:
return
mid = ""
body = msg.get("body") if isinstance(msg, dict) else None
if isinstance(body, dict):
mb = body.get("mid")
mid = mb if isinstance(mb, str) else ""
if uid_s is None:
return
await self.ensure_user(uid_s, display_name=user_blob.get("first_name"))
room = self.chat_store.get_room_by_max_chat_id(chat_key)
if room is None:
return
incoming = max_message_to_incoming(
text="",
user_id=user_id,
chat_id=room.platform_chat_id,
callback_data=data,
message_id=message_id,
payload_raw = cb.get("payload") if cb.get("payload") is not None else None
payload_str = str(payload_raw) if payload_raw is not None else ""
incoming = incoming_from_message_callback_payload(
max_user_id=uid_s,
platform_chat_id=room.platform_chat_id,
payload_raw=payload_str,
callback_message_id=mid,
)
if incoming is None:
if isinstance(callback_id, str):
await self.api.answer_callback(callback_id, notification="ok")
return
try:
outgoing_events = await self.dispatcher.dispatch(incoming)
outgoing = await self.dispatcher.dispatch(incoming)
except PlatformError:
outgoing = []
await self._send_outgoing(int(chat_key), outgoing, room)
if isinstance(callback_id, str):
await self.api.answer_callback(callback_id, notification=" ")
async def process_bot_started(self, payload: dict) -> None:
cid = payload.get("chat_id")
user_blob = payload.get("user") or {}
uid = user_blob.get("user_id")
chat_key = str(cid) if isinstance(cid, int) else None
if chat_key is None or not isinstance(uid, int):
return
for event in outgoing_events:
agent = self.registry.get_agent_by_id(room.agent_id)
ws = agent.workspace_path if agent else "/agents/0"
await self._send_outgoing(chat_id, event, ws)
uid_s = str(uid)
await self.ensure_user(uid_s, display_name=user_blob.get("first_name"))
# ------------------------------------------------------------------
# Surface commands
# ------------------------------------------------------------------
await self._resolve_room(
max_chat_key=chat_key,
max_user_id=uid_s,
)
async def _handle_surface_command(
self, cmd: IncomingCommand, max_chat_id: str, user_id: str, agent
) -> str | None:
command = cmd.command
args = cmd.args
deeplink_note = ""
dl = payload.get("payload") if isinstance(payload.get("payload"), str) else None
if dl:
deeplink_note = f" (payload: {dl})"
if command == "new":
name = " ".join(args) if args else None
self.max_chat_handler.handle_new(
max_chat_id=max_chat_id,
user_id=user_id,
agent_id=agent.id,
name=name,
)
return f"New chat created: {name or 'Unnamed'}"
welcome = (
"Здравствуйте, я помогу с задачами Lambda. "
f"Отправьте текст или файл.{deeplink_note}"
)
elif command == "chats":
return self.max_chat_handler.handle_chats(user_id)
await self.api.send_message_to_chat(int(chat_key), text=welcome)
elif command == "rename":
new_name = " ".join(args) if args else ""
return self.max_chat_handler.handle_rename(max_chat_id, new_name)
async def dispatch_update(self, update: dict) -> None:
utype = update.get("update_type")
if utype == "message_created":
await self.process_message_created(update)
elif utype == "message_callback":
await self.process_message_callback(update)
elif utype == "bot_started":
await self.process_bot_started(update)
elif command == "archive":
return self.max_chat_handler.handle_archive(max_chat_id)
async def _send_lines(self, max_chat_id: int, text: str) -> None:
if text:
await self._send_plain_text(max_chat_id, text)
elif command in ("clear", "reset"):
return self.max_chat_handler.handle_clear(max_chat_id)
async def _send_plain_text(self, max_chat_id: int, text: str, *, fmt: str | None = None) -> None:
chunk_size = MAX_TEXT_CHARS
for i in range(0, len(text), chunk_size):
part = text[i : i + chunk_size]
await self.api.send_message_to_chat(max_chat_id, text=part, fmt=fmt)
elif command == "list":
return self.attach_handler.handle_list(max_chat_id)
async def _send_outgoing(self, max_chat_id: int, events: list[OutgoingEvent], room: RoomMeta) -> None:
workspace_agent = Path(
room.workspace_path if room.workspace_path else self.surfaces_workspace,
)
elif command == "remove":
idx = args[0] if args else ""
return self.attach_handler.handle_remove(max_chat_id, idx)
for event in events:
if isinstance(event, OutgoingTyping):
await self.api.send_chat_action(max_chat_id, "typing_on")
continue
elif command == "help":
return get_help()
if isinstance(event, OutgoingNotification):
body = f"[{event.level.upper()}] {event.text}"
await self._send_plain_text(max_chat_id, body)
continue
return None
if isinstance(event, OutgoingMessage):
fmt = None
if getattr(event, "parse_mode", "plain") == "markdown":
fmt = "markdown"
# ------------------------------------------------------------------
# Outgoing to MAX
# ------------------------------------------------------------------
merged_text = getattr(event, "text", "") or ""
attachments = list(getattr(event, "attachments", []) or [])
async def _send_outgoing(
self, max_chat_id: str, event: OutgoingEvent, workspace_path: str
) -> None:
if isinstance(event, OutgoingTyping):
await self._send_typing(max_chat_id)
return
agent_def = None
try:
agent_def = self.registry.get(room.agent_id)
except AgentRegistryError:
pass
if isinstance(event, OutgoingNotification):
text = f"[{event.level.upper()}] {event.text}"
await self._send_message(max_chat_id, text)
return
root = (
Path(agent_def.workspace_path)
if agent_def and agent_def.workspace_path
else workspace_agent
)
if isinstance(event, OutgoingMessage):
if event.text:
await self._send_message(max_chat_id, event.text)
req_atts: list[dict] = []
for raw_att in attachments:
wp = getattr(raw_att, "workspace_path", None)
if not wp:
continue
try:
data = read_workspace_bytes(wp, agent_workspace=str(root))
except OSError:
logger.warning("max_outgoing_missing_file", path=wp)
continue
# Upload outgoing files
for att in event.attachments:
if not att.workspace_path:
fn = getattr(raw_att, "filename", None) or Path(str(wp)).name
mime = getattr(raw_att, "mime_type", None)
att_type = str(getattr(raw_att, "type", "") or "")
ctype = guess_upload_type(mime, attachment_type=str(att_type))
attached = await upload_file_as_attachment(
self.api, filename=fn, content=data, upload_type=ctype
)
req_atts.append(attached)
text_payload = merged_text.strip() or None
if text_payload is None and not req_atts:
continue
if self.files.file_exists(att.workspace_path, workspace_path):
# Read file and upload to MAX
file_data = self.files.read_outgoing_file(
att.workspace_path, workspace_path
)
# MAX file upload logic — зависит от API MAX
# Пока просто отправляем имя файла текстом
await self._send_message(
max_chat_id,
f"[Файл: {att.filename or att.workspace_path}]",
)
return
if isinstance(event, OutgoingUI):
lines = [event.text]
if event.buttons:
for btn in event.buttons:
lines.append(f" {btn.label}")
lines.append("")
lines.append("Ответьте !yes для подтверждения или !no для отмены.")
await self._send_message(max_chat_id, "\n".join(lines))
return
await self.api.send_message_to_chat(
max_chat_id,
text=text_payload,
attachments=req_atts or None,
fmt=fmt,
)
# ------------------------------------------------------------------
# Low-level MAX API
# ------------------------------------------------------------------
if isinstance(event, OutgoingUI):
lines = [event.text]
if getattr(event, "buttons", []):
lines.append("")
for button in event.buttons:
lines.append(f"{button.label}")
lines.append("")
lines.append("Ответьте /yes или /no (или кнопки с callback в MAX).")
async def _send_message(self, chat_id: str, text: str) -> None:
async with self.session.post(
f"{self.api_url}/sendMessage",
json={"chat_id": chat_id, "text": text},
) as resp:
await resp.json()
merged = "\n".join(lines)
await self._send_plain_text(max_chat_id, merged)
async def _send_typing(self, chat_id: str) -> None:
async with self.session.post(
f"{self.api_url}/sendChatAction",
json={"chat_id": chat_id, "action": "typing"},
) as resp:
await resp.json()
async def run(self) -> None:
await self.bootstrap_identity()
logger.info(
"max_bot_poll_start",
update_types=self.update_types,
registry_agents=len(self.registry.agents),
)
while True:
try:
updates, marker = await self.api.get_updates(
marker=self._marker,
types=self.update_types,
timeout=40,
limit=100,
)
self._marker = marker
for u in updates:
try:
await self.dispatch_update(u)
except Exception:
logger.exception("max_update_failed", update=u)
except asyncio.CancelledError:
raise
except (MaxApiError, httpx.HTTPError) as exc:
logger.error("max_poll_fatal", error=str(exc))
await asyncio.sleep(5)
async def shutdown(self) -> None:
close = getattr(self.platform, "close", None)
if callable(close):
await close()
await self.api.aclose()
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
async def main():
surface = MaxSurface()
await surface.start()
async def main() -> None:
app = MaxBotApp()
try:
await app.run()
finally:
await app.shutdown()
if __name__ == "__main__":
asyncio.run(main())
asyncio.run(main())

View file

@ -1,88 +1,151 @@
"""MAX event to internal protocol converter."""
from typing import Union, List
from core.protocol import (
IncomingMessage,
IncomingCommand,
IncomingCallback,
Attachment,
)
"""MAX Bot API payloads -> core Incoming* types."""
from __future__ import annotations
from typing import Any
from core.protocol import Attachment, IncomingCallback, IncomingCommand, IncomingMessage
def _extract_command(text: str) -> Union[IncomingCommand, IncomingCallback, None]:
if not text.startswith("!"):
return None
parts = text.strip().split(maxsplit=1)
cmd = parts[0].lower()
args = parts[1] if len(parts) > 1 else ""
if cmd == "!yes":
return IncomingCallback(
user_id="",
platform="max",
chat_id="",
action="confirm",
)
elif cmd == "!no":
return IncomingCallback(
user_id="",
platform="max",
chat_id="",
action="cancel",
)
else:
return IncomingCommand(
user_id="",
platform="max",
chat_id="",
command=cmd.lstrip("!"),
args=args.split() if args else [],
)
def max_message_to_incoming(
def incoming_from_text_commands(
*,
text: str,
user_id: str,
chat_id: str,
attachments: List[Attachment] = None,
callback_data: str = None,
message_id: str = None,
) -> Union[IncomingMessage, IncomingCommand, IncomingCallback]:
if callback_data:
max_user_id: str,
platform_chat_id: str,
attachments: list[Attachment],
) -> IncomingMessage | IncomingCommand | IncomingCallback:
"""Парсинг текста: только slash-команды (как в Telegram), обычное сообщение иначе."""
stripped = text.strip()
proto = stripped.lower()
if proto in {"/yes"}:
return IncomingCallback(
user_id=user_id,
user_id=max_user_id,
platform="max",
chat_id=chat_id,
action=callback_data,
payload={"message_id": message_id} if message_id else {},
chat_id=platform_chat_id,
action="confirm",
payload={},
)
if proto in {"/no"}:
return IncomingCallback(
user_id=max_user_id,
platform="max",
chat_id=platform_chat_id,
action="cancel",
payload={},
)
if text:
cmd = _extract_command(text)
if cmd is not None:
cmd.user_id = user_id
cmd.chat_id = chat_id
return cmd
if not stripped.startswith("/"):
return IncomingMessage(
user_id=max_user_id,
platform="max",
chat_id=platform_chat_id,
text=text,
attachments=attachments,
reply_to=None,
)
return IncomingMessage(
user_id=user_id,
raw = stripped[1:]
parts = raw.split(maxsplit=1)
name = (parts[0] or "").lower()
tail = parts[1] if len(parts) > 1 else ""
return IncomingCommand(
user_id=max_user_id,
platform="max",
chat_id=chat_id,
text=text or "",
attachments=attachments or [],
chat_id=platform_chat_id,
command=name,
args=tail.split() if tail else [],
)
def max_attachment_to_internal(
def incoming_from_message_callback_payload(
*,
filename: str,
mime_type: str,
download_url: str,
) -> Attachment:
return Attachment(
type="document",
url=download_url,
max_user_id: str,
platform_chat_id: str,
payload_raw: str | None,
callback_message_id: str | None,
) -> IncomingCallback | None:
if not payload_raw:
return None
if payload_raw in {"confirm", "cancel", "toggle_skill"}:
return IncomingCallback(
user_id=max_user_id,
platform="max",
chat_id=platform_chat_id,
action=payload_raw,
payload={"message_id": callback_message_id or ""},
)
return IncomingCallback(
user_id=max_user_id,
platform="max",
chat_id=platform_chat_id,
action="max_callback",
payload={"payload": payload_raw, "message_id": callback_message_id},
)
def attachment_from_max_dict(raw: dict[str, Any]) -> tuple[Attachment, dict[str, Any]] | None:
"""Return core Attachment placeholder + raw attachment for download."""
kind = raw.get("type")
payload = raw.get("payload")
if not isinstance(kind, str) or not isinstance(payload, dict):
return None
url = payload.get("url")
if not isinstance(url, str):
url = ""
token = payload.get("token")
filename = "attachment.bin"
mapped = "document"
mime: str | None = None
if kind == "image":
mapped = "image"
filename = "image.jpg"
elif kind == "video":
mapped = "video"
filename = "video.mp4"
elif kind == "audio":
mapped = "audio"
mime = payload.get("mime_type") if isinstance(payload.get("mime_type"), str) else "audio/mpeg"
filename = "audio.bin"
elif kind == "file":
fname = payload.get("filename")
filename = fname if isinstance(fname, str) and fname else "file.bin"
mapped = "document"
else:
return None
attachment = Attachment(
type=mapped,
url=url or None,
content=None,
filename=filename,
mime_type=mime_type,
)
mime_type=mime,
)
meta = dict(raw)
if token:
meta["_download_token_hint"] = token
return attachment, meta
def collect_max_attachments(message_body: dict[str, Any]) -> tuple[list[Attachment], list[dict[str, Any]]]:
attachments = message_body.get("attachments")
if not isinstance(attachments, list):
return [], []
core_list: list[Attachment] = []
raw_list: list[dict[str, Any]] = []
for item in attachments:
if isinstance(item, dict):
parsed = attachment_from_max_dict(item)
if parsed is None:
continue
core_a, raw_a = parsed
core_list.append(core_a)
raw_list.append(raw_a)
return core_list, raw_list

View file

@ -1,51 +1,88 @@
"""File handling for MAX surface."""
import os
import aiohttp
"""Incoming / outgoing file helpers for MAX (aligned with Matrix workspace layout)."""
from __future__ import annotations
import mimetypes
from pathlib import Path
import httpx
class FileHandler:
def __init__(self, workspace_root: str):
self.workspace_root = workspace_root
def _make_unique_filename(self, directory: str, filename: str) -> str:
base = Path(filename).stem
ext = Path(filename).suffix
candidate = filename
counter = 1
while os.path.exists(os.path.join(directory, candidate)):
candidate = f"{base} ({counter}){ext}"
counter += 1
return candidate
def guess_upload_type(mime_type: str | None, *, attachment_type: str) -> str:
if attachment_type == "image":
return "image"
if attachment_type == "video":
return "video"
if attachment_type == "audio":
return "audio"
mime = mime_type or ""
if mime.startswith("image/"):
return "image"
if mime.startswith("video/"):
return "video"
if mime.startswith("audio/"):
return "audio"
return "file"
async def download_attachment(
self,
download_url: str,
filename: str,
agent_workspace: str,
headers: dict = None,
) -> str:
full_dir = os.path.join(self.workspace_root, agent_workspace.strip("/"))
os.makedirs(full_dir, exist_ok=True)
unique_name = self._make_unique_filename(full_dir, filename)
filepath = os.path.join(full_dir, unique_name)
async def save_incoming_from_url(
*,
api: MaxBotApi,
workspace_root: Path,
filename: str,
url: str,
) -> str:
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 with aiohttp.ClientSession() as session:
async with session.get(download_url, headers=headers) as resp:
resp.raise_for_status()
with open(filepath, "wb") as f:
f.write(await resp.read())
return unique_name
async def upload_file_as_attachment(
api: MaxBotApi,
*,
filename: str,
content: bytes,
upload_type: str,
) -> dict:
meta = await api.get_upload_url(upload_type)
upload_url = meta.get("url")
token = meta.get("token")
if not isinstance(upload_url, str) or not upload_url:
raise RuntimeError("MAX uploads response missing url")
def read_outgoing_file(self, workspace_path: str, agent_workspace: str) -> bytes:
full_dir = os.path.join(self.workspace_root, agent_workspace.strip("/"))
filepath = os.path.join(full_dir, workspace_path.lstrip("/"))
with open(filepath, "rb") as f:
return f.read()
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
response = await client.post(
upload_url,
files={"data": (filename, content, guess_mimetype(filename))},
)
response.raise_for_status()
def file_exists(self, workspace_path: str, agent_workspace: str) -> bool:
full_dir = os.path.join(self.workspace_root, agent_workspace.strip("/"))
filepath = os.path.join(full_dir, workspace_path.lstrip("/"))
return os.path.exists(filepath)
payload: dict = {}
if token:
payload["token"] = token
if upload_type == "image":
return {"type": "image", "payload": payload}
type_map = {
"file": "file",
"video": "video",
"audio": "audio",
}
mapped = type_map.get(upload_type, "file")
return {"type": mapped, "payload": payload}
def guess_mimetype(filename: str) -> str:
mime, _ = mimetypes.guess_type(filename)
return mime or "application/octet-stream"
def read_workspace_bytes(workspace_path: str | Path, *, agent_workspace: str) -> bytes:
root = Path(agent_workspace)
resolved = resolve_workspace_attachment_path(root, str(workspace_path))
return resolved.read_bytes()

View file

@ -1 +1,5 @@
"""MAX surface handlers."""
"""MAX surface handlers."""
from adapter.max.handlers.commands import register_max_handlers
__all__ = ["register_max_handlers"]

View file

@ -9,7 +9,7 @@ class AttachmentHandler:
def handle_list(self, max_chat_id: str) -> str:
attachments = self.store.get_attachments(max_chat_id)
if not attachments:
return "Attachment queue is empty."
return "Очередь вложений пуста."
lines = [f" {i+1}. {name}" for i, (_, name) in enumerate(attachments)]
return "\n".join(lines)
@ -17,13 +17,13 @@ class AttachmentHandler:
attachments = self.store.staged_attachments.get(max_chat_id, [])
if index.lower() == "all":
self.store.staged_attachments[max_chat_id] = []
return "All attachments removed from queue."
return "Все вложения удалены из очереди."
try:
idx = int(index) - 1
if 0 <= idx < len(attachments):
removed = attachments.pop(idx)
return f"Removed: {removed[1]}"
return "Invalid index."
return f"Удалено: {removed[1]}"
return "Неверный номер."
except ValueError:
return "Usage: !remove <number> or !remove all"
return "Использование: /remove <номер> или /remove all"

View file

@ -7,7 +7,15 @@ class ChatHandler:
def __init__(self, store: ChatStore):
self.store = store
def handle_new(self, max_chat_id: str, user_id: str, agent_id: str, name: str = None) -> str:
def handle_new(
self,
max_chat_id: str,
user_id: str,
agent_id: str,
name: str | None = None,
*,
workspace_path: str = "",
) -> str:
platform_chat_id = str(uuid.uuid4())
room = RoomMeta(
platform_chat_id=platform_chat_id,
@ -15,6 +23,7 @@ class ChatHandler:
name=name or "New Chat",
user_id=user_id,
agent_id=agent_id,
workspace_path=workspace_path,
)
self.store.add_room(room)
return platform_chat_id
@ -22,27 +31,27 @@ class ChatHandler:
def handle_chats(self, user_id: str) -> str:
rooms = self.store.list_rooms_for_user(user_id)
if not rooms:
return "No active chats."
return "Нет активных чатов."
lines = [f" {i+1}. {r.name}" for i, r in enumerate(rooms)]
return "\n".join(lines)
def handle_rename(self, max_chat_id: str, new_name: str) -> str:
room = self.store.get_room_by_max_chat_id(max_chat_id)
if not room:
return "Chat not found."
return "Чат не найден."
room.name = new_name
return f"Chat renamed to: {new_name}"
return f"Чат переименован в: {new_name}"
def handle_archive(self, max_chat_id: str) -> str:
room = self.store.get_room_by_max_chat_id(max_chat_id)
if not room:
return "Chat not found."
return "Чат не найден."
self.store.remove_room(max_chat_id)
return "Chat archived."
return "Чат архивирован."
def handle_clear(self, max_chat_id: str) -> str:
room = self.store.get_room_by_max_chat_id(max_chat_id)
if not room:
return "Chat not found."
return "Чат не найден."
room.platform_chat_id = str(uuid.uuid4())
return "Chat context cleared."
return "Контекст чата очищен."

View file

@ -0,0 +1,112 @@
from __future__ import annotations
from adapter.max.handlers.chat import ChatHandler as MaxChatHandler
from adapter.max.handlers.help import get_help
from adapter.max.store import ChatStore
from sdk.prototype_state import PrototypeStateStore
from core.handler import EventDispatcher
from core.protocol import IncomingCallback, IncomingCommand, OutgoingMessage
_SINGLE_DIALOG_HINT = (
"В MAX один диалог с ботом. Чтобы сбросить контекст агента, используйте /clear или /reset."
)
def register_max_handlers(
dispatcher: EventDispatcher,
*,
chat_store: ChatStore,
max_chat_handler: MaxChatHandler,
prototype_state: PrototypeStateStore,
) -> None:
async def _room_or_error(
event: IncomingCommand,
) -> tuple[str, str] | list[OutgoingMessage]:
room = chat_store.get_room_by_platform_chat_id(event.chat_id)
if room is None:
return [
OutgoingMessage(
chat_id=event.chat_id,
text="Состояние ещё не готово. Напишите сообщение ещё раз.",
)
]
return room.max_chat_id, room.platform_chat_id
async def handle_max_help(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr): # noqa: ARG001
if not await auth_mgr.is_authenticated(event.user_id):
return [
OutgoingMessage(
chat_id=event.chat_id,
text="Введите /start чтобы начать.",
)
]
return [OutgoingMessage(chat_id=event.chat_id, text=get_help())]
async def handle_max_no_multichat(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr): # noqa: ARG001
if not await auth_mgr.is_authenticated(event.user_id):
return [
OutgoingMessage(
chat_id=event.chat_id,
text="Введите /start чтобы начать.",
)
]
_ = event
return [OutgoingMessage(chat_id=event.chat_id, text=_SINGLE_DIALOG_HINT)]
async def handle_max_clear(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr): # noqa: ARG001
if not await auth_mgr.is_authenticated(event.user_id):
return [
OutgoingMessage(
chat_id=event.chat_id,
text="Введите /start чтобы начать.",
)
]
routed = await _room_or_error(event)
if isinstance(routed, list):
return routed
max_chat_id, platform_chat_old = routed
await prototype_state.clear_current_session(platform_chat_old)
await chat_mgr.archive(platform_chat_old, event.user_id)
max_chat_handler.handle_clear(max_chat_id)
room = chat_store.get_room_by_max_chat_id(max_chat_id)
if room is None:
return [
OutgoingMessage(
chat_id=platform_chat_old,
text="Не удалось сбросить контекст.",
)
]
platform_chat_new = room.platform_chat_id
await chat_mgr.get_or_create(
user_id=event.user_id,
chat_id=platform_chat_new,
platform="max",
surface_ref=max_chat_id,
name=room.name,
)
return [
OutgoingMessage(
chat_id=platform_chat_new,
text="Контекст агента сброшен.",
)
]
for cmd in ("new", "rename", "archive", "chats"):
dispatcher.register(IncomingCommand, cmd, handle_max_no_multichat)
dispatcher.register(IncomingCommand, "reset", handle_max_clear)
dispatcher.register(IncomingCommand, "clear", handle_max_clear)
dispatcher.register(IncomingCommand, "help", handle_max_help)
async def handle_max_plain_callback(event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr): # noqa: ARG001
payload = str(event.payload.get("payload", ""))
return [
OutgoingMessage(
chat_id=event.chat_id,
text=f"Неизвестное действие кнопки: {payload}",
)
]
dispatcher.register(IncomingCallback, "max_callback", handle_max_plain_callback)

View file

@ -1,26 +1,26 @@
"""Help handler for MAX surface."""
"""Help text for MAX surface (single dialog, slash commands)."""
HELP_TEXT = """
Available commands:
Команды (/ как в Telegram):
Chat management:
!new [name] Create a new chat
!chats List active chats
!rename <name> Rename current chat
!archive Archive current chat
!clear / !reset Reset chat context
/start начать
/help эта справка
/clear или /reset сбросить контекст агента
Attachments:
!list Show attachment queue
!remove <n> Remove attachment from queue
!remove all Clear attachment queue
Вложения (файл без текста ставится в очередь):
Actions:
!yes Confirm agent action
!no Cancel agent action
!help Show this help
/list очередь вложений
/remove n убрать из очереди
/remove all очистить очередь
Подтверждения агента:
/yes / /no
Команды вида /new, /chats, /rename, /archive в MAX не нужны
у вас один диалог с ботом; контекст сбрасывайте через /clear.
"""
def get_help() -> str:
return HELP_TEXT.strip()
return HELP_TEXT.strip()

View file

@ -10,6 +10,7 @@ class RoomMeta:
name: str
user_id: str
agent_id: str
workspace_path: str = ""
@dataclass

View file

@ -0,0 +1 @@
# MAX adapter tests

View file

@ -0,0 +1,88 @@
from pathlib import Path
import pytest
from adapter.max.agent_registry import AgentRegistryError, load_agent_registry
def test_load_agent_registry_reads_yaml(tmp_path: Path):
path = tmp_path / "max.yaml"
path.write_text(
"agents:\n"
" - id: agent-1\n"
" label: One\n"
" base_url: http://localhost:8000/a1/\n"
" workspace_path: /agents/1\n",
encoding="utf-8",
)
reg = load_agent_registry(path)
assert [a.agent_id for a in reg.agents] == ["agent-1"]
a = reg.get("agent-1")
assert a.label == "One"
assert a.base_url == "http://localhost:8000/a1/"
assert a.workspace_path == "/agents/1"
def test_user_agents_resolve(tmp_path: Path):
path = tmp_path / "max.yaml"
path.write_text(
"user_agents:\n"
' "42": agent-1\n'
"agents:\n"
" - id: agent-1\n"
" label: One\n"
" - id: agent-2\n"
" label: Two\n",
encoding="utf-8",
)
reg = load_agent_registry(path)
assert reg.resolve_agent_for_user("42").agent_id == "agent-1"
assert reg.resolve_agent_for_user("42").source == "configured"
assert reg.resolve_agent_for_user("999").agent_id == "agent-1"
assert reg.resolve_agent_for_user("999").source == "default"
def test_duplicate_ids_rejected(tmp_path: Path):
path = tmp_path / "max.yaml"
path.write_text(
"agents:\n"
" - id: a\n"
" label: A\n"
" - id: a\n"
" label: B\n",
encoding="utf-8",
)
with pytest.raises(AgentRegistryError, match="duplicate agent id"):
load_agent_registry(path)
def test_empty_agents_rejected(tmp_path: Path):
path = tmp_path / "max.yaml"
path.write_text("agents: []\n", encoding="utf-8")
with pytest.raises(AgentRegistryError, match="non-empty"):
load_agent_registry(path)
def test_user_agents_must_be_strings(tmp_path: Path):
path = tmp_path / "max.yaml"
path.write_text(
"user_agents:\n"
" 42: agent-1\n"
"agents:\n"
" - id: agent-1\n"
" label: One\n",
encoding="utf-8",
)
with pytest.raises(AgentRegistryError, match="user_agents"):
load_agent_registry(path)
def test_unknown_agent_raises(tmp_path: Path):
path = tmp_path / "max.yaml"
path.write_text(
"agents:\n - id: a\n label: A\n",
encoding="utf-8",
)
reg = load_agent_registry(path)
with pytest.raises(AgentRegistryError, match="unknown agent id"):
reg.get("missing")

View file

@ -0,0 +1,90 @@
from __future__ import annotations
from unittest.mock import AsyncMock
import httpx
import pytest
from adapter.max.api_client import MaxApiError, MaxBotApi
@pytest.mark.asyncio
async def test_get_updates_returns_marker_and_updates():
api = MaxBotApi("token-x", base_url="http://max.test")
try:
api._client.request = AsyncMock(
return_value=httpx.Response(
200,
json={
"updates": [{"update_type": "message_created", "timestamp": 1}],
"marker": 7,
},
)
)
updates, marker = await api.get_updates(types=["message_created"])
assert len(updates) == 1
assert updates[0]["update_type"] == "message_created"
assert marker == 7
_, kwargs = api._client.request.call_args
assert kwargs["params"]["types"] == "message_created"
finally:
await api.aclose()
@pytest.mark.asyncio
async def test_get_updates_non_dict_body():
api = MaxBotApi("token-x", base_url="http://max.test")
try:
api._client.request = AsyncMock(return_value=httpx.Response(200, text="oops"))
updates, marker = await api.get_updates()
assert updates == []
assert marker is None
finally:
await api.aclose()
@pytest.mark.asyncio
async def test_http_error_raises_max_api_error():
api = MaxBotApi("token-x", base_url="http://max.test")
try:
api._client.request = AsyncMock(
return_value=httpx.Response(401, json={"code": "verify.token", "message": "bad"})
)
with pytest.raises(MaxApiError) as ei:
await api.get_me()
assert ei.value.status == 401
assert "bad" in str(ei.value).lower() or ei.value.payload
finally:
await api.aclose()
@pytest.mark.asyncio
async def test_send_message_to_chat_posts_json_body():
api = MaxBotApi("token-x", base_url="http://max.test")
try:
api._client.request = AsyncMock(return_value=httpx.Response(200, json={"message": {}}))
await api.send_message_to_chat(12345, text="hi", attachments=None, fmt=None)
args, kw = api._client.request.call_args
assert args[0] == "POST"
assert args[1] == "/messages"
assert kw["params"]["chat_id"] == 12345
assert kw["json"] == {"text": "hi"}
finally:
await api.aclose()
@pytest.mark.asyncio
async def test_download_file_uses_get():
api = MaxBotApi("token-x", base_url="http://max.test")
try:
api._client.get = AsyncMock(return_value=httpx.Response(200, content=b"\xff\xd8"))
buf = await api.download_file("https://files.example/bin")
assert buf == b"\xff\xd8"
api._client.get.assert_awaited_once()
finally:
await api.aclose()

View file

@ -0,0 +1,154 @@
from __future__ import annotations
import pytest
from adapter.max.converter import (
attachment_from_max_dict,
collect_max_attachments,
incoming_from_message_callback_payload,
incoming_from_text_commands,
)
from core.protocol import Attachment, IncomingCallback, IncomingCommand, IncomingMessage
@pytest.mark.parametrize(
"text,expect_type",
[
("Hello", IncomingMessage),
(" plain ", IncomingMessage),
],
)
def test_plain_text_to_message(text, expect_type):
r = incoming_from_text_commands(
text=text,
max_user_id="10",
platform_chat_id="pc-1",
attachments=[],
)
assert type(r) is expect_type
assert r.text == text
def test_slash_command_split():
r = incoming_from_text_commands(
text="/new title here",
max_user_id="10",
platform_chat_id="pc-1",
attachments=[],
)
assert isinstance(r, IncomingCommand)
assert r.command == "new"
assert r.args == ["title", "here"]
def test_slash_command_no_args():
r = incoming_from_text_commands(
text="/help",
max_user_id="10",
platform_chat_id="pc-1",
attachments=[],
)
assert isinstance(r, IncomingCommand)
assert r.command == "help"
assert r.args == []
def test_bang_prefix_is_plain_message_not_command():
"""MAX: только / считается командой."""
r = incoming_from_text_commands(
text="!help",
max_user_id="10",
platform_chat_id="pc-1",
attachments=[],
)
assert isinstance(r, IncomingMessage)
assert r.text == "!help"
def test_yes_no_callbacks():
yes = incoming_from_text_commands(
text="/yes",
max_user_id="10",
platform_chat_id="pc-1",
attachments=[],
)
assert isinstance(yes, IncomingCallback)
assert yes.action == "confirm"
no = incoming_from_text_commands(
text="/NO",
max_user_id="10",
platform_chat_id="pc-1",
attachments=[],
)
assert isinstance(no, IncomingCallback)
assert no.action == "cancel"
def test_incoming_message_keeps_attachments():
at = [Attachment(type="document", filename="a.txt")]
r = incoming_from_text_commands(
text="see file",
max_user_id="10",
platform_chat_id="pc-1",
attachments=at,
)
assert isinstance(r, IncomingMessage)
assert r.attachments == at
def test_message_callback_known_actions():
c = incoming_from_message_callback_payload(
max_user_id="10",
platform_chat_id="pc",
payload_raw="confirm",
callback_message_id="mid",
)
assert c is not None
assert isinstance(c, IncomingCallback)
assert c.action == "confirm"
assert c.payload.get("message_id") == "mid"
def test_message_callback_unknown_becomes_max_callback():
c = incoming_from_message_callback_payload(
max_user_id="10",
platform_chat_id="pc",
payload_raw="my_payload",
callback_message_id=None,
)
assert c is not None
assert c.action == "max_callback"
assert c.payload["payload"] == "my_payload"
def test_attachment_from_max_file():
parsed = attachment_from_max_dict(
{
"type": "file",
"payload": {
"url": "https://cdn.example/f",
"filename": "doc.pdf",
"token": "tok",
},
}
)
assert parsed is not None
att, raw = parsed
assert att.filename == "doc.pdf"
assert att.type == "document"
assert att.url == "https://cdn.example/f"
assert raw.get("_download_token_hint") == "tok"
def test_collect_max_attachments_skips_unknown():
core, raw = collect_max_attachments(
{
"attachments": [
{"type": "file", "payload": {"url": "u", "filename": "x.bin"}},
{"type": "sticker", "payload": {}},
]
}
)
assert len(core) == len(raw) == 1
assert core[0].filename == "x.bin"

View file

@ -0,0 +1,98 @@
from __future__ import annotations
import pytest
from adapter.max.handlers.chat import ChatHandler as MaxChatHandler
from adapter.max.handlers.commands import register_max_handlers
from adapter.max.store import ChatStore, RoomMeta
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 IncomingCommand, OutgoingMessage
from core.settings import SettingsManager
from core.store import InMemoryStore
from sdk.mock import MockPlatformClient
from sdk.prototype_state import PrototypeStateStore
def _build_dispatcher() -> tuple[
EventDispatcher,
ChatManager,
AuthManager,
ChatStore,
str,
]:
store_mem = InMemoryStore()
chat_store = ChatStore()
chat_handler = MaxChatHandler(chat_store)
prototype_state = PrototypeStateStore()
platform = MockPlatformClient()
chat_mgr = ChatManager(platform, store_mem)
auth_mgr = AuthManager(platform, store_mem)
settings_mgr = SettingsManager(platform, store_mem)
dispatcher = EventDispatcher(
platform=platform,
chat_mgr=chat_mgr,
auth_mgr=auth_mgr,
settings_mgr=settings_mgr,
)
register_all(dispatcher)
register_max_handlers(
dispatcher,
chat_store=chat_store,
max_chat_handler=chat_handler,
prototype_state=prototype_state,
)
pid = "550e8400-e29b-41d4-a716-446655440000"
chat_store.add_room(
RoomMeta(
platform_chat_id=pid,
max_chat_id="777",
name="Чат",
user_id="u1",
agent_id="agent-0",
workspace_path="/agents/0",
)
)
return dispatcher, chat_mgr, auth_mgr, chat_store, pid
@pytest.mark.asyncio
async def test_dispatcher_new_is_single_dialog_hint():
dispatcher, _chat_mgr, auth_mgr, _chat_store, pid = _build_dispatcher()
await auth_mgr.confirm("u1")
out = await dispatcher.dispatch(
IncomingCommand(user_id="u1", platform="max", chat_id=pid, command="new"),
)
assert len(out) == 1
assert isinstance(out[0], OutgoingMessage)
assert "один диалог" in out[0].text.lower()
@pytest.mark.asyncio
async def test_dispatcher_clear_rotates_platform_chat():
dispatcher, chat_mgr, auth_mgr, chat_store, pid = _build_dispatcher()
await auth_mgr.confirm("u1")
await chat_mgr.get_or_create(
user_id="u1",
chat_id=pid,
platform="max",
surface_ref="777",
name="Чат",
)
out = await dispatcher.dispatch(
IncomingCommand(user_id="u1", platform="max", chat_id=pid, command="clear"),
)
assert len(out) == 1
msg = out[0]
assert isinstance(msg, OutgoingMessage)
assert msg.chat_id != pid
assert "сброшен" in msg.text.lower()
room = chat_store.get_room_by_max_chat_id("777")
assert room is not None
assert room.platform_chat_id == msg.chat_id

View file

@ -0,0 +1,78 @@
from __future__ import annotations
import uuid
from adapter.max.handlers.attachments import AttachmentHandler
from adapter.max.handlers.chat import ChatHandler as MaxChatHandler
from adapter.max.store import ChatStore, RoomMeta
def test_chat_store_room_roundtrip():
store = ChatStore()
r = RoomMeta(
platform_chat_id="pid-1",
max_chat_id="100",
name="Main",
user_id="42",
agent_id="agent-0",
workspace_path="/agents/0",
)
store.add_room(r)
assert store.get_room_by_max_chat_id("100") is r
assert store.get_room_by_platform_chat_id("pid-1") is r
def test_staged_attachments():
store = ChatStore()
store.stage_attachment("100", ("rel/path.txt", "path.txt"))
assert store.get_attachments("100")
popped = store.pop_attachments("100")
assert len(popped) == 1
assert store.pop_attachments("100") == []
def test_remove_room_clears_staging():
store = ChatStore()
store.stage_attachment("100", ("a", "a"))
store.add_room(
RoomMeta(
platform_chat_id="x",
max_chat_id="100",
name="",
user_id="u",
agent_id="a",
)
)
store.remove_room("100")
assert store.get_room_by_max_chat_id("100") is None
assert store.get_attachments("100") == []
def test_chat_handler_clear_rotates_platform_id():
store = ChatStore()
h = MaxChatHandler(store)
pid1 = str(uuid.uuid4())
store.add_room(
RoomMeta(
platform_chat_id=pid1,
max_chat_id="100",
name="Tab",
user_id="42",
agent_id="agent-0",
workspace_path="/agents/0",
)
)
h.handle_clear("100")
room = store.get_room_by_max_chat_id("100")
assert room is not None
assert room.platform_chat_id != pid1
def test_attachment_handler_list_remove():
store = ChatStore()
h = AttachmentHandler(store)
store.stage_attachment("100", ("a", "f1.bin"))
assert "f1.bin" in h.handle_list("100")
msg = h.handle_remove("100", "1")
assert "Удалено" in msg or "удалено" in msg.lower()
assert "пуста" in h.handle_list("100").lower() or "пусто" in h.handle_list("100").lower()