feat: finalize matrix platform audit and docs

This commit is contained in:
Mikhail Putilovskij 2026-04-21 15:35:03 +03:00
parent 6422c7db58
commit 4524a6abc8
30 changed files with 3093 additions and 176 deletions

View file

@ -41,6 +41,7 @@ from adapter.matrix.store import (
get_load_pending,
get_room_meta,
get_staged_attachments,
next_platform_chat_id,
remove_staged_attachment_at,
set_pending_confirm,
set_platform_chat_id,
@ -163,7 +164,11 @@ class MatrixBot:
return
if room_meta.get("platform_chat_id"):
return
await set_platform_chat_id(self.runtime.store, room_id, f"matrix:{room_id}")
await set_platform_chat_id(
self.runtime.store,
room_id,
await next_platform_chat_id(self.runtime.store),
)
async def on_room_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
if getattr(event, "sender", None) == self.client.user_id:

View file

@ -41,12 +41,7 @@ def build_workspace_attachment_path(
safe_room = _sanitize_component(room_id.lstrip("!"))
safe_name = _sanitize_component(filename) or "attachment.bin"
relative_path = (
Path("surfaces")
/ "matrix"
/ safe_user
/ safe_room
/ "inbox"
/ f"{stamp}-{safe_name}"
Path("surfaces") / "matrix" / safe_user / safe_room / "inbox" / f"{stamp}-{safe_name}"
)
return relative_path.as_posix(), workspace_root / relative_path

View file

@ -17,7 +17,6 @@ from adapter.matrix.handlers.settings import (
handle_help,
handle_settings,
handle_settings_connectors,
handle_unknown_command,
handle_settings_plan,
handle_settings_safety,
handle_settings_skills,
@ -25,6 +24,7 @@ from adapter.matrix.handlers.settings import (
handle_settings_status,
handle_settings_whoami,
handle_toggle_skill,
handle_unknown_command,
)
from core.handler import EventDispatcher
from core.protocol import IncomingCallback, IncomingCommand
@ -44,7 +44,13 @@ def register_matrix_handlers(
dispatcher.register(IncomingCommand, "archive", make_handle_archive(client, store))
dispatcher.register(IncomingCommand, "help", handle_help)
dispatcher.register(IncomingCommand, "settings", handle_settings)
dispatcher.register(IncomingCommand, "reset", make_handle_reset(store, prototype_state) if prototype_state is not None else handle_settings)
dispatcher.register(
IncomingCommand,
"reset",
make_handle_reset(store, prototype_state)
if prototype_state is not None
else handle_settings,
)
dispatcher.register(IncomingCommand, "settings_skills", handle_settings_skills)
dispatcher.register(IncomingCommand, "settings_connectors", handle_settings_connectors)
dispatcher.register(IncomingCommand, "settings_soul", handle_settings_soul)
@ -59,6 +65,10 @@ def register_matrix_handlers(
dispatcher.register(IncomingCommand, "*", handle_unknown_command)
if agent_api is not None and prototype_state is not None:
dispatcher.register(IncomingCommand, "save", make_handle_save(agent_api, store, prototype_state))
dispatcher.register(
IncomingCommand,
"save",
make_handle_save(agent_api, store, prototype_state),
)
dispatcher.register(IncomingCommand, "load", make_handle_load(store, prototype_state))
dispatcher.register(IncomingCommand, "context", make_handle_context(store, prototype_state))

View file

@ -1,14 +1,14 @@
from __future__ import annotations
import structlog
from typing import Any
import structlog
from nio.api import RoomVisibility
from nio.responses import RoomCreateError
from adapter.matrix.store import (
get_user_meta,
next_chat_id,
next_platform_chat_id,
set_room_meta,
set_user_meta,
)
@ -62,6 +62,7 @@ async def provision_workspace_chat(
next_chat_index = int(user_meta.get("next_chat_index", 1))
chat_id = f"C{next_chat_index}"
platform_chat_id = await next_platform_chat_id(store)
room_name = room_name_override or _default_room_name(chat_id)
chat_resp = await client.room_create(
name=room_name,
@ -98,7 +99,7 @@ async def provision_workspace_chat(
"display_name": room_name,
"matrix_user_id": matrix_user_id,
"space_id": space_id,
"platform_chat_id": f"matrix:{chat_room_id}",
"platform_chat_id": platform_chat_id,
},
)
await chat_mgr.get_or_create(
@ -118,7 +119,15 @@ async def provision_workspace_chat(
}
async def handle_invite(client: Any, room: Any, event: Any, platform, store, auth_mgr, chat_mgr) -> None:
async def handle_invite(
client: Any,
room: Any,
event: Any,
platform,
store,
auth_mgr,
chat_mgr,
) -> None:
matrix_user_id = getattr(event, "sender", "")
display_name = getattr(room, "display_name", None) or matrix_user_id

View file

@ -1,12 +1,18 @@
from __future__ import annotations
from typing import Any, Awaitable, Callable
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_user_meta, next_chat_id, set_room_meta
from adapter.matrix.store import (
get_user_meta,
next_chat_id,
next_platform_chat_id,
set_room_meta,
)
from core.protocol import IncomingCommand, OutgoingMessage
logger = structlog.get_logger(__name__)
@ -69,6 +75,7 @@ def make_handle_new_chat(
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(
@ -106,7 +113,7 @@ def make_handle_new_chat(
"display_name": room_name,
"matrix_user_id": event.user_id,
"space_id": space_id,
"platform_chat_id": f"matrix:{room_id}",
"platform_chat_id": platform_chat_id,
},
)
ctx = await chat_mgr.get_or_create(
@ -151,7 +158,10 @@ def make_handle_rename(
return [
OutgoingMessage(
chat_id=event.chat_id,
text="Этот чат не найден в локальном состоянии бота. Открой зарегистрированную комнату или создай новый чат через !new.",
text=(
"Этот чат не найден в локальном состоянии бота. "
"Открой зарегистрированную комнату или создай новый чат через !new."
),
)
]
@ -181,7 +191,10 @@ def make_handle_archive(
return [
OutgoingMessage(
chat_id=event.chat_id,
text="Этот чат не найден в локальном состоянии бота. Создай новый чат через !new.",
text=(
"Этот чат не найден в локальном состоянии бота. "
"Создай новый чат через !new."
),
)
]
ctx = await chat_mgr.get(event.chat_id, user_id=event.user_id)

View file

@ -7,7 +7,12 @@ from typing import TYPE_CHECKING
import httpx
import structlog
from adapter.matrix.store import get_room_meta, set_load_pending, set_platform_chat_id
from adapter.matrix.store import (
get_room_meta,
next_platform_chat_id,
set_load_pending,
set_platform_chat_id,
)
from core.protocol import IncomingCommand, OutgoingEvent, OutgoingMessage
if TYPE_CHECKING:
@ -45,7 +50,7 @@ async def _resolve_room_id(event: IncomingCommand, chat_mgr) -> str:
async def _resolve_context_scope(
event: IncomingCommand,
store: "StateStore",
store: StateStore,
chat_mgr,
) -> tuple[str, str | None]:
room_id = await _resolve_room_id(event, chat_mgr)
@ -54,7 +59,7 @@ async def _resolve_context_scope(
return room_id, platform_chat_id
def make_handle_save(agent_api, store: "StateStore", prototype_state: "PrototypeStateStore"):
def make_handle_save(agent_api, store: StateStore, prototype_state: PrototypeStateStore):
async def handle_save(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list[OutgoingEvent]:
@ -96,7 +101,7 @@ def make_handle_save(agent_api, store: "StateStore", prototype_state: "Prototype
return handle_save
def make_handle_load(store: "StateStore", prototype_state: "PrototypeStateStore"):
def make_handle_load(store: StateStore, prototype_state: PrototypeStateStore):
async def handle_load(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list[OutgoingEvent]:
@ -123,17 +128,15 @@ def make_handle_load(store: "StateStore", prototype_state: "PrototypeStateStore"
return handle_load
def make_handle_reset(store: "StateStore", prototype_state: "PrototypeStateStore"):
def make_handle_reset(store: StateStore, prototype_state: PrototypeStateStore):
async def handle_reset(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list[OutgoingEvent]:
import time
room_id = await _resolve_room_id(event, chat_mgr)
room_meta = await get_room_meta(store, room_id)
old_chat_id = (room_meta or {}).get("platform_chat_id") or room_id
new_chat_id = f"matrix:{room_id}#{int(time.time())}"
new_chat_id = await next_platform_chat_id(store)
await set_platform_chat_id(store, room_id, new_chat_id)
disconnect = getattr(platform, "disconnect_chat", None)
@ -142,7 +145,12 @@ def make_handle_reset(store: "StateStore", prototype_state: "PrototypeStateStore
await prototype_state.clear_current_session(new_chat_id)
return [OutgoingMessage(chat_id=event.chat_id, text="Контекст сброшен. Агент не помнит предыдущий разговор.")]
return [
OutgoingMessage(
chat_id=event.chat_id,
text="Контекст сброшен. Агент не помнит предыдущий разговор.",
)
]
return handle_reset
@ -170,7 +178,7 @@ async def _call_reset_endpoint(agent_base_url: str, chat_id: str) -> list[Outgoi
return [OutgoingMessage(chat_id=chat_id, text="Контекст сброшен.")]
def make_handle_context(store: "StateStore", prototype_state: "PrototypeStateStore"):
def make_handle_context(store: StateStore, prototype_state: PrototypeStateStore):
async def handle_context(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list[OutgoingEvent]:

View file

@ -2,7 +2,6 @@ from __future__ import annotations
from core.protocol import IncomingCommand, OutgoingMessage
HELP_TEXT = "\n".join(
[
"Команды",
@ -32,9 +31,7 @@ async def handle_settings(
return [OutgoingMessage(chat_id=event.chat_id, text=MVP_UNAVAILABLE_TEXT)]
async def handle_help(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
async def handle_help(event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr) -> list:
return [OutgoingMessage(chat_id=event.chat_id, text=HELP_TEXT)]

View file

@ -13,7 +13,9 @@ PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"
LOAD_PENDING_PREFIX = "matrix_load_pending:"
RESET_PENDING_PREFIX = "matrix_reset_pending:"
STAGED_ATTACHMENTS_PREFIX = "matrix_staged_attachments:"
PLATFORM_CHAT_SEQ_KEY = "matrix_platform_chat_seq"
_STAGED_ATTACHMENTS_LOCKS: WeakValueDictionary[str, asyncio.Lock] = WeakValueDictionary()
_PLATFORM_CHAT_SEQ_LOCK = asyncio.Lock()
async def get_room_meta(store: StateStore, room_id: str) -> dict | None:
@ -29,9 +31,7 @@ async def get_platform_chat_id(store: StateStore, room_id: str) -> str | None:
return meta.get("platform_chat_id") if meta else None
async def set_platform_chat_id(
store: StateStore, room_id: str, platform_chat_id: str
) -> None:
async def set_platform_chat_id(store: StateStore, room_id: str, platform_chat_id: str) -> None:
meta = dict(await get_room_meta(store, room_id) or {})
meta["platform_chat_id"] = platform_chat_id
await set_room_meta(store, room_id, meta)
@ -71,16 +71,29 @@ async def next_chat_id(store: StateStore, matrix_user_id: str) -> str:
return f"C{index}"
async def next_platform_chat_id(store: StateStore) -> str:
async with _PLATFORM_CHAT_SEQ_LOCK:
data = await store.get(PLATFORM_CHAT_SEQ_KEY)
index = int((data or {}).get("next_platform_chat_index", 1))
await store.set(
PLATFORM_CHAT_SEQ_KEY,
{"next_platform_chat_index": index + 1},
)
return str(index)
def _pending_confirm_key(user_id: str, room_id: str | None = None) -> str:
if room_id is None:
return f"{PENDING_CONFIRM_PREFIX}{user_id}"
return f"{PENDING_CONFIRM_PREFIX}{user_id}:{room_id}"
async def get_pending_confirm(
store: StateStore, user_id: str, room_id: str | None = None
) -> dict | None:
return await store.get(_pending_confirm_key(user_id, room_id))
async def set_pending_confirm(
store: StateStore, user_id: str, room_id: str | dict, meta: dict | None = None
) -> None:
@ -146,9 +159,7 @@ def _staged_attachments_lock(room_id: str, user_id: str) -> asyncio.Lock:
return lock
async def get_staged_attachments(
store: StateStore, room_id: str, user_id: str
) -> list[dict]:
async def get_staged_attachments(store: StateStore, room_id: str, user_id: str) -> list[dict]:
data = await store.get(_staged_attachments_key(room_id, user_id))
if not isinstance(data, dict):
return []
@ -166,9 +177,7 @@ async def add_staged_attachment(
async with _staged_attachments_lock(room_id, user_id):
attachments = await get_staged_attachments(store, room_id, user_id)
attachments.append(attachment)
await store.set(
_staged_attachments_key(room_id, user_id), {"attachments": attachments}
)
await store.set(_staged_attachments_key(room_id, user_id), {"attachments": attachments})
async def remove_staged_attachment_at(
@ -181,16 +190,12 @@ async def remove_staged_attachment_at(
removed = attachments.pop(index)
if attachments:
await store.set(
_staged_attachments_key(room_id, user_id), {"attachments": attachments}
)
await store.set(_staged_attachments_key(room_id, user_id), {"attachments": attachments})
else:
await store.delete(_staged_attachments_key(room_id, user_id))
return removed
async def clear_staged_attachments(
store: StateStore, room_id: str, user_id: str
) -> None:
async def clear_staged_attachments(store: StateStore, room_id: str, user_id: str) -> None:
async with _staged_attachments_lock(room_id, user_id):
await store.delete(_staged_attachments_key(room_id, user_id))