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

542 lines
20 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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: 03
type: execute
wave: 2
depends_on: ["01-01", "01-02"]
files_modified:
- adapter/matrix/bot.py
- adapter/matrix/reactions.py
- adapter/matrix/handlers/confirm.py
- adapter/matrix/handlers/settings.py
autonomous: true
requirements: []
must_haves:
truths:
- "OutgoingUI renders as text + '!yes / !no' hint, no m.reaction events sent"
- "_button_action_to_reaction function is removed from bot.py"
- "on_reaction callback is removed from bot.py"
- "ReactionEvent import is removed from bot.py"
- "build_skills_text no longer mentions reactions 1-9"
- "build_confirmation_text uses !yes/!no instead of reaction emojis"
- "!yes reads pending_confirm from store and returns action description"
- "!no clears pending_confirm and returns cancellation message"
- "!settings returns a read-only dashboard with skills/soul/safety/chats status"
artifacts:
- path: "adapter/matrix/bot.py"
provides: "Clean send_outgoing without reactions, pending_confirm storage on OutgoingUI"
contains: "!yes"
- path: "adapter/matrix/reactions.py"
provides: "Updated text builders without reaction references"
- path: "adapter/matrix/handlers/confirm.py"
provides: "!yes/!no handlers reading pending_confirm"
contains: "get_pending_confirm"
- path: "adapter/matrix/handlers/settings.py"
provides: "Read-only dashboard for !settings"
key_links:
- from: "adapter/matrix/bot.py"
to: "adapter/matrix/store.py"
via: "set_pending_confirm on OutgoingUI send"
pattern: "set_pending_confirm"
- from: "adapter/matrix/handlers/confirm.py"
to: "adapter/matrix/store.py"
via: "get_pending_confirm / clear_pending_confirm"
pattern: "get_pending_confirm"
---
<objective>
Remove all reaction-based UX from the Matrix adapter and replace with text-based !yes/!no confirmation. Update settings dashboard to read-only format.
Purpose: Per D-06/D-07/D-08, reactions are removed entirely. OutgoingUI renders as plain text with !yes/!no hint. Per D-12, !settings becomes a read-only dashboard.
Output: Clean bot.py without reactions, working !yes/!no confirmation flow, updated text builders, read-only settings dashboard.
</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/phases/01-matrix-qa-polish/01-CONTEXT.md
@.planning/phases/01-matrix-qa-polish/01-RESEARCH.md
@adapter/matrix/bot.py
@adapter/matrix/reactions.py
@adapter/matrix/handlers/confirm.py
@adapter/matrix/handlers/settings.py
@adapter/matrix/store.py
@adapter/matrix/converter.py
@core/protocol.py
<interfaces>
<!-- From adapter/matrix/store.py (after Plan 01 adds pending_confirm helpers): -->
```python
PENDING_CONFIRM_PREFIX = "matrix_pending_confirm:"
async def get_pending_confirm(store: StateStore, room_id: str) -> dict | None
async def set_pending_confirm(store: StateStore, room_id: str, meta: dict) -> None
async def clear_pending_confirm(store: StateStore, room_id: str) -> None
```
<!-- From core/protocol.py — OutgoingUI and UIButton: -->
```python
@dataclass
class UIButton:
label: str
action: str
payload: dict = field(default_factory=dict)
style: str = "secondary"
@dataclass
class OutgoingUI:
chat_id: str
text: str
buttons: list[UIButton] = field(default_factory=list)
@dataclass
class IncomingCallback:
user_id: str
platform: str
chat_id: str
action: str
payload: dict = field(default_factory=dict)
```
<!-- From adapter/matrix/converter.py — how !yes/!no become IncomingCallback: -->
```python
# In from_command():
if command in {"yes", "no"}:
action = "confirm" if command == "yes" else "cancel"
return IncomingCallback(
user_id=sender,
platform=PLATFORM,
chat_id=chat_id,
action=action,
payload={"source": "command", "command": command},
)
```
<!-- From adapter/matrix/handlers/__init__.py — confirm/cancel registration: -->
```python
dispatcher.register(IncomingCallback, "confirm", handle_confirm)
dispatcher.register(IncomingCallback, "cancel", handle_cancel)
```
<!-- From sdk.interface.UserSettings — used by settings dashboard: -->
```python
@dataclass
class UserSettings:
skills: dict
connectors: dict
soul: dict
safety: dict
plan: dict
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Remove reactions from bot.py, update send_outgoing for !yes/!no (per D-06, D-07)</name>
<files>adapter/matrix/bot.py</files>
<read_first>adapter/matrix/bot.py, adapter/matrix/store.py, core/protocol.py</read_first>
<action>
Modify `adapter/matrix/bot.py` with these specific changes:
**1. Remove ReactionEvent import (line 14):**
Change the nio import block from:
```python
from nio import (
AsyncClient,
AsyncClientConfig,
InviteMemberEvent,
MatrixRoom,
ReactionEvent,
RoomMemberEvent,
RoomMessageText,
)
```
to:
```python
from nio import (
AsyncClient,
AsyncClientConfig,
InviteMemberEvent,
MatrixRoom,
RoomMemberEvent,
RoomMessageText,
)
```
**2. Remove `from_reaction` import (line 20):**
Change:
```python
from adapter.matrix.converter import from_reaction, from_room_event
```
to:
```python
from adapter.matrix.converter import from_room_event
```
**3. Add store import for pending_confirm:**
Add this import:
```python
from adapter.matrix.store import set_pending_confirm
```
**4. Delete the entire `on_reaction` method from MatrixBot class (lines 106-114).**
**5. Delete the entire `_button_action_to_reaction` function (lines 135-140).**
**6. Rewrite the OutgoingUI block in `send_outgoing` function.**
Replace the existing `if isinstance(event, OutgoingUI):` block (lines 154-180) with:
```python
if isinstance(event, OutgoingUI):
lines = [event.text]
if event.buttons:
lines.append("")
for btn in event.buttons:
lines.append(f" {btn.label}")
lines.append("")
lines.append("Ответьте !yes для подтверждения или !no для отмены.")
body = "\n".join(lines)
await client.room_send(room_id, "m.room.message", {"msgtype": "m.text", "body": body})
# Store pending confirmation for !yes/!no handler
if event.buttons:
action_id = event.buttons[0].action if event.buttons else "unknown"
payload = event.buttons[0].payload if event.buttons else {}
await set_pending_confirm(store, room_id, {
"action_id": action_id,
"description": event.text,
"payload": payload,
})
return
```
**PROBLEM:** `send_outgoing` is a module-level function with signature `async def send_outgoing(client, room_id, event)`. It doesn't receive `store`. We need to pass `store` to it.
**Solution:** Change `send_outgoing` signature to include `store`:
```python
async def send_outgoing(client: AsyncClient, room_id: str, event: OutgoingEvent, store: StateStore | None = None) -> None:
```
And update `MatrixBot._send_all` to pass store:
```python
async def _send_all(self, room_id: str, outgoing: list[OutgoingEvent]) -> None:
for event in outgoing:
await send_outgoing(self.client, room_id, event, store=self.runtime.store)
```
**7. In `main()`, remove the on_reaction callback registration.**
Delete this line:
```python
client.add_event_callback(bot.on_reaction, ReactionEvent)
```
**8. Add StateStore import at top:**
```python
from core.store import InMemoryStore, SQLiteStore, StateStore
```
(StateStore is already imported on line 37 — verify it's there.)
The `set_pending_confirm` call in the OutgoingUI handler should guard against store being None:
```python
if event.buttons and store is not None:
action_id = event.buttons[0].action
payload = event.buttons[0].payload
await set_pending_confirm(store, room_id, {
"action_id": action_id,
"description": event.text,
"payload": payload,
})
```
</action>
<verify>
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.bot import send_outgoing, MatrixBot, build_runtime; print('OK')" && python -c "import ast; tree = ast.parse(open('adapter/matrix/bot.py').read()); names = [n.name for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]; assert '_button_action_to_reaction' not in names, 'reaction helper still exists'; assert 'on_reaction' not in names, 'on_reaction still exists'; print('REACTION CODE REMOVED')"</automated>
</verify>
<acceptance_criteria>
- `adapter/matrix/bot.py` does NOT contain the string `_button_action_to_reaction`
- `adapter/matrix/bot.py` does NOT contain the string `on_reaction`
- `adapter/matrix/bot.py` does NOT contain `ReactionEvent`
- `adapter/matrix/bot.py` does NOT contain `from_reaction`
- `adapter/matrix/bot.py` does NOT contain `m.reaction`
- `adapter/matrix/bot.py` contains `Ответьте !yes для подтверждения или !no для отмены.`
- `adapter/matrix/bot.py` contains `set_pending_confirm`
- `send_outgoing` function signature includes `store` parameter
</acceptance_criteria>
<done>bot.py has no reaction code; OutgoingUI renders text + !yes/!no; pending_confirm stored on OutgoingUI send</done>
</task>
<task type="auto">
<name>Task 2: Update reactions.py text builders + confirm.py handlers + settings.py dashboard (per D-06, D-07, D-08, D-12)</name>
<files>adapter/matrix/reactions.py, adapter/matrix/handlers/confirm.py, adapter/matrix/handlers/settings.py</files>
<read_first>adapter/matrix/reactions.py, adapter/matrix/handlers/confirm.py, adapter/matrix/handlers/settings.py, adapter/matrix/store.py</read_first>
<action>
**Part A: Update adapter/matrix/reactions.py**
1. Update `build_skills_text` — replace the last line "Реакции 1-9 переключают навыки." with instruction for text commands:
Replace:
```python
lines.append("Реакции 1⃣-9⃣ переключают навыки.")
```
With:
```python
lines.append("!skill on/off <название> — переключить навык.")
```
2. Update `build_confirmation_text` — remove reaction emojis, use only !yes/!no:
Replace the entire function with:
```python
def build_confirmation_text(description: str) -> str:
return "\n".join(
[
"Lambda",
description,
"",
"Ответьте !yes для подтверждения или !no для отмены.",
]
)
```
3. Remove `add_reaction` and `remove_reaction` functions entirely (they send m.reaction events which are no longer used).
4. Keep `CONFIRM_REACTION`, `CANCEL_REACTION`, `SKILL_REACTIONS`, `REACTION_TO_INDEX`, `reaction_to_skill_index` — they are still imported by `converter.py` for `from_reaction`. Even though `from_reaction` is no longer called from bot.py, converter.py still exports it and removing would break imports. Keep for backwards compat.
Actually, check: `from_reaction` is imported in `converter.py` definition, not as an external import. And `bot.py` no longer imports `from_reaction`. But `converter.py` imports `CANCEL_REACTION, CONFIRM_REACTION, reaction_to_skill_index` from `reactions.py`. So those constants MUST stay.
Keep: `CONFIRM_REACTION`, `CANCEL_REACTION`, `SKILL_REACTIONS`, `REACTION_TO_INDEX`, `reaction_to_skill_index`, `build_skills_text`, `build_confirmation_text`.
Remove: `add_reaction`, `remove_reaction`.
Remove the `AsyncClient` import since add_reaction/remove_reaction used it and nothing else does.
Updated file should look like:
```python
from __future__ import annotations
from sdk.interface import UserSettings
CONFIRM_REACTION = "👍"
CANCEL_REACTION = "❌"
SKILL_REACTIONS = ["1⃣", "2⃣", "3⃣", "4⃣", "5⃣", "6⃣", "7⃣", "8⃣", "9⃣"]
REACTION_TO_INDEX = {emoji: idx + 1 for idx, emoji in enumerate(SKILL_REACTIONS)}
def build_skills_text(settings: UserSettings) -> str:
lines: list[str] = ["Скиллы"]
for idx, (name, enabled) in enumerate(settings.skills.items(), start=1):
state = "on" if enabled else "off"
emoji = SKILL_REACTIONS[idx - 1] if idx - 1 < len(SKILL_REACTIONS) else f"{idx}."
lines.append(f" {state} {emoji} {name}")
lines.append("")
lines.append("!skill on/off <название> — переключить навык.")
return "\n".join(lines)
def build_confirmation_text(description: str) -> str:
return "\n".join(
[
"Lambda",
description,
"",
"Ответьте !yes для подтверждения или !no для отмены.",
]
)
def reaction_to_skill_index(key: str) -> int | None:
return REACTION_TO_INDEX.get(key)
```
**Part B: Update adapter/matrix/handlers/confirm.py**
Rewrite to read pending_confirm from store. The handlers receive the standard signature `(event, auth_mgr, platform, chat_mgr, settings_mgr)` but need access to `store`. Since they're registered in `__init__.py` as plain functions (not closures), convert them to closure factories.
Replace entire file:
```python
from __future__ import annotations
from adapter.matrix.store import get_pending_confirm, clear_pending_confirm
from core.protocol import IncomingCallback, OutgoingMessage
def make_handle_confirm(store=None):
async def handle_confirm(
event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
if store is None:
return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")]
pending = await get_pending_confirm(store, event.chat_id)
if not pending:
return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")]
description = pending.get("description", "действие")
action_id = pending.get("action_id", "unknown")
await clear_pending_confirm(store, event.chat_id)
return [
OutgoingMessage(
chat_id=event.chat_id,
text=f"Подтверждено: {description}",
)
]
return handle_confirm
def make_handle_cancel(store=None):
async def handle_cancel(
event: IncomingCallback, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
if store is None:
return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")]
pending = await get_pending_confirm(store, event.chat_id)
if not pending:
return [OutgoingMessage(chat_id=event.chat_id, text="Нет ожидающих подтверждений.")]
await clear_pending_confirm(store, event.chat_id)
return [
OutgoingMessage(
chat_id=event.chat_id,
text="Действие отменено.",
)
]
return handle_cancel
```
**Part C: Update adapter/matrix/handlers/__init__.py for new confirm imports**
Change confirm imports from:
```python
from adapter.matrix.handlers.confirm import handle_cancel, handle_confirm
```
to:
```python
from adapter.matrix.handlers.confirm import make_handle_cancel, make_handle_confirm
```
Change registrations from:
```python
dispatcher.register(IncomingCallback, "confirm", handle_confirm)
dispatcher.register(IncomingCallback, "cancel", handle_cancel)
```
to:
```python
dispatcher.register(IncomingCallback, "confirm", make_handle_confirm(store))
dispatcher.register(IncomingCallback, "cancel", make_handle_cancel(store))
```
**Part D: Update adapter/matrix/handlers/settings.py — handle_settings becomes read-only dashboard (per D-12)**
Replace the `handle_settings` function body. Keep ALL other functions unchanged.
```python
async def handle_settings(
event: IncomingCommand, auth_mgr, platform, chat_mgr, settings_mgr
) -> list:
settings = await settings_mgr.get(event.user_id)
chats = await chat_mgr.list_active(event.user_id)
# Skills section
skills_lines = []
for name, enabled in settings.skills.items():
state = "on" if enabled else "off"
skills_lines.append(f" {state} {name}")
skills_text = "\n".join(skills_lines) if skills_lines else " нет навыков"
# Soul section
soul_lines = []
for key, value in (settings.soul or {}).items():
soul_lines.append(f" {key}: {value}")
soul_text = "\n".join(soul_lines) if soul_lines else " по умолчанию"
# Safety section
safety_lines = []
for key, value in (settings.safety or {}).items():
state = "on" if value else "off"
safety_lines.append(f" {state} {key}")
safety_text = "\n".join(safety_lines) if safety_lines else " по умолчанию"
# Chats section
chat_lines = [f" {c.display_name} ({c.chat_id})" for c in chats]
chats_text = "\n".join(chat_lines) if chat_lines else " нет активных чатов"
dashboard = "\n".join([
"Настройки",
"",
"Скиллы:",
skills_text,
"",
"Личность:",
soul_text,
"",
"Безопасность:",
safety_text,
"",
f"Активные чаты ({len(chats)}):",
chats_text,
"",
"Изменить: !skills, !soul, !safety",
])
return [OutgoingMessage(chat_id=event.chat_id, text=dashboard)]
```
</action>
<verify>
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && python -c "from adapter.matrix.reactions import build_skills_text, build_confirmation_text; from adapter.matrix.handlers.confirm import make_handle_confirm, make_handle_cancel; from adapter.matrix.handlers.settings import handle_settings; print('OK')" && python -c "from adapter.matrix.handlers import register_matrix_handlers; print('OK')"</automated>
</verify>
<acceptance_criteria>
- `adapter/matrix/reactions.py` does NOT contain `add_reaction`
- `adapter/matrix/reactions.py` does NOT contain `remove_reaction`
- `adapter/matrix/reactions.py` does NOT contain the string `Реакции 1`
- `adapter/matrix/reactions.py` contains `!skill on/off`
- `adapter/matrix/reactions.py` contains `!yes` in build_confirmation_text
- `adapter/matrix/handlers/confirm.py` contains `get_pending_confirm`
- `adapter/matrix/handlers/confirm.py` contains `clear_pending_confirm`
- `adapter/matrix/handlers/confirm.py` contains `def make_handle_confirm(`
- `adapter/matrix/handlers/confirm.py` contains `def make_handle_cancel(`
- `adapter/matrix/handlers/__init__.py` contains `make_handle_confirm(store)`
- `adapter/matrix/handlers/__init__.py` contains `make_handle_cancel(store)`
- `adapter/matrix/handlers/settings.py` `handle_settings` function contains the string `Настройки` and `Скиллы:` and `Изменить:`
- `adapter/matrix/handlers/settings.py` `handle_settings` does NOT contain `!connectors` or `!plan` or `!status` or `!whoami`
</acceptance_criteria>
<done>Reactions removed from text builders; !yes/!no handlers read pending_confirm; !settings is read-only dashboard</done>
</task>
</tasks>
<verification>
After both tasks:
- `python -c "from adapter.matrix.bot import send_outgoing, MatrixBot; from adapter.matrix.reactions import build_skills_text; from adapter.matrix.handlers.confirm import make_handle_confirm; from adapter.matrix.handlers import register_matrix_handlers; print('ALL OK')"`
- No string `m.reaction` in `adapter/matrix/bot.py`
- No string `_button_action_to_reaction` in `adapter/matrix/bot.py`
- No string `Реакции 1` in `adapter/matrix/reactions.py`
</verification>
<success_criteria>
- bot.py: no reaction code, OutgoingUI renders text + !yes/!no, stores pending_confirm
- reactions.py: build_skills_text says "!skill on/off", build_confirmation_text says "!yes/!no"
- confirm.py: !yes reads pending_confirm and confirms, !no clears and cancels
- settings.py: !settings returns read-only dashboard
- All imports resolve
</success_criteria>
<output>
After completion, create `.planning/phases/01-matrix-qa-polish/01-03-SUMMARY.md`
</output>