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

@@ -1,9 +1,17 @@
"""Telegram bot adapter — commands and message handlers."""
import asyncio
import json
import logging
from pathlib import Path
from telegram import BotCommand, Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram import (
BotCommand,
ForceReply,
InlineKeyboardButton,
InlineKeyboardMarkup,
Update,
)
from telegram.constants import ChatAction, ChatType
from telegram.ext import (
Application,
@@ -22,14 +30,28 @@ from src.claude_session import (
VALID_MODELS,
)
from src.fast_commands import dispatch as fast_dispatch
from src import ralph_flow
from src.router import (
route_message,
_load_approved_tasks,
_ralph_propose,
_ralph_approve,
_ralph_status,
_ralph_stop,
)
WORKSPACE_DIR = Path("/home/moltbot/workspace")
ADAPTER_NAME = "telegram"
_RALPH_STATUS_EMOJI = {
"pending": "📋",
"approved": "",
"running": "🟢",
"complete": "",
"failed": "",
"stopped": "",
}
logger = logging.getLogger("echo-core.telegram")
_security_log = logging.getLogger("echo-core.security")
@@ -339,12 +361,101 @@ async def cmd_ralph_a(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
await update.message.reply_text(result)
def _list_workspace_projects() -> list[str]:
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_map() -> dict[str, str]:
"""Return {slug: status} from approved-tasks.json."""
try:
data = _load_approved_tasks()
except Exception:
return {}
return {p.get("name", ""): p.get("status", "") for p in data.get("projects", [])}
def _build_ralph_root_keyboard() -> InlineKeyboardMarkup:
"""Build the /l landing keyboard: project rows + refresh/close."""
statuses = _project_status_map()
rows: list[list[InlineKeyboardButton]] = []
current_row: list[InlineKeyboardButton] = []
for slug in _list_workspace_projects():
emoji = _RALPH_STATUS_EMOJI.get(statuses.get(slug, ""), "·")
current_row.append(
InlineKeyboardButton(
f"{emoji} {slug}",
callback_data=f"ralph:project:{slug}",
)
)
if len(current_row) == 2:
rows.append(current_row)
current_row = []
if current_row:
rows.append(current_row)
rows.append([
InlineKeyboardButton("🔄 Reîncarcă", callback_data="ralph:refresh"),
InlineKeyboardButton("❌ Închide", callback_data="ralph:close"),
])
return InlineKeyboardMarkup(rows)
def _build_ralph_project_keyboard(slug: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([
[
InlineKeyboardButton(" Propune feature", callback_data=f"ralph:propose:{slug}"),
InlineKeyboardButton("👁 Vezi PRD", callback_data=f"ralph:prd:{slug}"),
],
[
InlineKeyboardButton("📊 Status", callback_data=f"ralph:status:{slug}"),
InlineKeyboardButton("✅ Aprobă tonight", callback_data=f"ralph:approve:{slug}"),
],
[
InlineKeyboardButton("🛑 Stop", callback_data=f"ralph:stop:{slug}"),
InlineKeyboardButton("🔙 Înapoi", callback_data="ralph:menu"),
],
])
def _render_ralph_root_summary() -> str:
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 = _RALPH_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.")
return "\n".join(lines)
async def cmd_ralph_l(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""/l — status proiecte Ralph."""
"""/l — listă interactivă proiecte Ralph."""
args = list(context.args or [])
filter_slug = args[0].lower() if args else None
result = await asyncio.to_thread(_ralph_status, filter_slug)
await update.message.reply_text(result)
if args:
filter_slug = args[0].lower()
result = await asyncio.to_thread(_ralph_status, filter_slug)
await update.message.reply_text(result)
return
await update.message.reply_text(
_render_ralph_root_summary(),
reply_markup=_build_ralph_root_keyboard(),
parse_mode="Markdown",
)
async def cmd_ralph_k(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -357,6 +468,115 @@ async def cmd_ralph_k(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
await update.message.reply_text(result)
async def callback_ralph(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle inline keyboard callbacks for Ralph (pattern ^ralph:)."""
query = update.callback_query
await query.answer()
data = query.data or ""
parts = data.split(":", 2)
if len(parts) < 2 or parts[0] != "ralph":
return
action = parts[1]
slug = parts[2] if len(parts) > 2 else None
chat_id = str(query.message.chat_id)
user_id = str(query.from_user.id) if query.from_user else "0"
if action == "menu" or action == "refresh":
try:
await query.edit_message_text(
_render_ralph_root_summary(),
reply_markup=_build_ralph_root_keyboard(),
parse_mode="Markdown",
)
except Exception:
logger.exception("Failed to refresh ralph menu")
return
if action == "close":
try:
await query.edit_message_text("Închis.", reply_markup=None)
except Exception:
logger.exception("Failed to close ralph menu")
return
if not slug:
return
if action == "project":
try:
await query.edit_message_text(
f"*{slug}*\nAlege o acțiune:",
reply_markup=_build_ralph_project_keyboard(slug),
parse_mode="Markdown",
)
except Exception:
logger.exception("Failed to open ralph project menu")
return
if action == "propose":
# Set state then prompt with ForceReply for description
ralph_flow.set_state(
ADAPTER_NAME, chat_id, user_id,
step=ralph_flow.STEP_INPUT_DESCRIPTION,
project=slug,
)
await context.bot.send_message(
chat_id=int(chat_id),
text=f"📝 Descriere pentru *{slug}* (1-3 propoziții):",
reply_markup=ForceReply(selective=True),
parse_mode="Markdown",
)
return
if action == "prd":
prd_path = WORKSPACE_DIR / slug / "scripts" / "ralph" / "prd.json"
if not prd_path.exists():
await context.bot.send_message(
chat_id=int(chat_id),
text=f"Nu există PRD pentru `{slug}`. Aprobă-l și night-execute îl generează.",
parse_mode="Markdown",
)
return
try:
prd = json.loads(prd_path.read_text(encoding="utf-8"))
except (ValueError, OSError) as e:
await context.bot.send_message(chat_id=int(chat_id), text=f"PRD corupt: {e}")
return
stories = prd.get("userStories", [])
done = sum(1 for s in stories if s.get("passes"))
lines = [f"*PRD pentru {slug}* — {done}/{len(stories)} 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 len(stories) > 12:
lines.append(f"\n…și încă {len(stories) - 12} stories.")
await context.bot.send_message(
chat_id=int(chat_id),
text="\n".join(lines),
parse_mode="Markdown",
)
return
if action == "status":
result = await asyncio.to_thread(_ralph_status, slug)
await context.bot.send_message(chat_id=int(chat_id), text=result)
return
if action == "approve":
result = await asyncio.to_thread(_ralph_approve, [slug])
await context.bot.send_message(chat_id=int(chat_id), text=result)
return
if action == "stop":
result = await asyncio.to_thread(_ralph_stop, slug)
await context.bot.send_message(chat_id=int(chat_id), text=result)
return
# --- Fast command handlers ---
@@ -535,6 +755,17 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
chat_id, text[:100],
)
# Ralph multi-step state: if user is replying to a "Descriere pentru X" prompt,
# route this text to _ralph_propose instead of Claude.
state = ralph_flow.get_state(ADAPTER_NAME, str(chat_id), str(user_id))
if state and state.get("step") == ralph_flow.STEP_INPUT_DESCRIPTION:
slug = state.get("project")
if slug:
ralph_flow.clear_state(ADAPTER_NAME, str(chat_id), str(user_id))
result = await asyncio.to_thread(_ralph_propose, slug, text)
await message.reply_text(result)
return
# Show typing indicator
await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
@@ -556,6 +787,7 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
response, _is_cmd = await asyncio.to_thread(
route_message, str(chat_id), str(user_id), text,
on_text=on_text,
adapter_name=ADAPTER_NAME,
)
# Only send combined response if no intermediates were delivered
@@ -586,6 +818,7 @@ def create_telegram_bot(config: Config, token: str) -> Application:
app.add_handler(CommandHandler("model", cmd_model))
app.add_handler(CommandHandler("register", cmd_register))
app.add_handler(CallbackQueryHandler(callback_model, pattern="^model:"))
app.add_handler(CallbackQueryHandler(callback_ralph, pattern="^ralph:"))
# Ralph commands
app.add_handler(CommandHandler("p", cmd_ralph_p))