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

373 lines
16 KiB
Markdown
Raw Permalink 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.

---
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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- From adapter/matrix/store.py — current helpers the executor must preserve: -->
```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
```
<!-- From core/protocol.py — types used but NOT modified: -->
```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
```
<!-- From nio.responses — error types for isinstance checks: -->
```python
from nio.responses import RoomCreateError, RoomPutStateError, RoomInviteError
# RoomCreateError has .status_code, no .room_id
# RoomPutStateError has .status_code
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add pending_confirm helpers to store.py</name>
<files>adapter/matrix/store.py</files>
<read_first>adapter/matrix/store.py</read_first>
<action>
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.
</action>
<verify>
<automated>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')"</automated>
</verify>
<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>
<done>pending_confirm helpers importable and existing store tests pass</done>
</task>
<task type="auto">
<name>Task 2: Rewrite handle_invite for Space+rooms (per D-01, D-02)</name>
<files>adapter/matrix/handlers/auth.py</files>
<read_first>adapter/matrix/handlers/auth.py, adapter/matrix/store.py, core/protocol.py</read_first>
<action>
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.
</action>
<verify>
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.handlers.auth import handle_invite; print('OK')"</automated>
</verify>
<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>
<done>handle_invite creates Space + chat room, stores space_id, uses next_chat_id, is idempotent on user_meta</done>
</task>
<task type="auto">
<name>Task 3: Update room_router.py for space-aware resolve</name>
<files>adapter/matrix/room_router.py</files>
<read_first>adapter/matrix/room_router.py, adapter/matrix/store.py</read_first>
<action>
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
</action>
<verify>
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.room_router import resolve_chat_id; print('OK')"</automated>
</verify>
<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>
<done>resolve_chat_id no longer auto-creates rooms, returns fallback for unregistered rooms</done>
</task>
</tasks>
<verification>
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)
</verification>
<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>
<output>
After completion, create `.planning/phases/01-matrix-qa-polish/01-01-SUMMARY.md`
</output>