"""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