--- phase: 01-matrix-qa-polish plan: 01 type: execute wave: 1 depends_on: [] files_modified: - adapter/matrix/store.py - adapter/matrix/handlers/auth.py - adapter/matrix/room_router.py autonomous: true requirements: [] must_haves: truths: - "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" artifacts: - path: "adapter/matrix/store.py" provides: "pending_confirm helpers + PENDING_CONFIRM_PREFIX" contains: "PENDING_CONFIRM_PREFIX" - path: "adapter/matrix/handlers/auth.py" provides: "Space+rooms invite flow" contains: "space=True" - path: "adapter/matrix/room_router.py" provides: "space-aware resolve_chat_id" key_links: - from: "adapter/matrix/handlers/auth.py" to: "adapter/matrix/store.py" via: "set_user_meta with space_id" pattern: "set_user_meta.*space_id" - from: "adapter/matrix/handlers/auth.py" to: "adapter/matrix/store.py" via: "next_chat_id for dynamic C-number" pattern: "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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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 ```python 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 ``` ```python @dataclass class OutgoingMessage: chat_id: str text: str parse_mode: str = "plain" attachments: list[Attachment] = field(default_factory=list) reply_to: str | None = None ``` ```python 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`): ```python PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:" ``` Add these three functions at the end of the file: ```python 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')" - `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) 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" ``` 12. **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`: ```python 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')" - `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 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: ```python 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')" - `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` 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) - 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 After completion, create `.planning/phases/01-matrix-qa-polish/01-01-SUMMARY.md`