feat(ralph): interactive UX layer pe Discord și Telegram (W1)
Adaugă straturile interactive peste slash commands flat: **Discord (`src/adapters/discord_views.py`):** - `RalphRootView` — listă proiecte workspace cu emoji status + Refresh + Close - `RalphProjectView` — Propose / Vezi PRD / Aprobă tonight / Status / Stop / Înapoi - `RalphProposeModal` — TextInput pentru descriere feature - Critical pattern: `await interaction.response.defer(ephemeral=True)` în orice button callback cu I/O (eng review concern #2 — "Discord 3s timeout") - `/p slug` autocomplete din `~/workspace/` - `/l` afișează `RalphRootView` ephemeral **Telegram (`src/adapters/telegram_bot.py`):** - `cmd_ralph_l` (fără arg) trimite `InlineKeyboardMarkup` cu workspace + active - `callback_ralph` cu pattern `^ralph:` rutează: project, menu, refresh, close, propose, prd, status, approve, stop - Pentru "Propose feature" → set ralph_flow state cu step=input_description + `ForceReply()`; `handle_message` detectează state și rutează la `_ralph_propose` - Pasează `adapter_name="telegram"` la `route_message` **State management (`src/ralph_flow.py`):** - Atomic JSON peste `sessions/ralph_flow.json` (pattern reusat din claude_session) - Schema per (adapter, chat, user): `{step, project?, expires_at, ...}` - TTL 10 min default; `cleanup_expired()` și auto-drop la `get_state` pe expirate **Router (`src/router.py`):** - `route_message` primește `adapter_name` keyword arg - `_maybe_whatsapp_redirect` adaugă "💡 Pentru meniu interactiv folosește Discord sau Telegram" la mesajele de usage când adapter_name="whatsapp" - WhatsApp `_handle_chat` pasează `adapter_name="whatsapp"` **Tests:** - `test_ralph_flow.py` — 10 teste (round-trip, isolation, expiry, atomic write) - `test_router.py::TestRalphDispatch` — 3 teste (whatsapp redirect, discord no-redirect, usage message) Foundation pentru W2 (planning agent — STEP_IN_PLANNING reservat). Spike Step 0 PASS: skill subprocess + AskUserQuestion→text serialization confirmat empiric (vezi tasks/spike-planning-findings.md). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
128
src/ralph_flow.py
Normal file
128
src/ralph_flow.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Ralph UX flow state — short-lived per (adapter, chat, user) state for interactive menus.
|
||||
|
||||
Tracks state for multi-step flows like "user clicked Propose → next message is description".
|
||||
Persisted in sessions/ralph_flow.json so it survives Echo Core restart.
|
||||
TTL: 10 min default; cleanup_expired() drops stale entries.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
SESSIONS_DIR = PROJECT_ROOT / "sessions"
|
||||
_STATE_FILE = SESSIONS_DIR / "ralph_flow.json"
|
||||
|
||||
DEFAULT_TTL_SECONDS = 600 # 10 minutes
|
||||
|
||||
# Step values used across adapters
|
||||
STEP_INPUT_DESCRIPTION = "input_description"
|
||||
STEP_IN_PLANNING = "in_planning" # reserved for W2 (planning agent)
|
||||
|
||||
|
||||
def _key(adapter: str, chat_id: str, user_id: str) -> str:
|
||||
return f"{adapter}:{chat_id}:{user_id}"
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
try:
|
||||
text = _STATE_FILE.read_text(encoding="utf-8")
|
||||
if not text.strip():
|
||||
return {}
|
||||
return json.loads(text)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=SESSIONS_DIR, prefix=".ralph_flow_", suffix=".json"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
os.replace(tmp_path, _STATE_FILE)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _is_expired(entry: dict, now: datetime | None = None) -> bool:
|
||||
expires_at = entry.get("expires_at")
|
||||
if not expires_at:
|
||||
return False
|
||||
try:
|
||||
return datetime.fromisoformat(expires_at) < (now or datetime.now(timezone.utc))
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
|
||||
def get_state(adapter: str, chat_id: str, user_id: str) -> dict | None:
|
||||
"""Return current state or None if absent/expired. Drops expired entries on read."""
|
||||
data = _load()
|
||||
key = _key(adapter, chat_id, user_id)
|
||||
entry = data.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if _is_expired(entry):
|
||||
del data[key]
|
||||
_save(data)
|
||||
return None
|
||||
return entry
|
||||
|
||||
|
||||
def set_state(
|
||||
adapter: str,
|
||||
chat_id: str,
|
||||
user_id: str,
|
||||
step: str,
|
||||
project: str | None = None,
|
||||
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
||||
**extras,
|
||||
) -> None:
|
||||
"""Set state for (adapter, chat, user). Overwrites any previous state."""
|
||||
data = _load()
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
|
||||
).isoformat()
|
||||
entry: dict = {"step": step, "expires_at": expires_at}
|
||||
if project is not None:
|
||||
entry["project"] = project
|
||||
entry.update(extras)
|
||||
data[_key(adapter, chat_id, user_id)] = entry
|
||||
_save(data)
|
||||
|
||||
|
||||
def clear_state(adapter: str, chat_id: str, user_id: str) -> bool:
|
||||
"""Clear state. Returns True if anything was cleared."""
|
||||
data = _load()
|
||||
key = _key(adapter, chat_id, user_id)
|
||||
if key in data:
|
||||
del data[key]
|
||||
_save(data)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_expired() -> int:
|
||||
"""Drop all expired entries. Returns count dropped."""
|
||||
data = _load()
|
||||
now = datetime.now(timezone.utc)
|
||||
dropped = 0
|
||||
for k in list(data.keys()):
|
||||
if _is_expired(data[k], now=now):
|
||||
del data[k]
|
||||
dropped += 1
|
||||
if dropped:
|
||||
_save(data)
|
||||
return dropped
|
||||
Reference in New Issue
Block a user