"""Telegram bot adapter — commands and message handlers.""" import asyncio import json import logging import os import subprocess from pathlib import Path from telegram import ( BotCommand, ForceReply, InlineKeyboardButton, InlineKeyboardMarkup, ReactionTypeEmoji, Update, ) from telegram.constants import ChatAction, ChatType from telegram.ext import ( Application, CallbackQueryHandler, CommandHandler, ContextTypes, MessageHandler, filters, ) from src.config import Config from src.claude_session import ( clear_session, get_active_session, set_session_model, 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, planning_advance, planning_approve, planning_cancel, start_planning_session, ) from src.planning_session import is_in_planning 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") # Module-level config reference, set by create_telegram_bot() _config: Config | None = None def _get_config() -> Config: """Return the module-level config, raising if not initialized.""" if _config is None: raise RuntimeError("Bot not initialized — call create_telegram_bot() first") return _config # --- Authorization helpers --- def is_owner(user_id: int) -> bool: """Check if user_id matches config bot.owner.""" owner = _get_config().get("bot.owner") return str(user_id) == str(owner) def is_admin(user_id: int) -> bool: """Check if user_id is owner or in admins list.""" if is_owner(user_id): return True admins = _get_config().get("bot.admins", []) return str(user_id) in admins def is_registered_chat(chat_id: int) -> bool: """Check if Telegram chat_id is in any registered channel entry.""" channels = _get_config().get("telegram_channels", {}) return any(ch.get("id") == str(chat_id) for ch in channels.values()) def _channel_alias_for_chat(chat_id: int) -> str | None: """Resolve a Telegram chat ID to its config alias.""" channels = _get_config().get("telegram_channels", {}) for alias, info in channels.items(): if info.get("id") == str(chat_id): return alias return None # --- Message splitting helper --- def split_message(text: str, limit: int = 4096) -> list[str]: """Split text into chunks that fit Telegram's message limit.""" if len(text) <= limit: return [text] chunks = [] while text: if len(text) <= limit: chunks.append(text) break split_at = text.rfind("\n", 0, limit) if split_at == -1: split_at = limit chunks.append(text[:split_at]) text = text[split_at:].lstrip("\n") return chunks # --- Command handlers --- async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start — welcome message.""" await update.message.reply_text( "Echo Core — Telegram adapter.\n" "Send a message to chat with Claude.\n" "Use /help for available commands." ) async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /help — list commands.""" lines = [ "*Echo Commands*", "/help — Show this help", "/clear — Clear session", "/status — Session status", "/model — View/change AI model", "", "*Email*", "/email — Check unread emails", "/emailsend :: ", "/emailsave — Save emails to KB", "", "*Calendar*", "/calendar — Today + tomorrow", "/calendarweek — Week schedule", "/calendarbusy — Am I busy?", "", "*Notes*", "/note — Quick note", "/jurnal — Journal entry", "/search — Memory search", "/kb [category] — KB notes", "", "*Reminders*", "/remind ", "", "*Git*", "/commit [msg] — Commit changes", "/push — Push to remote", "/pull — Pull with rebase", "/test [pattern] — Run tests", "", "*Ops*", "/logs [N] — Log lines", "/doctor — Diagnostics", "/heartbeat — Health checks", "", "*Ralph (autonomous projects)*", "/p — Propose new project", "/a [slug] — Approve for tonight (no slug = list pending)", "/l — List projects status", "/k — Stop a running project", ] await update.message.reply_text("\n".join(lines), parse_mode="Markdown") async def cmd_clear(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /clear — clear session for this chat.""" chat_id = str(update.effective_chat.id) default_model = _get_config().get("bot.default_model", "sonnet") removed = clear_session(chat_id) if removed: await update.message.reply_text( f"Session cleared. Model reset to {default_model}." ) else: await update.message.reply_text("No active session for this chat.") async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /status — show session status.""" chat_id = str(update.effective_chat.id) session = get_active_session(chat_id) if not session: await update.message.reply_text("No active session.") return model = session.get("model", "?") sid = session.get("session_id", "?")[:8] count = session.get("message_count", 0) in_tok = session.get("total_input_tokens", 0) out_tok = session.get("total_output_tokens", 0) await update.message.reply_text( f"Model: {model}\n" f"Session: {sid}\n" f"Messages: {count}\n" f"Tokens: {in_tok} in / {out_tok} out" ) async def cmd_model(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /model — view or change model with inline keyboard.""" chat_id = str(update.effective_chat.id) args = context.args if args: # /model opus — change model directly choice = args[0].lower() if choice not in VALID_MODELS: await update.message.reply_text( f"Invalid model '{choice}'. Choose from: {', '.join(sorted(VALID_MODELS))}" ) return session = get_active_session(chat_id) if session: set_session_model(chat_id, choice) else: from src.claude_session import _load_sessions, _save_sessions from datetime import datetime, timezone sessions = _load_sessions() sessions[chat_id] = { "session_id": "", "model": choice, "created_at": datetime.now(timezone.utc).isoformat(), "last_message_at": datetime.now(timezone.utc).isoformat(), "message_count": 0, } _save_sessions(sessions) await update.message.reply_text(f"Model changed to *{choice}*.", parse_mode="Markdown") return # No args — show current model + inline keyboard session = get_active_session(chat_id) if session: current = session.get("model", "?") else: current = _get_config().get("bot.default_model", "sonnet") keyboard = [ [ InlineKeyboardButton( f"{'> ' if m == current else ''}{m}", callback_data=f"model:{m}", ) for m in sorted(VALID_MODELS) ] ] await update.message.reply_text( f"Current model: *{current}*\nSelect a model:", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown", ) async def callback_model(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle inline keyboard callback for model selection.""" query = update.callback_query await query.answer() choice = query.data.replace("model:", "") if choice not in VALID_MODELS: return chat_id = str(query.message.chat_id) session = get_active_session(chat_id) if session: set_session_model(chat_id, choice) else: from src.claude_session import _load_sessions, _save_sessions from datetime import datetime, timezone sessions = _load_sessions() sessions[chat_id] = { "session_id": "", "model": choice, "created_at": datetime.now(timezone.utc).isoformat(), "last_message_at": datetime.now(timezone.utc).isoformat(), "message_count": 0, } _save_sessions(sessions) await query.edit_message_text(f"Model changed to *{choice}*.", parse_mode="Markdown") async def cmd_register(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /register — register current chat (owner only).""" user_id = update.effective_user.id if not is_owner(user_id): _security_log.warning( "Unauthorized owner command /register by user=%s (%s)", user_id, update.effective_user.username, ) await update.message.reply_text("Owner only.") return if not context.args: await update.message.reply_text("Usage: /register ") return alias = context.args[0].lower() chat_id = str(update.effective_chat.id) config = _get_config() channels = config.get("telegram_channels", {}) if alias in channels: await update.message.reply_text(f"Alias '{alias}' already registered.") return channels[alias] = {"id": chat_id, "default_model": "sonnet"} config.set("telegram_channels", channels) config.save() await update.message.reply_text( f"Chat registered as '{alias}' (ID: {chat_id})." ) # --- Ralph commands (autonomous project execution) --- async def cmd_ralph_p(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/p — propune proiect Ralph.""" args = list(context.args or []) if len(args) < 2: await update.message.reply_text( "Folosire: /p \nEx: /p roa2web Homepage redesign cu hero section" ) return slug = args[0] description = " ".join(args[1:]) result = await asyncio.to_thread(_ralph_propose, slug, description) await update.message.reply_text(result) async def cmd_ralph_a(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/a [slug] — aprobă proiect (fără arg = listă pending).""" args = list(context.args or []) slugs: list[str] = [] if args: for a in args: slugs.extend(s.strip() for s in a.replace(",", " ").split() if s.strip()) result = await asyncio.to_thread(_ralph_approve, slugs) 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("🧠 Planifică", callback_data=f"ralph:plan:{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 _build_planning_active_keyboard() -> InlineKeyboardMarkup: """Keyboard shown DURING an active planning session (after each turn).""" return InlineKeyboardMarkup([ [ InlineKeyboardButton("▶️ Continuă faza", callback_data="ralph:planadvance"), InlineKeyboardButton("🛑 Anulează", callback_data="ralph:plancancel"), ], ]) def _build_planning_final_keyboard() -> InlineKeyboardMarkup: """Keyboard shown when the planning pipeline has finished all phases.""" return InlineKeyboardMarkup([ [ InlineKeyboardButton("✅ Dau drumul tonight", callback_data="ralph:planapprove"), InlineKeyboardButton("🛑 Anulează", callback_data="ralph:plancancel"), ], ]) 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 — listă interactivă proiecte Ralph.""" args = list(context.args or []) 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: """/k — oprește proiect Ralph.""" args = list(context.args or []) if not args: await update.message.reply_text("Folosire: /k ") return result = await asyncio.to_thread(_ralph_stop, args[0]) await update.message.reply_text(result) def split_planning_chunks(text: str, limit: int = 4096) -> list[str]: """Telegram-safe split (mirrors split_message but local to avoid forward ref).""" if len(text) <= limit: return [text] chunks = [] while text: if len(text) <= limit: chunks.append(text) break cut = text.rfind("\n", 0, limit) if cut == -1: cut = limit chunks.append(text[:cut]) text = text[cut:].lstrip("\n") return chunks async def cmd_plan(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/plan [descriere] — pornește o sesiune de planning conversational.""" args = list(context.args or []) if not args: await update.message.reply_text("Folosire: /plan [descriere]") return slug = args[0] description = " ".join(args[1:]).strip() if not description: # Look up from approved-tasks try: data = _load_approved_tasks() except Exception: data = {"projects": []} for p in data.get("projects", []): if p.get("name", "").lower() == slug.lower(): description = p.get("description") or "" break if not description: await update.message.reply_text( f"Nu am descriere pentru `{slug}`. Adaugă cu /p {slug} .", parse_mode="Markdown", ) return chat_id = update.message.chat_id await update.message.reply_text( f"🧠 Pornesc planning pentru *{slug}*… (durează ~60s)", parse_mode="Markdown", ) first = await asyncio.to_thread( start_planning_session, slug, description, str(chat_id), ADAPTER_NAME, ) for chunk in split_planning_chunks(first): await context.bot.send_message(chat_id=chat_id, text=chunk) await context.bot.send_message( chat_id=chat_id, text="Răspunde aici. Apasă _Continuă faza_ când ești gata să trec la următoarea.", reply_markup=_build_planning_active_keyboard(), parse_mode="Markdown", ) async def cmd_cancel_planning(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/cancel — anulează sesiunea de planning curentă.""" text = await asyncio.to_thread( planning_cancel, str(update.message.chat_id), ADAPTER_NAME, ) await update.message.reply_text(text) 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 # ---- Planning agent (W2) --------------------------------------------- if action == "plan": # Look up project description from approved-tasks.json (or workspace fallback). try: data = _load_approved_tasks() except Exception: data = {"projects": []} description = "" for p in data.get("projects", []): if p.get("name", "").lower() == (slug or "").lower(): description = p.get("description") or "" break if not description: # No description yet — set state and prompt with ForceReply. # Next message in this chat will start the planning session. ralph_flow.set_state( ADAPTER_NAME, chat_id, user_id, step=ralph_flow.STEP_INPUT_DESCRIPTION_THEN_PLAN, project=slug, ) await context.bot.send_message( chat_id=int(chat_id), text=f"📝 Descriere pentru *{slug}* — pornesc planning după ce trimiți:", reply_markup=ForceReply(selective=True), parse_mode="Markdown", ) return await context.bot.send_message( chat_id=int(chat_id), text=f"🧠 Pornesc planning pentru *{slug}*… (durează ~60s)", parse_mode="Markdown", ) first = await asyncio.to_thread( start_planning_session, slug, description, str(chat_id), ADAPTER_NAME, ) for chunk in split_message(first): await context.bot.send_message(chat_id=int(chat_id), text=chunk) await context.bot.send_message( chat_id=int(chat_id), text="Răspunde aici. Apasă _Continuă faza_ când ești gata să trec la următoarea.", reply_markup=_build_planning_active_keyboard(), parse_mode="Markdown", ) return if action == "planadvance": await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) text, completed = await asyncio.to_thread( planning_advance, str(chat_id), ADAPTER_NAME, ) for chunk in split_message(text): await context.bot.send_message(chat_id=int(chat_id), text=chunk) kb = _build_planning_final_keyboard() if completed else _build_planning_active_keyboard() await context.bot.send_message( chat_id=int(chat_id), text=("Plan gata. Confirmi?" if completed else "Continuăm?"), reply_markup=kb, ) return if action == "plancancel": text = await asyncio.to_thread(planning_cancel, str(chat_id), ADAPTER_NAME) await context.bot.send_message(chat_id=int(chat_id), text=text) return if action == "planapprove": text = await asyncio.to_thread(planning_approve, str(chat_id), ADAPTER_NAME) await context.bot.send_message(chat_id=int(chat_id), text=text) return # --- Audio helpers --- _AUDIO_PREFIX = "__AUDIO__:" async def _send_voice_telegram(update: Update, wav_path: str) -> None: """Convertește WAV→OGG (ffmpeg) și trimite ca voice note Telegram.""" ogg_path = wav_path.replace(".wav", ".ogg") try: ffmpeg = "/home/moltbot/bin/ffmpeg" subprocess.run( [ ffmpeg, "-i", wav_path, "-c:a", "libopus", "-b:a", "64k", ogg_path, "-y", ], check=True, capture_output=True, timeout=30, ) with open(ogg_path, "rb") as f: await update.message.reply_voice(voice=f) except subprocess.CalledProcessError as e: err = e.stderr.decode(errors="replace")[:200] await update.message.reply_text(f"Conversie audio eșuată: {err}") except Exception as e: await update.message.reply_text(f"Eroare trimitere audio: {e}") finally: for p in [wav_path, ogg_path]: try: if os.path.exists(p): os.unlink(p) except OSError: pass # --- Fast command handlers --- async def _fast_cmd(update: Update, name: str, args: list[str]) -> None: """Run a fast command and reply with the result.""" await update.message.chat.send_action(ChatAction.TYPING) result = await asyncio.to_thread(fast_dispatch, name, args) if result: if result.startswith(_AUDIO_PREFIX): wav_path = result[len(_AUDIO_PREFIX):] await _send_voice_telegram(update, wav_path) else: for chunk in split_message(result): await update.message.reply_text(chunk) else: await update.message.reply_text(f"Unknown command: /{name}") async def cmd_email(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/email — check unread emails.""" await _fast_cmd(update, "email", list(context.args or [])) async def cmd_emailsend(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/emailsend :: """ await _fast_cmd(update, "email", ["send"] + list(context.args or [])) async def cmd_emailsave(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/emailsave — save unread emails to KB.""" await _fast_cmd(update, "email", ["save"]) async def cmd_calendar(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/calendar — today + tomorrow events.""" await _fast_cmd(update, "calendar", list(context.args or [])) async def cmd_calendarweek(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/calendarweek — this week's schedule.""" await _fast_cmd(update, "calendar", ["week"]) async def cmd_calendarbusy(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/calendarbusy — am I in a meeting?""" await _fast_cmd(update, "calendar", ["busy"]) async def cmd_note(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/note """ args = list(context.args or []) if not args: await update.message.reply_text("Usage: /note ") return await _fast_cmd(update, "note", args) async def cmd_jurnal(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/jurnal """ args = list(context.args or []) if not args: await update.message.reply_text("Usage: /jurnal ") return await _fast_cmd(update, "jurnal", args) async def cmd_search(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/search """ args = list(context.args or []) if not args: await update.message.reply_text("Usage: /search ") return await _fast_cmd(update, "search", args) async def cmd_kb(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/kb [category]""" await _fast_cmd(update, "kb", list(context.args or [])) async def cmd_remind(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/remind or /remind """ args = list(context.args or []) if len(args) < 2: await update.message.reply_text("Usage: /remind ") return await _fast_cmd(update, "remind", args) async def cmd_commit(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/commit [message]""" await _fast_cmd(update, "commit", list(context.args or [])) async def cmd_push(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/push""" await _fast_cmd(update, "push", []) async def cmd_pull(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/pull""" await _fast_cmd(update, "pull", []) async def cmd_test(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/test [pattern]""" await _fast_cmd(update, "test", list(context.args or [])) async def cmd_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/logs [N]""" await _fast_cmd(update, "logs", list(context.args or [])) async def cmd_doctor(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/doctor""" await _fast_cmd(update, "doctor", []) async def cmd_heartbeat_tg(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/heartbeat""" await _fast_cmd(update, "heartbeat", []) async def cmd_audio(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """/audio [voce] [text|url] — TTS via Supertonic.""" await _fast_cmd(update, "audio", list(context.args or [])) # --- Message handler --- async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Process incoming text messages — route to Claude.""" message = update.message if not message or not message.text: return user_id = update.effective_user.id chat_id = update.effective_chat.id chat_type = update.effective_chat.type # Private chat: only admins if chat_type == ChatType.PRIVATE: if not is_admin(user_id): _security_log.warning( "Unauthorized Telegram DM from user=%s (%s): %s", user_id, update.effective_user.username, message.text[:100], ) return # Group chat: only registered chats, and bot must be mentioned or replied to elif chat_type in (ChatType.GROUP, ChatType.SUPERGROUP): if not is_registered_chat(chat_id): return # In groups, only respond when mentioned or replied to bot_username = context.bot.username is_reply_to_bot = ( message.reply_to_message and message.reply_to_message.from_user and message.reply_to_message.from_user.id == context.bot.id ) is_mention = bot_username and f"@{bot_username}" in message.text if not is_reply_to_bot and not is_mention: return else: return text = message.text # Remove bot mention from text if present bot_username = context.bot.username if bot_username: text = text.replace(f"@{bot_username}", "").strip() if not text: return logger.info( "Telegram message from %s (%s) in chat %s: %s", user_id, update.effective_user.username, chat_id, text[:100], ) # Ralph multi-step state: route the next message based on the recorded step. state = ralph_flow.get_state(ADAPTER_NAME, str(chat_id), str(user_id)) if state: step = state.get("step") slug = state.get("project") if slug and step == ralph_flow.STEP_INPUT_DESCRIPTION: 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 if slug and step == ralph_flow.STEP_INPUT_DESCRIPTION_THEN_PLAN: ralph_flow.clear_state(ADAPTER_NAME, str(chat_id), str(user_id)) await message.reply_text( f"🧠 Pornesc planning pentru *{slug}*… (durează ~60s)", parse_mode="Markdown", ) try: first = await asyncio.to_thread( start_planning_session, slug, text, str(chat_id), ADAPTER_NAME, ) except Exception as e: logger.exception("start_planning_session failed for %s", slug) await message.reply_text(f"Planning blocat: {e}") return for chunk in split_planning_chunks(first): await context.bot.send_message(chat_id=chat_id, text=chunk) await context.bot.send_message( chat_id=chat_id, text="Răspunde aici. Apasă _Continuă faza_ când ești gata să trec la următoarea.", reply_markup=_build_planning_active_keyboard(), parse_mode="Markdown", ) return # Emoji reaction: 👀 = am văzut, procesez try: await context.bot.set_message_reaction( chat_id=chat_id, message_id=message.message_id, reaction=[ReactionTypeEmoji(emoji="👀")], ) except Exception: pass # Reactions not supported in this chat type — ignore silently # Show typing indicator await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) # Track intermediate messages sent via callback sent_count = 0 loop = asyncio.get_event_loop() def on_text(text_block: str) -> None: """Send intermediate Claude text blocks to the chat.""" nonlocal sent_count chunks = split_message(text_block) for chunk in chunks: asyncio.run_coroutine_threadsafe( context.bot.send_message(chat_id=chat_id, text=chunk), loop ) sent_count += 1 try: 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 if sent_count == 0: chunks = split_message(response) for chunk in chunks: await message.reply_text(chunk) # Emoji reaction: ✅ = răspuns trimis try: await context.bot.set_message_reaction( chat_id=chat_id, message_id=message.message_id, reaction=[ReactionTypeEmoji(emoji="✅")], ) except Exception: pass except Exception: logger.exception("Error processing Telegram message from %s", user_id) await message.reply_text("Sorry, something went wrong processing your message.") # --- Factory --- def create_telegram_bot(config: Config, token: str) -> Application: """Create and configure the Telegram bot with all handlers.""" global _config _config = config app = Application.builder().token(token).build() # Core commands app.add_handler(CommandHandler("start", cmd_start)) app.add_handler(CommandHandler("help", cmd_help)) app.add_handler(CommandHandler("clear", cmd_clear)) app.add_handler(CommandHandler("status", cmd_status)) 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)) app.add_handler(CommandHandler("a", cmd_ralph_a)) app.add_handler(CommandHandler("l", cmd_ralph_l)) app.add_handler(CommandHandler("k", cmd_ralph_k)) # Planning agent (W2) app.add_handler(CommandHandler("plan", cmd_plan)) app.add_handler(CommandHandler("cancel", cmd_cancel_planning)) # Fast commands app.add_handler(CommandHandler("email", cmd_email)) app.add_handler(CommandHandler("emailsend", cmd_emailsend)) app.add_handler(CommandHandler("emailsave", cmd_emailsave)) app.add_handler(CommandHandler("calendar", cmd_calendar)) app.add_handler(CommandHandler("calendarweek", cmd_calendarweek)) app.add_handler(CommandHandler("calendarbusy", cmd_calendarbusy)) app.add_handler(CommandHandler("note", cmd_note)) app.add_handler(CommandHandler("jurnal", cmd_jurnal)) app.add_handler(CommandHandler("search", cmd_search)) app.add_handler(CommandHandler("kb", cmd_kb)) app.add_handler(CommandHandler("remind", cmd_remind)) app.add_handler(CommandHandler("commit", cmd_commit)) app.add_handler(CommandHandler("push", cmd_push)) app.add_handler(CommandHandler("pull", cmd_pull)) app.add_handler(CommandHandler("test", cmd_test)) app.add_handler(CommandHandler("logs", cmd_logs)) app.add_handler(CommandHandler("doctor", cmd_doctor)) app.add_handler(CommandHandler("heartbeat", cmd_heartbeat_tg)) app.add_handler(CommandHandler("audio", cmd_audio)) # Text message handler (must be last) app.add_handler( MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message) ) # Register bot menu commands on startup async def post_init(application: Application) -> None: await application.bot.set_my_commands([ BotCommand("help", "List commands"), BotCommand("email", "Check unread emails"), BotCommand("emailsend", "Send an email"), BotCommand("emailsave", "Save emails to KB"), BotCommand("calendar", "Today + tomorrow events"), BotCommand("calendarweek", "Week schedule"), BotCommand("calendarbusy", "Am I busy?"), BotCommand("note", "Quick note"), BotCommand("jurnal", "Journal entry"), BotCommand("search", "Memory search"), BotCommand("kb", "KB notes"), BotCommand("remind", "Create reminder"), BotCommand("commit", "Git commit"), BotCommand("push", "Git push"), BotCommand("pull", "Git pull"), BotCommand("test", "Run tests"), BotCommand("clear", "Clear session"), BotCommand("status", "Session status"), BotCommand("model", "View/change model"), BotCommand("logs", "Show log lines"), BotCommand("doctor", "Diagnostics"), BotCommand("heartbeat", "Health checks"), BotCommand("audio", "TTS: text/url → voice note [voce] [rezumat]"), BotCommand("p", "Ralph: propose new project"), BotCommand("a", "Ralph: approve project for tonight"), BotCommand("l", "Ralph: list projects status"), BotCommand("k", "Ralph: stop running project"), BotCommand("plan", "Planning conversational pentru un proiect"), BotCommand("cancel", "Anulează planning în curs"), ]) app.post_init = post_init return app