📄 intent_router.py 8,470 bytes Apr 25, 2026 📋 Raw

"""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 icarus.core.intent_engine import parse_intent, execute_intent
from icarus.core.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 icarus.core.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