surfaces/adapter/matrix/handlers/chat.py
Mikhail Putilovskij e733119d1e feat: enforce agent routing and persist restart state
Task 4: stale room blocking + agent_id binding
- MatrixBot._check_agent_routing: blocks normal messages when user has no
  selected agent or room is bound to a different agent
- agent_routing_enabled flag on MatrixRuntime activates the check only
  in real multi-agent mode (RoutedPlatformClient)
- make_handle_new_chat now writes agent_id into new room metadata when
  user already has a selected agent

Task 5: durable restart state tests
- test_restart_persistence.py proves selected_agent_id, room agent_id,
  platform_chat_id, and the sequence counter all survive SQLiteStore
  close/reopen; also covers clean startup with no prior state
2026-04-24 14:01:49 +03:00

209 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
import structlog
from nio.api import RoomVisibility
from nio.responses import RoomCreateError
from adapter.matrix.store import (
get_selected_agent_id,
get_user_meta,
next_chat_id,
next_platform_chat_id,
set_room_meta,
)
from core.protocol import IncomingCommand, OutgoingMessage
logger = structlog.get_logger(__name__)
def _is_unregistered_chat_id(chat_id: str) -> bool:
return chat_id.startswith("unregistered:")
async def _fallback_new_chat(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
if not await auth_mgr.is_authenticated(event.user_id):
return [OutgoingMessage(chat_id=event.chat_id, text="Введите !start чтобы начать.")]
name = " ".join(event.args).strip() if event.args else ""
chats = await chat_mgr.list_active(event.user_id)
chat_id = f"C{len(chats) + 1}"
ctx = await chat_mgr.get_or_create(
user_id=event.user_id,
chat_id=chat_id,
platform=event.platform,
surface_ref=event.chat_id,
name=name or None,
)
return [
OutgoingMessage(
chat_id=event.chat_id, text=f"Создан чат: {ctx.display_name} ({ctx.chat_id})"
)
]
def make_handle_new_chat(
client: Any | None,
store: Any | None,
) -> Callable[..., Awaitable[list]]:
async def handle_new_chat(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
if client is None or store is None:
return await _fallback_new_chat(event, auth_mgr, platform, chat_mgr, settings_mgr)
if not await auth_mgr.is_authenticated(event.user_id):
return [
OutgoingMessage(
chat_id=event.chat_id,
text="Сначала примите приглашение бота.",
)
]
user_meta = await get_user_meta(store, event.user_id)
space_id = (user_meta or {}).get("space_id")
if not space_id:
return [
OutgoingMessage(
chat_id=event.chat_id,
text="Ошибка: Space не найден. Примите приглашение бота заново.",
)
]
name = " ".join(event.args).strip() if event.args else ""
chat_id = await next_chat_id(store, event.user_id)
platform_chat_id = await next_platform_chat_id(store)
room_name = name or f"Чат {chat_id}"
response = await client.room_create(
name=room_name,
visibility=RoomVisibility.private,
is_direct=False,
invite=[event.user_id],
)
if isinstance(response, RoomCreateError):
logger.error(
"room_create failed",
user_id=event.user_id,
status_code=getattr(response, "status_code", None),
)
return [OutgoingMessage(chat_id=event.chat_id, text="Не удалось создать комнату.")]
room_id = getattr(response, "room_id", None)
if not room_id:
return [OutgoingMessage(chat_id=event.chat_id, text="Не удалось создать комнату.")]
homeserver = event.user_id.split(":")[-1]
await client.room_put_state(
room_id=space_id,
event_type="m.space.child",
content={"via": [homeserver]},
state_key=room_id,
)
selected_agent_id = await get_selected_agent_id(store, event.user_id)
room_meta: dict = {
"room_type": "chat",
"chat_id": chat_id,
"display_name": room_name,
"matrix_user_id": event.user_id,
"space_id": space_id,
"platform_chat_id": platform_chat_id,
}
if selected_agent_id:
room_meta["agent_id"] = selected_agent_id
await set_room_meta(store, room_id, room_meta)
ctx = await chat_mgr.get_or_create(
user_id=event.user_id,
chat_id=chat_id,
platform=event.platform,
surface_ref=room_id,
name=room_name,
)
return [
OutgoingMessage(
chat_id=event.chat_id,
text=f"Создан чат: {ctx.display_name} ({ctx.chat_id})",
)
]
return handle_new_chat
async def handle_list_chats(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
chats = await chat_mgr.list_active(event.user_id)
if not chats:
return [OutgoingMessage(chat_id=event.chat_id, text="Нет активных чатов.")]
lines = [f"{c.display_name} ({c.chat_id})" for c in chats]
return [OutgoingMessage(chat_id=event.chat_id, text="\n".join(lines))]
def make_handle_rename(
client: Any | None,
store: Any | None,
) -> Callable[..., Awaitable[list]]:
async def handle_rename(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
if not event.args:
return [
OutgoingMessage(chat_id=event.chat_id, text="Укажите название: !rename Название")
]
if _is_unregistered_chat_id(event.chat_id):
return [
OutgoingMessage(
chat_id=event.chat_id,
text=(
"Этот чат не найден в локальном состоянии бота. "
"Открой зарегистрированную комнату или создай новый чат через !new."
),
)
]
new_name = " ".join(event.args)
ctx = await chat_mgr.rename(event.chat_id, new_name, user_id=event.user_id)
if client is not None and ctx.surface_ref:
await client.room_put_state(
room_id=ctx.surface_ref,
event_type="m.room.name",
content={"name": new_name},
state_key="",
)
return [OutgoingMessage(chat_id=event.chat_id, text=f"Переименован в: {ctx.display_name}")]
return handle_rename
def make_handle_archive(
client: Any | None,
store: Any | None,
) -> Callable[..., Awaitable[list]]:
async def handle_archive(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
if _is_unregistered_chat_id(event.chat_id):
return [
OutgoingMessage(
chat_id=event.chat_id,
text=(
"Этот чат не найден в локальном состоянии бота. "
"Создай новый чат через !new."
),
)
]
ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id)
if ctx is None:
return [OutgoingMessage(chat_id=event.chat_id, text="Этот чат не найден.")]
await chat_mgr.archive(event.chat_id, user_id=event.user_id)
if client is not None and ctx.surface_ref:
await client.room_leave(ctx.surface_ref)
return [OutgoingMessage(chat_id=event.chat_id, text="Чат архивирован.")]
return handle_archive