"""Inbound Intent Router — Process Telegram group replies as calendar intents. When a user replies to a bot message in the family Telegram group, or uses a wake word, route the message through the intent engine for calendar mutations. GUARDRAILS: - Only process if the message is a reply to the bot OR contains a wake word - Never scrape general family chatter - Wake words: "Socrates", "calendar", "schedule" (configurable) - All mutations confirmed back to the group via Hermes """ import json import os import re from family_assistant.intent_engine import parse_intent, execute_intent from family_assistant.hermes import push_pipeline_results # Wake words that trigger intent processing even without a reply WAKE_WORDS = os.environ.get("INTENT_WAKE_WORDS", "Socrates,calendar,schedule,@HomeButler").split(",") # Bot username (set in .env) — used to detect replies to bot messages BOT_USERNAME = os.environ.get("TELEGRAM_BOT_USERNAME", "") def should_process(message: str, is_reply_to_bot: bool = False) -> bool: """Determine if a group message should be routed through the intent engine. Args: message: The text of the incoming message is_reply_to_bot: True if the message is a reply to one of the bot's messages Returns: True if the message should be processed as a calendar intent """ if is_reply_to_bot: return True # Check for wake words (case-insensitive) message_lower = message.lower() for word in WAKE_WORDS: word = word.strip().lower() if word and word in message_lower: return True return False def process_group_message(message: str, sender: str = "", is_reply_to_bot: bool = False, quoted_message: str = "", dry_run: bool = False) -> dict: """Process a family group message for calendar intents. This is the main entry point for inbound intent routing. Args: message: The text of the incoming message sender: The display name of the sender is_reply_to_bot: True if the message is a reply to one of the bot's messages quoted_message: The text of the message being replied to (if any) dry_run: If True, don't execute mutations Returns: A result dict with intent, execution result, and notification status """ if not should_process(message, is_reply_to_bot): return {"status": "skipped", "reason": "not_a_calendar_intent"} # If replying to a bot message, include the quoted text as context # so the LLM knows which event is being discussed full_message = message if quoted_message and is_reply_to_bot: full_message = f"[Replying to bot message: {quoted_message}]\n\nUser says: {message}" # Parse the intent intent = parse_intent(full_message) if intent.get("type") == "none": return {"status": "ignored", "intent": intent, "reason": "not_calendar_related"} # Execute the intent result = execute_intent(intent, dry_run=dry_run) # Build notification for the group notification = _format_result_message(intent, result, sender) return { "status": "processed", "intent": intent, "result": result, "notification": notification, } def _format_result_message(intent: dict, result: dict, sender: str = "") -> str: """Format the result of an intent execution as a Telegram message.""" intent_type = intent.get("type", "unknown") result_status = result.get("status", "unknown") if result_status.startswith("DRY_RUN"): prefix = "🔍 **Dry Run**" elif result_status in ("MOVED", "CANCELLED", "CREATED", "RENAMED", "REJECTED"): prefix = "✅" elif result_status == "not_found": prefix = "❓" elif result_status == "ambiguous": prefix = "âš ī¸" elif result_status == "error": prefix = "❌" else: prefix = "â„šī¸" if intent_type == "move": if result_status == "MOVED": summary = result.get("summary", intent.get("summary")) new_start = _friendly_datetime(result.get("new_start", "")) return f"{prefix} Moved **{summary}** to {new_start}" elif result_status == "not_found": return f"{prefix} Couldn't find an event matching \"{intent.get('summary')}\"" elif result_status.startswith("DRY_RUN"): summary = result.get("summary", intent.get("summary")) new_start = _friendly_datetime(result.get("new_start", "")) old_start = _friendly_datetime(result.get("old_start", "")) return f"{prefix} Would move **{summary}** from {old_start} to {new_start}" elif result_status == "ambiguous": return f"{prefix} {result.get('message', 'Multiple events found')}" else: return f"❌ Error moving event: {result.get('message', 'Unknown error')}" elif intent_type == "cancel": if result_status == "CANCELLED": summary = result.get("summary", intent.get("summary")) return f"{prefix} Cancelled **{summary}**" elif result_status == "not_found": return f"{prefix} Couldn't find an event matching \"{intent.get('summary')}\"" elif result_status.startswith("DRY_RUN"): summary = result.get("summary", intent.get("summary")) return f"{prefix} Would cancel **{summary}**" else: return f"❌ Error cancelling: {result.get('message', 'Unknown error')}" elif intent_type == "add": if result_status == "CREATED": summary = result.get("summary", intent.get("summary")) start = _friendly_datetime(result.get("start", "")) return f"{prefix} Added **{summary}** — {start}" elif result_status.startswith("DRY_RUN"): summary = result.get("summary", intent.get("summary")) start = _friendly_datetime(result.get("start", "")) return f"{prefix} Would add **{summary}** — {start}" else: return f"❌ Error adding event: {result.get('message', 'Unknown error')}" elif intent_type == "rename": if result_status == "RENAMED": return f"{prefix} Renamed to **{result.get('new_summary', '')}**" elif result_status.startswith("DRY_RUN"): return f"{prefix} Would rename to **{result.get('new_summary', '')}**" else: return f"❌ Error renaming: {result.get('message', 'Unknown error')}" elif intent_type == "reject": summary = intent.get("summary", "") scope = intent.get("scope", "event") scope_label = "permanently" if scope == "all" else "for this event" return f"{prefix} Rejected **{summary}** {scope_label}" elif intent_type == "remind": if result_status == "CREATED": summary = result.get("summary", intent.get("summary")) date = result.get("date", intent.get("date", "")) return f"{prefix} Reminder set: **{summary}** — {date}" elif result_status.startswith("DRY_RUN"): summary = result.get("summary", intent.get("summary")) date = result.get("date", intent.get("date", "")) return f"{prefix} Would set reminder: **{summary}** — {date}" else: return f"❌ Error setting reminder: {result.get('message', 'Unknown error')}" elif intent_type == "question": if result_status == "ANSWERED": answer_text = result.get("answer", "") return answer_text if answer_text else "I couldn't find that in the emails I've processed." elif result_status == "error": return f"❌ Error: {result.get('message', 'Unknown error')}" return "I don't see that in the emails I've processed." elif intent_type == "chatter": # Bot should remain silent for chatter return "" return f"â„šī¸ {intent_type}: {result_status}" def _friendly_datetime(dt_str: str) -> str: """Convert ISO datetime to a human-friendly format.""" if not dt_str: return "TBD" try: from datetime import datetime from zoneinfo import ZoneInfo from family_assistant.config import CHICAGO_TZ dt = datetime.fromisoformat(dt_str) if dt.tzinfo is None: dt = dt.replace(tzinfo=CHICAGO_TZ) return dt.astimezone(CHICAGO_TZ).strftime("%a %b %d, %-I:%M %p") except (ValueError, TypeError): return dt_str