surfaces/docs/research/matrix-spaces.md
Mikhail Putilovskij 67499daa61 feat: extend platform mock + add research docs
platform/interface.py:
- Add Attachment, MessageChunk, AgentEvent types
- Add stream_message() to PlatformClient Protocol (door open for streaming)
- Add WebhookReceiver Protocol

platform/mock.py:
- Add attachment_mode config (url/binary/s3)
- Implement stream_message() — single chunk, ready for real streaming
- Add register_webhook_receiver() + simulate_agent_event() for testing

docs/research/:
- telegram-forum-topics.md — aiogram 3.x Forum Topics API, FSM patterns, UX analysis
- fsm-patterns.md — FSM storage options, StateData best practices
- matrix-spaces.md — matrix-nio Space API, room ordering, invite flow
- matrix-events.md — reactions, threads, typing, sync loop pitfalls
- telegram-chat-alternatives.md — 7 alternatives for multi-chat UX, virtual chats in DM recommended
2026-03-30 14:04:34 +03:00

8.6 KiB
Raw Permalink Blame History

Research: matrix-nio Space API

Based on: Matrix Client-Server Spec, matrix-nio 0.24+, Element Web behavior.

Создание Space

Space в Matrix — это комната с room type m.space. При создании нужны state events.

Полный рабочий пример

from nio import AsyncClient, RoomCreateResponse

async def create_personal_space(client: AsyncClient, display_name: str) -> str:
    """
    Создаёт персональный Space для пользователя.
    Возвращает room_id созданного Space.
    """
    response = await client.room_create(
        name=f"Lambda — {display_name}",
        is_public=False,
        room_version="10",
        initial_state=[
            {
                "type": "m.room.topic",
                "state_key": "",
                "content": {"topic": "Lambda AI workspace"},
            },
            {
                "type": "m.room.join_rules",
                "state_key": "",
                "content": {"join_rule": "invite"},
            },
        ],
        creation_content={
            "type": "m.space",  # ЭТО ГЛАВНОЕ — иначе создастся обычная комната
        },
    )

    if isinstance(response, RoomCreateResponse):
        return response.room_id
    raise Exception(f"Failed to create space: {response}")

Подводный камень #1: creation_content обязателен

# НЕПРАВИЛЬНО — создаст обычную комнату, не Space
await client.room_create(name="My Space")

# ПРАВИЛЬНО
await client.room_create(
    name="My Space",
    creation_content={"type": "m.space"},
)

Добавление комнат в Space

Чтобы добавить комнату в Space, нужно установить state event m.space.child в самом Space.

Создание комнаты и добавление в Space

async def create_room_in_space(
    client: AsyncClient,
    space_room_id: str,
    room_name: str,
    order: str,
) -> str:
    """
    Создаёт комнату и добавляет её в Space.
    Возвращает room_id новой комнаты.
    """
    # 1. Создаём комнату
    room_resp = await client.room_create(
        name=room_name,
        is_public=False,
        room_version="10",
        initial_state=[
            {
                "type": "m.room.join_rules",
                "state_key": "",
                "content": {"join_rule": "invite"},
            },
        ],
    )
    if not isinstance(room_resp, RoomCreateResponse):
        raise Exception(f"Failed to create room: {room_resp}")
    room_id = room_resp.room_id

    # 2. Добавляем в Space через m.space.child
    # state_key = room_id дочерней комнаты (НЕ произвольное имя!)
    await client.room_put_state(
        room_id=space_room_id,
        event_type="m.space.child",
        state_key=room_id,
        content={
            "via": ["example.com"],  # серверы для присоединения — обязательно
            "order": order,          # строковый порядок
            "suggested": True,
        },
    )

    # 3. Обратная ссылка (помогает клиентам понять структуру)
    await client.room_put_state(
        room_id=room_id,
        event_type="m.space.parent",
        state_key=space_room_id,
        content={
            "via": ["example.com"],
            "canonical": True,
        },
    )

    return room_id

