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:
272
src/adapters/discord_views.py
Normal file
272
src/adapters/discord_views.py
Normal 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)
|
||||
Reference in New Issue
Block a user