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