Подводный камень #2: state_key — это room_id, не имя

# НЕПРАВИЛЬНО
await client.room_put_state(
    room_id=space_id,
    event_type="m.space.child",
    state_key="Настройки",  # ОШИБКА: нужен room_id
    content={"via": ["example.com"]},
)

# ПРАВИЛЬНО
await client.room_put_state(
    room_id=space_id,
    event_type="m.space.child",
    state_key="!abc123:example.com",  # именно room_id
    content={"via": ["example.com"]},
)

Управление порядком комнат

Порядок в Space контролируется полем order в m.space.child. Это строковое сравнение.

Закрепить «Настройки» вверху, чаты ниже

async def setup_space_order(
    client: AsyncClient,
    space_id: str,
    settings_room_id: str,
    chat_room_ids: list[str],
) -> None:
    # Настройки вверху
    await client.room_put_state(
        room_id=space_id,
        event_type="m.space.child",
        state_key=settings_room_id,
        content={"via": ["example.com"], "order": "00", "suggested": True},
    )

    # Чаты с нарастающим порядком
    for idx, chat_id in enumerate(chat_room_ids):
        await client.room_put_state(
            room_id=space_id,
            event_type="m.space.child",
            state_key=chat_id,
            content={"via": ["example.com"], "order": f"{10 + idx:02d}", "suggested": True},
        )

Подводный камень #3: order — строковое, нужен padding

# НЕПРАВИЛЬНО: "10" < "2" в строковом сравнении!
orders = ["0", "1", "10", "11", "2", "3"]

# ПРАВИЛЬНО: одинаковая длина
orders = ["00", "01", "02", "03", "10", "11"]

Приглашение пользователя в Space и дочерние комнаты

Простого способа пригласить в Space и все дочерние комнаты одним вызовом нет — нужно приглашать в каждую отдельно.

async def invite_user_to_space_and_rooms(
    client: AsyncClient,
    space_id: str,
    child_room_ids: list[str],
    user_id: str,
) -> None:
    # Приглашаем в Space
    await client.room_invite(space_id, user_id)

    # Приглашаем в каждую дочернюю комнату
    for room_id in child_room_ids:
        await client.room_invite(room_id, user_id)

Подводный камень #4: пользователь в Space не видит комнаты автоматически

Приглашение в Space не тянет дочерние комнаты — нужно приглашать каждую явно.


Переименование и удаление из Space

Переименование комнаты

async def rename_room(client: AsyncClient, room_id: str, new_name: str) -> None:
    await client.room_put_state(
        room_id=room_id,
        event_type="m.room.name",
        state_key="",
        content={"name": new_name},
    )

Удаление комнаты из Space (без удаления самой комнаты)

async def remove_from_space(
    client: AsyncClient,
    space_id: str,
    room_id: str,
) -> None:
    # Пустой content = убрать из Space
    await client.room_put_state(
        room_id=space_id,
        event_type="m.space.child",
        state_key=room_id,
        content={},  # пустое — убирает комнату из Space
    )

Подводные камни (резюме)

Проблема Решение
Space создаётся как обычная комната Используй creation_content={"type": "m.space"}
Комнаты не видны в Space Добавь m.space.child с state_key=room_id
Неправильный порядок Строковый padding: "00", "01", "10"
Пользователь не видит комнаты Приглашай в каждую дочернюю отдельно
Комната не убирается из Space Установи пустой content: {} в m.space.child
via не указан Всегда указывай "via": ["homeserver.com"]

Выводы для нашей реализации

  1. При регистрации: create_personal_space() → сохранить space_id в БД
  2. Комната «Настройки»: создать первой с order: "00"
  3. «Чат 1»: создать второй с order: "10"
  4. Команда !new: создать комнату + m.space.child + пригласить пользователя
  5. Команда !rename: обновить m.room.name
  6. Команда !archive: установить пустой m.space.child