surfaces/.planning/phases/01-matrix-qa-polish/01-01-PLAN.md

16 KiB
Raw Blame History

phase plan type wave depends_on files_modified autonomous requirements must_haves
01-matrix-qa-polish 01 execute 1
adapter/matrix/store.py
adapter/matrix/handlers/auth.py
adapter/matrix/room_router.py
true
truths artifacts key_links
Bot creates a Space named 'Lambda - {display_name}' on first invite
Bot creates 'Chat 1' room inside that Space
Bot invites user to both Space and chat room
space_id is stored in user_meta for future lookups
Repeated invite does not create a second Space (idempotent)
chat_id uses next_chat_id, not hardcoded C1
path provides contains
adapter/matrix/store.py pending_confirm helpers + PENDING_CONFIRM_PREFIX PENDING_CONFIRM_PREFIX
path provides contains
adapter/matrix/handlers/auth.py Space+rooms invite flow space=True
path provides
adapter/matrix/room_router.py space-aware resolve_chat_id
from to via pattern
adapter/matrix/handlers/auth.py adapter/matrix/store.py set_user_meta with space_id set_user_meta.*space_id
from to via pattern
adapter/matrix/handlers/auth.py adapter/matrix/store.py next_chat_id for dynamic C-number next_chat_id
Rewrite the Matrix invite flow from DM-first to Space+rooms architecture, and add pending_confirm store helpers.

Purpose: Per D-01/D-02, the bot must create a Space per user on first invite, with a "Chat 1" room inside it. The old DM join + hardcoded C1 flow must be fully replaced. Additionally, pending_confirm helpers are added to store.py now (used by Plan 03) to avoid file conflicts.

Output: Working handle_invite that creates Space + chat room, stores space_id in user_meta, uses next_chat_id. Store has pending_confirm helpers.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/01-matrix-qa-polish/01-CONTEXT.md @.planning/phases/01-matrix-qa-polish/01-RESEARCH.md

@adapter/matrix/store.py @adapter/matrix/handlers/auth.py @adapter/matrix/room_router.py @core/protocol.py

ROOM_META_PREFIX = "matrix_room:"
USER_META_PREFIX = "matrix_user:"
ROOM_STATE_PREFIX = "matrix_state:"
SKILLS_MSG_PREFIX = "matrix_skills_msg:"

async def get_room_meta(store: StateStore, room_id: str) -> dict | None
async def set_room_meta(store: StateStore, room_id: str, meta: dict) -> None
async def get_user_meta(store: StateStore, matrix_user_id: str) -> dict | None
async def set_user_meta(store: StateStore, matrix_user_id: str, meta: dict) -> None
async def get_room_state(store: StateStore, room_id: str) -> str
async def set_room_state(store: StateStore, room_id: str, state: str) -> None
async def get_skills_message_id(store: StateStore, room_id: str) -> str | None
async def set_skills_message_id(store: StateStore, room_id: str, event_id: str) -> None
async def next_chat_id(store: StateStore, matrix_user_id: str) -> str
@dataclass
class OutgoingMessage:
    chat_id: str
    text: str
    parse_mode: str = "plain"
    attachments: list[Attachment] = field(default_factory=list)
    reply_to: str | None = None
from nio.responses import RoomCreateError, RoomPutStateError, RoomInviteError
# RoomCreateError has .status_code, no .room_id
# RoomPutStateError has .status_code
Task 1: Add pending_confirm helpers to store.py adapter/matrix/store.py adapter/matrix/store.py Add three new helper functions and one constant to `adapter/matrix/store.py`, AFTER the existing `next_chat_id` function. Keep ALL existing code unchanged.

Add this constant after line 8 (after SKILLS_MSG_PREFIX):

PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"

Add these three functions at the end of the file:

async def get_pending_confirm(store: StateStore, room_id: str) -> dict | None:
    return await store.get(f"{PENDING_CONFIRM_PREFIX}{room_id}")


async def set_pending_confirm(store: StateStore, room_id: str, meta: dict) -> None:
    await store.set(f"{PENDING_CONFIRM_PREFIX}{room_id}", meta)


async def clear_pending_confirm(store: StateStore, room_id: str) -> None:
    await store.delete(f"{PENDING_CONFIRM_PREFIX}{room_id}")

Note: store.delete is already available on StateStore (both InMemoryStore and SQLiteStore implement it). Verify by checking core/store.py — if delete is not present, use store.set(key, None) as equivalent.

Per D-08: pending_confirm is keyed by room_id (not user_id+room_id) because in Space model each room belongs to one user. cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.store import get_pending_confirm, set_pending_confirm, clear_pending_confirm, PENDING_CONFIRM_PREFIX; print('OK')" <acceptance_criteria>

  • adapter/matrix/store.py contains the string PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"
  • adapter/matrix/store.py contains function async def get_pending_confirm(store: StateStore, room_id: str) -> dict | None:
  • adapter/matrix/store.py contains function async def set_pending_confirm(store: StateStore, room_id: str, meta: dict) -> None:
  • adapter/matrix/store.py contains function async def clear_pending_confirm(store: StateStore, room_id: str) -> None:
  • All existing functions (get_room_meta, set_room_meta, get_user_meta, set_user_meta, get_room_state, set_room_state, get_skills_message_id, set_skills_message_id, next_chat_id) still exist unchanged
  • pytest tests/adapter/matrix/test_store.py -x -q passes (all existing store tests green) </acceptance_criteria> pending_confirm helpers importable and existing store tests pass
