diff --git a/src/adapters/discord_bot.py b/src/adapters/discord_bot.py index 5e95579..57c6ac5 100644 --- a/src/adapters/discord_bot.py +++ b/src/adapters/discord_bot.py @@ -24,6 +24,7 @@ from src.router import ( _ralph_stop, _load_approved_tasks, ) +from src.adapters.discord_views import RalphRootView logger = logging.getLogger("echo-core.discord") _security_log = logging.getLogger("echo-core.security") @@ -933,8 +934,28 @@ def create_bot(config: Config) -> discord.Client: ) -> list[app_commands.Choice[str]]: 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") @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( interaction: discord.Interaction, slug: str, description: str ) -> None: @@ -949,9 +970,12 @@ def create_bot(config: Config) -> discord.Client: slugs = [slug] if slug else [] 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: - 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") @app_commands.describe(slug="Project slug to stop") diff --git a/src/adapters/discord_views.py b/src/adapters/discord_views.py new file mode 100644 index 0000000..d1f62fb --- /dev/null +++ b/src/adapters/discord_views.py @@ -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) diff --git a/src/adapters/telegram_bot.py b/src/adapters/telegram_bot.py index 7850574..35213bb 100644 --- a/src/adapters/telegram_bot.py +++ b/src/adapters/telegram_bot.py @@ -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)) diff --git a/src/adapters/whatsapp.py b/src/adapters/whatsapp.py index 1ec540b..67a5e65 100644 --- a/src/adapters/whatsapp.py +++ b/src/adapters/whatsapp.py @@ -226,6 +226,7 @@ async def handle_incoming(msg: dict, client: httpx.AsyncClient) -> None: response, _is_cmd = await asyncio.to_thread( route_message, channel_id, user_id, text, on_text=on_text, + adapter_name="whatsapp", ) # Only send combined response if no intermediates were delivered if sent_count == 0: diff --git a/src/ralph_flow.py b/src/ralph_flow.py new file mode 100644 index 0000000..ae0ae61 --- /dev/null +++ b/src/ralph_flow.py @@ -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 diff --git a/src/router.py b/src/router.py index 981511a..a7107ef 100644 --- a/src/router.py +++ b/src/router.py @@ -41,6 +41,7 @@ def route_message( text: str, model: str | None = None, on_text: Callable[[str], None] | None = None, + adapter_name: str | None = None, ) -> tuple[str, bool]: """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 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() # 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: 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.""" low = text.lower() 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"): parts = text.split(None, 2) if len(parts) < 3: - return "Folosire: /p \nEx: /p roa2web Homepage redesign cu hero section" + return _maybe_whatsapp_redirect( + "Folosire: /p \nEx: /p roa2web Homepage redesign cu hero section", + adapter_name, + ) return _ralph_propose(parts[1].strip(), parts[2].strip()) if first in ("/a", "!approve"): @@ -189,7 +208,7 @@ def _try_ralph_dispatch(text: str) -> str | None: if first in ("/l", "!status"): parts = text.split(None, 1) 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"): parts = text.split(None, 1) diff --git a/tests/test_ralph_flow.py b/tests/test_ralph_flow.py new file mode 100644 index 0000000..f6ec275 --- /dev/null +++ b/tests/test_ralph_flow.py @@ -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 == [] diff --git a/tests/test_router.py b/tests/test_router.py index dd47655..0038136 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -134,6 +134,31 @@ class TestStatusCommand: 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 ---