Merge branch 'ralph/ralph-ux-conv' — W1 interactive UX
Bring in interactive layer for Ralph commands: Discord Views/Modal, Telegram InlineKeyboardMarkup + callback_ralph multi-step, ralph_flow state management, WhatsApp text-only fallback with redirect hint. Spike Step 0 PASS validated; W2 (planning agent) and W3 (Ralph QC + dashboard live) follow in subsequent worktrees.
This commit is contained in:
@@ -24,6 +24,7 @@ from src.router import (
|
|||||||
_ralph_stop,
|
_ralph_stop,
|
||||||
_load_approved_tasks,
|
_load_approved_tasks,
|
||||||
)
|
)
|
||||||
|
from src.adapters.discord_views import RalphRootView
|
||||||
|
|
||||||
logger = logging.getLogger("echo-core.discord")
|
logger = logging.getLogger("echo-core.discord")
|
||||||
_security_log = logging.getLogger("echo-core.security")
|
_security_log = logging.getLogger("echo-core.security")
|
||||||
@@ -933,8 +934,28 @@ def create_bot(config: Config) -> discord.Client:
|
|||||||
) -> list[app_commands.Choice[str]]:
|
) -> list[app_commands.Choice[str]]:
|
||||||
return await _autocomplete_by_status(interaction, current, ("running", "approved"))
|
return await _autocomplete_by_status(interaction, current, ("running", "approved"))
|
||||||
|
|
||||||
|
async def _ralph_autocomplete_workspace(
|
||||||
|
interaction: discord.Interaction, current: str
|
||||||
|
) -> list[app_commands.Choice[str]]:
|
||||||
|
from pathlib import Path
|
||||||
|
ws = Path("/home/moltbot/workspace")
|
||||||
|
if not ws.exists():
|
||||||
|
return []
|
||||||
|
current_low = (current or "").lower()
|
||||||
|
choices: list[app_commands.Choice[str]] = []
|
||||||
|
for p in sorted(ws.iterdir()):
|
||||||
|
if not p.is_dir() or p.name.startswith("."):
|
||||||
|
continue
|
||||||
|
if current_low and current_low not in p.name.lower():
|
||||||
|
continue
|
||||||
|
choices.append(app_commands.Choice(name=p.name, value=p.name))
|
||||||
|
if len(choices) >= 25:
|
||||||
|
break
|
||||||
|
return choices
|
||||||
|
|
||||||
@tree.command(name="p", description="Propose new Ralph project")
|
@tree.command(name="p", description="Propose new Ralph project")
|
||||||
@app_commands.describe(slug="Project slug (e.g. game-library)", description="Short description of what to do")
|
@app_commands.describe(slug="Project slug (e.g. game-library)", description="Short description of what to do")
|
||||||
|
@app_commands.autocomplete(slug=_ralph_autocomplete_workspace)
|
||||||
async def ralph_p(
|
async def ralph_p(
|
||||||
interaction: discord.Interaction, slug: str, description: str
|
interaction: discord.Interaction, slug: str, description: str
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -949,9 +970,12 @@ def create_bot(config: Config) -> discord.Client:
|
|||||||
slugs = [slug] if slug else []
|
slugs = [slug] if slug else []
|
||||||
await interaction.response.send_message(_ralph_approve(slugs))
|
await interaction.response.send_message(_ralph_approve(slugs))
|
||||||
|
|
||||||
@tree.command(name="l", description="List Ralph projects status")
|
@tree.command(name="l", description="List Ralph projects (interactive)")
|
||||||
async def ralph_l(interaction: discord.Interaction) -> None:
|
async def ralph_l(interaction: discord.Interaction) -> None:
|
||||||
await interaction.response.send_message(_ralph_status())
|
view = RalphRootView()
|
||||||
|
await interaction.response.send_message(
|
||||||
|
view.render_summary(), view=view, ephemeral=True
|
||||||
|
)
|
||||||
|
|
||||||
@tree.command(name="k", description="Stop a running Ralph project")
|
@tree.command(name="k", description="Stop a running Ralph project")
|
||||||
@app_commands.describe(slug="Project slug to stop")
|
@app_commands.describe(slug="Project slug to stop")
|
||||||
|
|||||||
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)
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
"""Telegram bot adapter — commands and message handlers."""
|
"""Telegram bot adapter — commands and message handlers."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
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.constants import ChatAction, ChatType
|
||||||
from telegram.ext import (
|
from telegram.ext import (
|
||||||
Application,
|
Application,
|
||||||
@@ -22,14 +30,28 @@ from src.claude_session import (
|
|||||||
VALID_MODELS,
|
VALID_MODELS,
|
||||||
)
|
)
|
||||||
from src.fast_commands import dispatch as fast_dispatch
|
from src.fast_commands import dispatch as fast_dispatch
|
||||||
|
from src import ralph_flow
|
||||||
from src.router import (
|
from src.router import (
|
||||||
route_message,
|
route_message,
|
||||||
|
_load_approved_tasks,
|
||||||
_ralph_propose,
|
_ralph_propose,
|
||||||
_ralph_approve,
|
_ralph_approve,
|
||||||
_ralph_status,
|
_ralph_status,
|
||||||
_ralph_stop,
|
_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")
|
logger = logging.getLogger("echo-core.telegram")
|
||||||
_security_log = logging.getLogger("echo-core.security")
|
_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)
|
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:
|
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 [])
|
args = list(context.args or [])
|
||||||
filter_slug = args[0].lower() if args else None
|
if args:
|
||||||
result = await asyncio.to_thread(_ralph_status, filter_slug)
|
filter_slug = args[0].lower()
|
||||||
await update.message.reply_text(result)
|
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:
|
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)
|
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 ---
|
# --- Fast command handlers ---
|
||||||
|
|
||||||
|
|
||||||
@@ -535,6 +755,17 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
|||||||
chat_id, text[:100],
|
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
|
# Show typing indicator
|
||||||
await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
|
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(
|
response, _is_cmd = await asyncio.to_thread(
|
||||||
route_message, str(chat_id), str(user_id), text,
|
route_message, str(chat_id), str(user_id), text,
|
||||||
on_text=on_text,
|
on_text=on_text,
|
||||||
|
adapter_name=ADAPTER_NAME,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only send combined response if no intermediates were delivered
|
# 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("model", cmd_model))
|
||||||
app.add_handler(CommandHandler("register", cmd_register))
|
app.add_handler(CommandHandler("register", cmd_register))
|
||||||
app.add_handler(CallbackQueryHandler(callback_model, pattern="^model:"))
|
app.add_handler(CallbackQueryHandler(callback_model, pattern="^model:"))
|
||||||
|
app.add_handler(CallbackQueryHandler(callback_ralph, pattern="^ralph:"))
|
||||||
|
|
||||||
# Ralph commands
|
# Ralph commands
|
||||||
app.add_handler(CommandHandler("p", cmd_ralph_p))
|
app.add_handler(CommandHandler("p", cmd_ralph_p))
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ async def handle_incoming(msg: dict, client: httpx.AsyncClient) -> None:
|
|||||||
response, _is_cmd = await asyncio.to_thread(
|
response, _is_cmd = await asyncio.to_thread(
|
||||||
route_message, channel_id, user_id, text,
|
route_message, channel_id, user_id, text,
|
||||||
on_text=on_text,
|
on_text=on_text,
|
||||||
|
adapter_name="whatsapp",
|
||||||
)
|
)
|
||||||
# Only send combined response if no intermediates were delivered
|
# Only send combined response if no intermediates were delivered
|
||||||
if sent_count == 0:
|
if sent_count == 0:
|
||||||
|
|||||||
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
|
||||||
@@ -41,6 +41,7 @@ def route_message(
|
|||||||
text: str,
|
text: str,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
on_text: Callable[[str], None] | None = None,
|
on_text: Callable[[str], None] | None = None,
|
||||||
|
adapter_name: str | None = None,
|
||||||
) -> tuple[str, bool]:
|
) -> tuple[str, bool]:
|
||||||
"""Route an incoming message. Returns (response_text, is_command).
|
"""Route an incoming message. Returns (response_text, is_command).
|
||||||
|
|
||||||
@@ -49,11 +50,14 @@ def route_message(
|
|||||||
|
|
||||||
*on_text* — optional callback invoked with each intermediate text block
|
*on_text* — optional callback invoked with each intermediate text block
|
||||||
from Claude, enabling real-time streaming to the adapter.
|
from Claude, enabling real-time streaming to the adapter.
|
||||||
|
|
||||||
|
*adapter_name* — "discord" / "telegram" / "whatsapp" / None. Used for
|
||||||
|
adapter-specific response shaping (e.g., redirect line on WhatsApp).
|
||||||
"""
|
"""
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
|
|
||||||
# Ralph commands — short form (/p /a /l /k) and legacy aliases (!propose !approve !status !stop)
|
# Ralph commands — short form (/p /a /l /k) and legacy aliases (!propose !approve !status !stop)
|
||||||
ralph_response = _try_ralph_dispatch(text)
|
ralph_response = _try_ralph_dispatch(text, adapter_name=adapter_name)
|
||||||
if ralph_response is not None:
|
if ralph_response is not None:
|
||||||
return ralph_response, True
|
return ralph_response, True
|
||||||
|
|
||||||
@@ -168,7 +172,19 @@ RALPH_CMDS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _try_ralph_dispatch(text: str) -> str | None:
|
_WHATSAPP_REDIRECT = (
|
||||||
|
"\n\n💡 Pentru meniu interactiv folosește Discord sau Telegram."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_whatsapp_redirect(text: str, adapter_name: str | None) -> str:
|
||||||
|
"""Append a redirect hint for WhatsApp users so they discover the rich UX."""
|
||||||
|
if adapter_name == "whatsapp":
|
||||||
|
return text + _WHATSAPP_REDIRECT
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _try_ralph_dispatch(text: str, adapter_name: str | None = None) -> str | None:
|
||||||
"""Parse and dispatch Ralph commands. Returns response string or None if no match."""
|
"""Parse and dispatch Ralph commands. Returns response string or None if no match."""
|
||||||
low = text.lower()
|
low = text.lower()
|
||||||
first = low.split(None, 1)[0] if low else ""
|
first = low.split(None, 1)[0] if low else ""
|
||||||
@@ -176,7 +192,10 @@ def _try_ralph_dispatch(text: str) -> str | None:
|
|||||||
if first in ("/p", "!propose"):
|
if first in ("/p", "!propose"):
|
||||||
parts = text.split(None, 2)
|
parts = text.split(None, 2)
|
||||||
if len(parts) < 3:
|
if len(parts) < 3:
|
||||||
return "Folosire: /p <slug> <descriere>\nEx: /p roa2web Homepage redesign cu hero section"
|
return _maybe_whatsapp_redirect(
|
||||||
|
"Folosire: /p <slug> <descriere>\nEx: /p roa2web Homepage redesign cu hero section",
|
||||||
|
adapter_name,
|
||||||
|
)
|
||||||
return _ralph_propose(parts[1].strip(), parts[2].strip())
|
return _ralph_propose(parts[1].strip(), parts[2].strip())
|
||||||
|
|
||||||
if first in ("/a", "!approve"):
|
if first in ("/a", "!approve"):
|
||||||
@@ -189,7 +208,7 @@ def _try_ralph_dispatch(text: str) -> str | None:
|
|||||||
if first in ("/l", "!status"):
|
if first in ("/l", "!status"):
|
||||||
parts = text.split(None, 1)
|
parts = text.split(None, 1)
|
||||||
filter_slug = parts[1].strip().lower() if len(parts) > 1 else None
|
filter_slug = parts[1].strip().lower() if len(parts) > 1 else None
|
||||||
return _ralph_status(filter_slug)
|
return _maybe_whatsapp_redirect(_ralph_status(filter_slug), adapter_name)
|
||||||
|
|
||||||
if first in ("/k", "!stop"):
|
if first in ("/k", "!stop"):
|
||||||
parts = text.split(None, 1)
|
parts = text.split(None, 1)
|
||||||
|
|||||||
108
tests/test_ralph_flow.py
Normal file
108
tests/test_ralph_flow.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Tests for src/ralph_flow.py — short-lived per-user UX state."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import ralph_flow
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_state_file(tmp_path, monkeypatch):
|
||||||
|
"""Redirect state file to a tmp location for each test."""
|
||||||
|
monkeypatch.setattr(ralph_flow, "_STATE_FILE", tmp_path / "ralph_flow.json")
|
||||||
|
monkeypatch.setattr(ralph_flow, "SESSIONS_DIR", tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_state_returns_none_when_absent():
|
||||||
|
assert ralph_flow.get_state("discord", "c1", "u1") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_then_get_round_trip():
|
||||||
|
ralph_flow.set_state(
|
||||||
|
"discord", "c1", "u1",
|
||||||
|
step=ralph_flow.STEP_INPUT_DESCRIPTION,
|
||||||
|
project="roa2web",
|
||||||
|
)
|
||||||
|
state = ralph_flow.get_state("discord", "c1", "u1")
|
||||||
|
assert state is not None
|
||||||
|
assert state["step"] == ralph_flow.STEP_INPUT_DESCRIPTION
|
||||||
|
assert state["project"] == "roa2web"
|
||||||
|
assert "expires_at" in state
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_state_removes_entry():
|
||||||
|
ralph_flow.set_state("telegram", "42", "7", step="input_description")
|
||||||
|
assert ralph_flow.clear_state("telegram", "42", "7") is True
|
||||||
|
assert ralph_flow.get_state("telegram", "42", "7") is None
|
||||||
|
# Second clear is a no-op
|
||||||
|
assert ralph_flow.clear_state("telegram", "42", "7") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_keyed_by_adapter_chat_user():
|
||||||
|
"""Different adapters / chats / users have isolated state."""
|
||||||
|
ralph_flow.set_state("discord", "c1", "u1", step="input_description", project="A")
|
||||||
|
ralph_flow.set_state("telegram", "c1", "u1", step="input_description", project="B")
|
||||||
|
ralph_flow.set_state("discord", "c2", "u1", step="input_description", project="C")
|
||||||
|
ralph_flow.set_state("discord", "c1", "u2", step="input_description", project="D")
|
||||||
|
|
||||||
|
assert ralph_flow.get_state("discord", "c1", "u1")["project"] == "A"
|
||||||
|
assert ralph_flow.get_state("telegram", "c1", "u1")["project"] == "B"
|
||||||
|
assert ralph_flow.get_state("discord", "c2", "u1")["project"] == "C"
|
||||||
|
assert ralph_flow.get_state("discord", "c1", "u2")["project"] == "D"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_state_returns_none_and_self_cleans(monkeypatch):
|
||||||
|
"""get_state on an expired entry should return None and drop the entry."""
|
||||||
|
# Set with 0s TTL — already expired
|
||||||
|
ralph_flow.set_state(
|
||||||
|
"discord", "c1", "u1",
|
||||||
|
step="input_description",
|
||||||
|
ttl_seconds=0,
|
||||||
|
)
|
||||||
|
assert ralph_flow.get_state("discord", "c1", "u1") is None
|
||||||
|
# Verify entry was dropped from disk
|
||||||
|
assert ralph_flow._load() == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_expired_drops_only_expired():
|
||||||
|
ralph_flow.set_state("discord", "c1", "u1", step="x", ttl_seconds=0) # expired
|
||||||
|
ralph_flow.set_state("discord", "c2", "u2", step="y", ttl_seconds=600) # fresh
|
||||||
|
|
||||||
|
dropped = ralph_flow.cleanup_expired()
|
||||||
|
|
||||||
|
assert dropped == 1
|
||||||
|
assert ralph_flow.get_state("discord", "c1", "u1") is None
|
||||||
|
assert ralph_flow.get_state("discord", "c2", "u2") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_state_overwrites_previous():
|
||||||
|
ralph_flow.set_state("discord", "c1", "u1", step="step_a", project="P1")
|
||||||
|
ralph_flow.set_state("discord", "c1", "u1", step="step_b", project="P2")
|
||||||
|
state = ralph_flow.get_state("discord", "c1", "u1")
|
||||||
|
assert state["step"] == "step_b"
|
||||||
|
assert state["project"] == "P2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_state_extras_propagate():
|
||||||
|
ralph_flow.set_state(
|
||||||
|
"discord", "c1", "u1",
|
||||||
|
step="x",
|
||||||
|
custom_field="hello",
|
||||||
|
nested={"a": 1},
|
||||||
|
)
|
||||||
|
state = ralph_flow.get_state("discord", "c1", "u1")
|
||||||
|
assert state["custom_field"] == "hello"
|
||||||
|
assert state["nested"] == {"a": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupted_state_file_returns_empty(tmp_path):
|
||||||
|
"""If state file is corrupt JSON, _load returns {} so get_state stays robust."""
|
||||||
|
ralph_flow._STATE_FILE.write_text("not json {")
|
||||||
|
assert ralph_flow.get_state("discord", "c1", "u1") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_atomic_write_does_not_leave_temp_files(tmp_path):
|
||||||
|
ralph_flow.set_state("discord", "c1", "u1", step="x")
|
||||||
|
leftovers = [p for p in tmp_path.iterdir() if p.name.startswith(".ralph_flow_")]
|
||||||
|
assert leftovers == []
|
||||||
@@ -134,6 +134,31 @@ class TestStatusCommand:
|
|||||||
assert is_cmd is True
|
assert is_cmd is True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Ralph command dispatch ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestRalphDispatch:
|
||||||
|
def test_p_without_args_returns_usage(self):
|
||||||
|
response, is_cmd = route_message("ch-1", "user-1", "/p")
|
||||||
|
assert "Folosire: /p" in response
|
||||||
|
assert is_cmd is True
|
||||||
|
|
||||||
|
def test_whatsapp_appends_redirect_hint_on_usage(self):
|
||||||
|
"""WhatsApp users see a redirect line pointing them to Discord/TG."""
|
||||||
|
response, is_cmd = route_message(
|
||||||
|
"ch-1", "user-1", "/p", adapter_name="whatsapp"
|
||||||
|
)
|
||||||
|
assert "Folosire: /p" in response
|
||||||
|
assert "Discord sau Telegram" in response
|
||||||
|
|
||||||
|
def test_discord_does_not_get_whatsapp_redirect(self):
|
||||||
|
response, is_cmd = route_message(
|
||||||
|
"ch-1", "user-1", "/p", adapter_name="discord"
|
||||||
|
)
|
||||||
|
assert "Folosire: /p" in response
|
||||||
|
assert "Discord sau Telegram" not in response
|
||||||
|
|
||||||
|
|
||||||
# --- Unknown command ---
|
# --- Unknown command ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user