Task 2: Rewrite handle_invite for Space+rooms (per D-01, D-02) adapter/matrix/handlers/auth.py adapter/matrix/handlers/auth.py, adapter/matrix/store.py, core/protocol.py Completely rewrite `adapter/matrix/handlers/auth.py`. The new `handle_invite` must:
  1. Idempotency check on user_meta (not room_meta): Check get_user_meta(store, matrix_user_id). If it already has a space_id, return early (do nothing). This replaces the old get_room_meta(store, room.room_id) check. Per Pitfall 5 from RESEARCH.md.

  2. Create Space: Call await client.room_create(name=f"Lambda -- {display_name}", space=True, visibility="private"). Check isinstance(resp, RoomCreateError) — if error, log and return early.

  3. Create first chat room: Call await client.room_create(name="Chat 1", visibility="private", is_direct=False). Check isinstance(resp, RoomCreateError).

  4. Add room to Space: Call await client.room_put_state(room_id=space_id, event_type="m.space.child", content={"via": [homeserver]}, state_key=chat_room_id). Extract homeserver as matrix_user_id.split(":")[-1].

  5. Invite user to both: await client.room_invite(space_id, matrix_user_id) and await client.room_invite(chat_room_id, matrix_user_id).

  6. Use next_chat_id: Call chat_id = await next_chat_id(store, matrix_user_id) to get "C1" (not hardcoded). Per D-05 and Pitfall 6 from RESEARCH.md.

  7. Store user_meta: await set_user_meta(store, matrix_user_id, {"space_id": space_id, "next_chat_index": 2}). Note: next_chat_id already incremented to 2, so store will already have next_chat_index=2 after the call. Just ensure space_id is stored in user_meta.

  8. Store room_meta: await set_room_meta(store, chat_room_id, {"room_type": "chat", "chat_id": chat_id, "display_name": "Chat 1", "matrix_user_id": matrix_user_id, "space_id": space_id}).

  9. Auth confirm: Keep await auth_mgr.confirm(matrix_user_id).

  10. Platform get_or_create_user: Keep existing call.

  11. Welcome message: Send to the CHAT ROOM (not the invite room). Text:

"Привет, {display_name}! Пиши -- я здесь.\n\nКоманды: !new . !chats . !rename . !archive . !skills . !soul . !safety . !settings"
  1. Also join the original invite room: Keep await client.join(room.room_id) so the bot accepts the DM invite (otherwise nio may not process events from this user). Put this BEFORE Space creation.

Complete replacement for adapter/matrix/handlers/auth.py:

from __future__ import annotations

import structlog
from typing import Any

from nio.responses import RoomCreateError

from adapter.matrix.store import get_user_meta, set_user_meta, set_room_meta, next_chat_id

logger = structlog.get_logger(__name__)


async def handle_invite(client: Any, room: Any, event: Any, platform, store, auth_mgr) -> None:
    matrix_user_id = getattr(event, "sender", "")
    display_name = getattr(room, "display_name", None) or matrix_user_id

    # Idempotency: if user already has a Space, skip
    existing = await get_user_meta(store, matrix_user_id)
    if existing and existing.get("space_id"):
        return

    # Accept the invite room (so nio tracks this user)
    await client.join(room.room_id)

    # Register user on platform
    user = await platform.get_or_create_user(
        external_id=matrix_user_id,
        platform="matrix",
        display_name=display_name,
    )
    await auth_mgr.confirm(matrix_user_id)

    homeserver = matrix_user_id.split(":")[-1]

    # 1. Create Space
    space_resp = await client.room_create(
        name=f"Lambda \u2014 {display_name}",
        space=True,
        visibility="private",
    )
    if isinstance(space_resp, RoomCreateError):
        logger.error("space creation failed", user=matrix_user_id, error=getattr(space_resp, "status_code", None))
        return
    space_id = space_resp.room_id

    # 2. Create first chat room
    chat_resp = await client.room_create(
        name="\u0427\u0430\u0442 1",
        visibility="private",
        is_direct=False,
    )
    if isinstance(chat_resp, RoomCreateError):
        logger.error("chat room creation failed", user=matrix_user_id, error=getattr(chat_resp, "status_code", None))
        return
    chat_room_id = chat_resp.room_id

    # 3. Link chat room into Space
    await client.room_put_state(
        room_id=space_id,
        event_type="m.space.child",
        content={"via": [homeserver]},
        state_key=chat_room_id,
    )

    # 4. Invite user
    await client.room_invite(space_id, matrix_user_id)
    await client.room_invite(chat_room_id, matrix_user_id)

    # 5. Store metadata
    chat_id = await next_chat_id(store, matrix_user_id)  # Returns "C1", increments to 2

    # Update user_meta to include space_id (next_chat_id already set next_chat_index)
    user_meta = await get_user_meta(store, matrix_user_id) or {}
    user_meta["space_id"] = space_id
    await set_user_meta(store, matrix_user_id, user_meta)

    await set_room_meta(store, chat_room_id, {
        "room_type": "chat",
        "chat_id": chat_id,
        "display_name": "\u0427\u0430\u0442 1",
        "matrix_user_id": matrix_user_id,
        "space_id": space_id,
    })

    # 6. Welcome message in chat room
    welcome = (
        f"\u041f\u0440\u0438\u0432\u0435\u0442, {user.display_name or matrix_user_id}! \u041f\u0438\u0448\u0438 \u2014 \u044f \u0437\u0434\u0435\u0441\u044c.\n\n"
        "\u041a\u043e\u043c\u0430\u043d\u0434\u044b: !new \u00b7 !chats \u00b7 !rename \u00b7 !archive \u00b7 !skills \u00b7 !soul \u00b7 !safety \u00b7 !settings"
    )
    await client.room_send(chat_room_id, "m.room.message", {"msgtype": "m.text", "body": welcome})

