16 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01-matrix-qa-polish | 01 | execute | 1 |
|
true |
|
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.pycontains the stringPENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"adapter/matrix/store.pycontains functionasync def get_pending_confirm(store: StateStore, room_id: str) -> dict | None:adapter/matrix/store.pycontains functionasync def set_pending_confirm(store: StateStore, room_id: str, meta: dict) -> None:adapter/matrix/store.pycontains functionasync 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 -qpasses (all existing store tests green) </acceptance_criteria> pending_confirm helpers importable and existing store tests pass
-
Idempotency check on user_meta (not room_meta): Check
get_user_meta(store, matrix_user_id). If it already has aspace_id, return early (do nothing). This replaces the oldget_room_meta(store, room.room_id)check. Per Pitfall 5 from RESEARCH.md. -
Create Space: Call
await client.room_create(name=f"Lambda -- {display_name}", space=True, visibility="private"). Checkisinstance(resp, RoomCreateError)— if error, log and return early. -
Create first chat room: Call
await client.room_create(name="Chat 1", visibility="private", is_direct=False). Checkisinstance(resp, RoomCreateError). -
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). Extracthomeserverasmatrix_user_id.split(":")[-1]. -
Invite user to both:
await client.room_invite(space_id, matrix_user_id)andawait client.room_invite(chat_room_id, matrix_user_id). -
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. -
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. -
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}). -
Auth confirm: Keep
await auth_mgr.confirm(matrix_user_id). -
Platform get_or_create_user: Keep existing call.
-
Welcome message: Send to the CHAT ROOM (not the invite room). Text:
"Привет, {display_name}! Пиши -- я здесь.\n\nКоманды: !new . !chats . !rename . !archive . !skills . !soul . !safety . !settings"
- 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.pydoes NOT contain the string"chat_id": "C1"(hardcode removed)adapter/matrix/handlers/auth.pycontains the stringspace=Trueadapter/matrix/handlers/auth.pycontains the stringroom_put_stateadapter/matrix/handlers/auth.pycontains the stringnext_chat_idadapter/matrix/handlers/auth.pycontains the stringget_user_metaadapter/matrix/handlers/auth.pyimports fromnio.responses(specificallyRoomCreateError)adapter/matrix/handlers/auth.pycontainsroom_invite(invites user to Space and chat room)adapter/matrix/handlers/auth.pycontainsm.space.childstring </acceptance_criteria> handle_invite creates Space + chat room, stores space_id, uses next_chat_id, is idempotent on user_meta
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_idandset_room_metaimports (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.pydoes NOT containnext_chat_idadapter/matrix/room_router.pydoes NOT containset_room_metaadapter/matrix/room_router.pycontainsunregistered:{room_id}orf"unregistered:{room_id}"adapter/matrix/room_router.pycontainsget_room_metaadapter/matrix/room_router.pycontainslogger.warning</acceptance_criteria> resolve_chat_id no longer auto-creates rooms, returns fallback for unregistered rooms
<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>