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:
2026-04-26 18:14:24 +00:00
parent 094c6be5a9
commit 86384b38e3
8 changed files with 821 additions and 11 deletions

View File

@@ -0,0 +1,272 @@
"""Discord interactive Views for Ralph — root list + per-project actions + propose modal.
Critical pattern: every button callback that does I/O MUST call
`await interaction.response.defer(ephemeral=True)` FIRST, then use
`interaction.followup.send(...)`. Otherwise Discord's 3s interaction
timeout kicks in and the user sees "Interaction failed".
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
import discord
from src.router import (
_load_approved_tasks,
_ralph_approve,
_ralph_propose,
_ralph_status,
_ralph_stop,
)
log = logging.getLogger(__name__)
WORKSPACE_DIR = Path("/home/moltbot/workspace")
# Status → emoji prefix for project labels
_STATUS_EMOJI = {
"pending": "📋",
"approved": "",
"running": "🟢",
"complete": "",
"failed": "",
"stopped": "",
}
VIEW_TIMEOUT = 600 # 10 min — matches ralph_flow TTL
def _project_label(name: str, status: str | None) -> str:
"""Return a short button label like '🟢 roa2web' (max 80 chars per Discord)."""
emoji = _STATUS_EMOJI.get(status or "", "·")
label = f"{emoji} {name}"
return label[:80]
def _list_workspace_projects() -> list[str]:
"""Return workspace folder names sorted, skipping hidden dirs."""
if not WORKSPACE_DIR.exists():
return []
return sorted(
p.name for p in WORKSPACE_DIR.iterdir()
if p.is_dir() and not p.name.startswith(".")
)
def _project_status(name: str) -> str | None:
"""Look up status of a project from approved-tasks.json (or None)."""
try:
data = _load_approved_tasks()
except Exception:
return None
for p in data.get("projects", []):
if p.get("name", "").lower() == name.lower():
return p.get("status")
return None
def _read_prd(name: str) -> dict | None:
prd_path = WORKSPACE_DIR / name / "scripts" / "ralph" / "prd.json"
if not prd_path.exists():
return None
try:
return json.loads(prd_path.read_text(encoding="utf-8"))
except (ValueError, OSError):
return None
# ---------------------------------------------------------------------------
# RalphProposeModal — text input for new feature description
# ---------------------------------------------------------------------------
class RalphProposeModal(discord.ui.Modal, title="Propune feature Ralph"):
"""Modal asking the user for a feature description for a given project."""
description: discord.ui.TextInput = discord.ui.TextInput(
label="Descriere",
placeholder="Ce trebuie făcut? (1-3 propoziții)",
style=discord.TextStyle.paragraph,
required=True,
max_length=1000,
)
def __init__(self, slug: str) -> None:
super().__init__(timeout=VIEW_TIMEOUT)
self.slug = slug
self.title = f"Propune feature: {slug}"[:45]
async def on_submit(self, interaction: discord.Interaction) -> None:
try:
result = _ralph_propose(self.slug, str(self.description.value).strip())
except Exception as e:
log.exception("Ralph propose modal failed for %s", self.slug)
await interaction.response.send_message(
f"Eroare la propunere: {e}", ephemeral=True
)
return
await interaction.response.send_message(result, ephemeral=True)
# ---------------------------------------------------------------------------
# RalphProjectView — per-project action buttons
# ---------------------------------------------------------------------------
class RalphProjectView(discord.ui.View):
"""Buttons: Propune feature / Vezi PRD / Start acum / Status / Stop / Înapoi."""
def __init__(self, slug: str) -> None:
super().__init__(timeout=VIEW_TIMEOUT)
self.slug = slug
@discord.ui.button(label=" Propune feature", style=discord.ButtonStyle.primary, row=0)
async def propose(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.send_modal(RalphProposeModal(self.slug))
@discord.ui.button(label="👁 Vezi PRD", style=discord.ButtonStyle.secondary, row=0)
async def view_prd(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
prd = _read_prd(self.slug)
if prd is None:
await interaction.followup.send(
f"Nu există PRD pentru {self.slug}. Aprobă cu /a {self.slug} și night-execute îl generează.",
ephemeral=True,
)
return
stories = prd.get("userStories", [])
done = sum(1 for s in stories if s.get("passes"))
total = len(stories)
lines = [f"**PRD pentru {self.slug}** — {done}/{total} stories"]
for s in stories[:12]:
mark = "" if s.get("passes") else ""
sid = s.get("id", "?")
title = (s.get("title") or "")[:80]
lines.append(f"{mark} `{sid}` {title}")
if total > 12:
lines.append(f"\n…și încă {total - 12} stories.")
await interaction.followup.send("\n".join(lines), ephemeral=True)
@discord.ui.button(label="📊 Status", style=discord.ButtonStyle.secondary, row=0)
async def status(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
text = _ralph_status(self.slug)
await interaction.followup.send(text, ephemeral=True)
@discord.ui.button(label="✅ Aprobă pentru tonight", style=discord.ButtonStyle.success, row=1)
async def approve(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
text = _ralph_approve([self.slug])
await interaction.followup.send(text, ephemeral=True)
@discord.ui.button(label="🛑 Stop", style=discord.ButtonStyle.danger, row=1)
async def stop(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
text = _ralph_stop(self.slug)
await interaction.followup.send(text, ephemeral=True)
@discord.ui.button(label="🔙 Înapoi", style=discord.ButtonStyle.secondary, row=1)
async def back(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
await interaction.response.defer(ephemeral=True)
view = RalphRootView()
content = view.render_summary()
await interaction.edit_original_response(content=content, view=view)
# ---------------------------------------------------------------------------
# RalphRootView — workspace + active projects landing
# ---------------------------------------------------------------------------
class _ProjectButton(discord.ui.Button):
"""Dynamic button representing a single project; opens RalphProjectView."""
def __init__(self, slug: str, status: str | None, row: int) -> None:
super().__init__(
label=_project_label(slug, status),
style=discord.ButtonStyle.secondary,
row=row,
custom_id=f"ralph_project:{slug}",
)
self.slug = slug
async def callback(self, interaction: discord.Interaction) -> None:
await interaction.response.defer(ephemeral=True)
view = RalphProjectView(self.slug)
content = f"**{self.slug}**\nAlege o acțiune:"
await interaction.edit_original_response(content=content, view=view)
class _RefreshButton(discord.ui.Button):
def __init__(self) -> None:
super().__init__(
label="🔄 Reîncarcă",
style=discord.ButtonStyle.primary,
row=4,
custom_id="ralph_refresh",
)
async def callback(self, interaction: discord.Interaction) -> None:
await interaction.response.defer(ephemeral=True)
view = RalphRootView()
await interaction.edit_original_response(
content=view.render_summary(), view=view
)
class _CloseButton(discord.ui.Button):
def __init__(self) -> None:
super().__init__(
label="❌ Închide",
style=discord.ButtonStyle.danger,
row=4,
custom_id="ralph_close",
)
async def callback(self, interaction: discord.Interaction) -> None:
await interaction.response.defer(ephemeral=True)
await interaction.edit_original_response(content="Închis.", view=None)
class RalphRootView(discord.ui.View):
"""Landing view: workspace projects with status emoji + refresh + close."""
def __init__(self) -> None:
super().__init__(timeout=VIEW_TIMEOUT)
self._build_buttons()
def _build_buttons(self) -> None:
projects = _list_workspace_projects()
# Discord limit: 5 buttons per row × 5 rows. Reserve last row for refresh/close.
# Project buttons go on rows 0..3 (max 20 projects).
for idx, slug in enumerate(projects[:20]):
row = idx // 5
status = _project_status(slug)
self.add_item(_ProjectButton(slug, status, row=row))
self.add_item(_RefreshButton())
self.add_item(_CloseButton())
def render_summary(self) -> str:
"""Build the message text shown above the buttons."""
try:
data = _load_approved_tasks()
except Exception:
data = {"projects": []}
active = [
p for p in data.get("projects", [])
if p.get("status") in ("pending", "approved", "running")
]
lines = ["📋 **Proiecte Ralph**"]
if active:
lines.append("")
lines.append("**Active:**")
for p in active[:10]:
emoji = _STATUS_EMOJI.get(p.get("status", ""), "·")
desc = (p.get("description") or "")[:60]
lines.append(f"{emoji} `{p.get('name')}` — {desc}")
lines.append("")
lines.append("Apasă pe un proiect pentru acțiuni, sau Reîncarcă pentru status fresh.")
return "\n".join(lines)