IMPORTANT: Use the actual Cyrillic characters in strings, not unicode escapes. The unicode escapes above are just for plan encoding safety. The actual file must have readable Russian text: "Чат 1", "Привет, ...", "Команды: ..." etc. cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.handlers.auth import handle_invite; print('OK')" <acceptance_criteria>

  • adapter/matrix/handlers/auth.py does NOT contain the string "chat_id": "C1" (hardcode removed)
  • adapter/matrix/handlers/auth.py contains the string space=True
  • adapter/matrix/handlers/auth.py contains the string room_put_state
  • adapter/matrix/handlers/auth.py contains the string next_chat_id
  • adapter/matrix/handlers/auth.py contains the string get_user_meta
  • adapter/matrix/handlers/auth.py imports from nio.responses (specifically RoomCreateError)
  • adapter/matrix/handlers/auth.py contains room_invite (invites user to Space and chat room)
  • adapter/matrix/handlers/auth.py contains m.space.child string </acceptance_criteria> handle_invite creates Space + chat room, stores space_id, uses next_chat_id, is idempotent on user_meta
Task 3: Update room_router.py for space-aware resolve adapter/matrix/room_router.py adapter/matrix/room_router.py, adapter/matrix/store.py The current `resolve_chat_id` in `adapter/matrix/room_router.py` auto-creates room_meta with a new chat_id if none exists. This is problematic in the Space model because rooms should only be created through `handle_invite` or `!new`. Update the fallback behavior:

Replace the entire adapter/matrix/room_router.py with:

from __future__ import annotations

import structlog

from adapter.matrix.store import get_room_meta
from core.store import StateStore

logger = structlog.get_logger(__name__)


async def resolve_chat_id(store: StateStore, room_id: str, matrix_user_id: str) -> str:
    meta = await get_room_meta(store, room_id)
    if meta and meta.get("chat_id"):
        return meta["chat_id"]

    # Room not registered — this can happen if the bot receives a message
    # in a room it didn't create (e.g., a DM). Return a fallback chat_id
    # based on room_id to avoid crashing, but don't auto-register.
    logger.warning("unregistered_room", room_id=room_id, user=matrix_user_id)
    return f"unregistered:{room_id}"

Key changes:

  • Remove next_chat_id and set_room_meta imports (no longer auto-creating)
  • Remove auto-creation of room_meta for unknown rooms
  • Return f"unregistered:{room_id}" as fallback so messages from unregistered rooms don't crash but are identifiable
  • Add structlog warning for debugging cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.room_router import resolve_chat_id; print('OK')" <acceptance_criteria>
  • adapter/matrix/room_router.py does NOT contain next_chat_id
  • adapter/matrix/room_router.py does NOT contain set_room_meta
  • adapter/matrix/room_router.py contains unregistered:{room_id} or f"unregistered:{room_id}"
  • adapter/matrix/room_router.py contains get_room_meta
  • adapter/matrix/room_router.py contains logger.warning </acceptance_criteria> resolve_chat_id no longer auto-creates rooms, returns fallback for unregistered rooms
After all 3 tasks: - `python -c "from adapter.matrix.handlers.auth import handle_invite; from adapter.matrix.store import get_pending_confirm, set_pending_confirm, clear_pending_confirm; from adapter.matrix.room_router import resolve_chat_id; print('ALL IMPORTS OK')"` - `pytest tests/adapter/matrix/test_store.py -x -q` passes (existing store tests still green)

<success_criteria>

  • handle_invite creates Space (space=True) + chat room + room_put_state link
  • No hardcoded "C1" in auth.py
  • pending_confirm helpers available in store.py
  • room_router doesn't auto-create rooms
  • Existing store tests pass </success_criteria>
After completion, create `.planning/phases/01-matrix-qa-polish/01-01-SUMMARY.md`