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