📄 cli.py 21,998 bytes Apr 27, 2026 📋 Raw

"""CLI entry point for the Family Assistant package."""

import argparse
import json
import sys
from datetime import datetime, timedelta

from family_assistant.config import CHICAGO_TZ
from family_assistant.email_fetcher import fetch_unread
from family_assistant.calendar_sync import (
get_calendar_service,
list_upcoming_events,
create_event,
_delete_event,
)
from family_assistant.conflict_engine import (
detect_conflicts,
resolve_all_conflicts,
execute_resolution,
)
from family_assistant.pipeline import process_emails
from family_assistant.setup import run_setup
from family_assistant.tests.test_qa import run_qa_tests

def main():
parser = argparse.ArgumentParser(description="Family Calendar Manager")
parser.add_argument(
"command",
choices=[
"check-email", "upcoming", "process", "test", "qa", "conflicts", "resolve", "handle",
"setup", "conflict-notify", "conflict-callback", "reject", "rejections", "intent",
"chat-intent", "click", "maintenance", "maint-done", "remind", "inbound-hook",
"slot-callback", "slot-cache-cleanup", "add-recurring",
"brain-query", "brain-stats", "brain-purge", "brain-backfill",
"resolve-location", "location-cache", "seed-locations",
"dropbox", "drive-setup", "reset-circuit",
],
help="Command to run",
)
parser.add_argument("--hours", type=int, default=48, help="Hours ahead for upcoming events")
parser.add_argument("--dry-run", action="store_true", help="Process emails without creating events")
parser.add_argument("--no-notify", action="store_true", help="Skip Telegram notifications")
parser.add_argument("--quiet", action="store_true", help="Only send DM alerts, no family group messages")
parser.add_argument("--json", action="store_true", help="Output raw JSON")
parser.add_argument("--option", type=int, default=0, help="Option number for handle command (1-based)")
parser.add_argument("--callback-data", type=str, default=None, help="Callback data from Telegram button (format: resolve|id|index)")
parser.add_argument("--event-summary", type=str, default=None, help="Event summary for reject command")
parser.add_argument("--reject-message", type=str, default=None, help="Natural language rejection reason")
parser.add_argument("--message", type=str, default=None, help="Natural language message for intent command")
parser.add_argument("--reply", action="store_true", help="Indicate the message is a reply to the bot (for chat-intent)")
parser.add_argument("--quoted", type=str, default=None, help="Text of the message being replied to (for context)")
parser.add_argument("--non-interactive", action="store_true", help="Non-interactive setup (create templates)")
parser.add_argument("--url", type=str, default=None, help="URL to click/fetch (for click command)")
parser.add_argument("--context", type=str, default="", help="Context summary for the URL (for click command)")
parser.add_argument("--question", type=str, default=None, help="Question for brain-query command")
parser.add_argument("--top-k", type=int, default=3, help="Number of results to retrieve for brain-query")
parser.add_argument("--days", type=int, default=30, help="Days to look back for brain-backfill (default: 30)")
parser.add_argument("--file", type=str, default=None, help="File path for dropbox command")
parser.add_argument("--test", type=str, default=None, help="Dry-run test: extract without uploading (dropbox)")
parser.add_argument("--message-id", type=str, default="", help="Telegram message ID for dropbox reaction")

args = parser.parse_args()

if args.command == "check-email":
    result = fetch_unread()
    print(json.dumps(result, indent=2, default=str))

elif args.command == "upcoming":
    events = list_upcoming_events(hours=args.hours)
    print(json.dumps(events, indent=2, default=str))

elif args.command == "process":
    result = process_emails(dry_run=args.dry_run, notify=not args.no_notify, quiet=args.quiet)
    print(json.dumps(result, indent=2, default=str))

elif args.command == "test":
    start = datetime(2026, 4, 16, 10, 0, tzinfo=CHICAGO_TZ)
    end = start + timedelta(hours=1)
    created = create_event(
        "Test: Socrates Calendar Integration",
        start,
        end,
        description="Automated test event from OpenClaw",
    )
    print(f"Created: {created['id']} — {created['summary']}")
    _delete_event(created["id"])
    print("Deleted test event ✅")

elif args.command == "conflicts":
    conflicts = detect_conflicts(hours=args.hours)
    if args.json:
        print(json.dumps(conflicts, indent=2, default=str))
    elif conflicts:
        print(f"⚠️  {len(conflicts)} scheduling conflict(s) in the next {args.hours}h:")
        for c in conflicts:
            e1 = c["event1"]
            e2 = c["event2"]
            print(f"  • {e1['summary']} ({e1['start']}) conflicts with {e2['summary']} ({e2['start']}) — {c['overlap_minutes']}min overlap")
    else:
        print(f"✅ No scheduling conflicts in the next {args.hours}h")

elif args.command == "resolve":
    resolutions = resolve_all_conflicts(hours=args.hours)
    if args.json:
        print(json.dumps(resolutions, indent=2, default=str))
    elif not resolutions:
        print(f"✅ No scheduling conflicts to resolve in the next {args.hours}h")
    else:
        print(f"⚠️  {len(resolutions)} conflict(s) found — resolving...\n")
        for r in resolutions:
            c = r["conflict"]
            res = r["resolution"]
            print(f"  Conflict: {c['event1']['summary']} vs {c['event2']['summary']}")
            print(f"  {res.get('message', 'No suggestion available')}")
            options = res.get("options", [])
            if options:
                for i, opt in enumerate(options, 1):
                    print(f"    {i}. [{opt.get('action','?').upper()}] {opt.get('description', '')}")
            print()

elif args.command == "handle":
    if args.option < 1:
        print("Usage: family_calendar.py handle --option N [--hours 168] [--dry-run]")
        print("First run 'resolve' to see options, then 'handle --option N' to execute one.")
        sys.exit(1)

    resolutions = resolve_all_conflicts(hours=args.hours)
    if not resolutions:
        print(f"✅ No scheduling conflicts to handle in the next {args.hours}h")
        sys.exit(0)

    # Handle the first conflict with the chosen option
    # (future: support multiple conflicts by index)
    r = resolutions[0]
    conflict = r["conflict"]
    resolution = r["resolution"]
    options = resolution.get("options", [])

    if args.option > len(options):
        print(f"❌ Invalid option {args.option}. Only {len(options)} available:")
        for i, opt in enumerate(options, 1):
            print(f"  {i}. [{opt.get('action','?').upper()}] {opt.get('description', '')}")
        sys.exit(1)

    result = execute_resolution(conflict, resolution, args.option, dry_run=args.dry_run)
    if args.json:
        print(json.dumps(result, indent=2, default=str))
    else:
        status = result.get("status", "?")
        msg = result.get("message", "No details")
        if status == "ERROR":
            print(f"❌ {msg}")
        elif status == "DRY_RUN":
            print(f"🔍 [DRY RUN] {msg}")
        else:
            print(f"✅ {msg}")

elif args.command == "conflict-notify":
    from family_assistant.conflict_notify import scan_and_notify
    notified = scan_and_notify()
    if not notified:
        print("No conflicts found")
    else:
        print(f"Notified about {len(notified)} conflict(s): {notified}")

elif args.command == "conflict-callback":
    if not args.callback_data:
        print("Usage: family_assistant conflict-callback --callback-data 'resolve|abc123|0'")
        sys.exit(1)
    # Route to the correct handler based on callback prefix
    if args.callback_data.startswith("slot|"):
        from family_assistant.slot_handler import handle_slot_callback
        result = handle_slot_callback(args.callback_data, dry_run=args.dry_run)
    else:
        from family_assistant.conflict_notify import handle_callback
        result = handle_callback(args.callback_data, dry_run=args.dry_run)
    print(json.dumps(result, indent=2, default=str))

elif args.command == "reject":
    from family_assistant.rejection_engine import reject_event
    event_summary = args.event_summary if hasattr(args, 'event_summary') and args.event_summary else ""
    message = args.reject_message if hasattr(args, 'reject_message') and args.reject_message else "User rejected"
    if not event_summary:
        print("Usage: family_assistant reject <event_summary> <message>")
        print('Example: family_assistant reject "First Communion Mass" "we don\'t need that, ignore future"')
        sys.exit(1)
    result = reject_event(event_summary, message, delete_from_calendar=not args.dry_run)
    print(json.dumps(result, indent=2, default=str))

elif args.command == "rejections":
    from family_assistant.rejection_engine import get_rejection_rules
    rules = get_rejection_rules()
    if not rules:
        print("No rejection rules configured")
    else:
        print(f"{len(rules)} rejection rule(s):")
        for r in rules:
            print(f"  [{r.get('scope','?')}] {r['pattern']} — {r.get('reason','')}")

elif args.command == "intent":
    from family_assistant.intent_engine import parse_intent, execute_intent
    if not args.message:
        print("Usage: family_calendar.py intent --message 'change OT Session to April 20'")
        sys.exit(1)
    intent = parse_intent(args.message)
    print(f"Parsed intent: {json.dumps(intent, indent=2, default=str)}")
    result = execute_intent(intent, dry_run=args.dry_run)
    print(f"Result: {json.dumps(result, indent=2, default=str)}")

elif args.command == "chat-intent":
    from family_assistant.intent_router import process_group_message
    if not args.message:
        print("Usage: family_calendar.py chat-intent --message 'move it to the 21st' [--reply] [--quoted 'bot msg']")
        sys.exit(1)
    is_reply = args.reply if hasattr(args, 'reply') else False
    quoted = args.quoted if hasattr(args, 'quoted') else ""
    result = process_group_message(args.message, is_reply_to_bot=is_reply, quoted_message=quoted or "", dry_run=args.dry_run)
    print(json.dumps(result, indent=2, default=str))
    if result.get("notification"):
        print(f"\nNotification: {result['notification']}")

elif args.command == "maintenance":
    # DISABLED — maintenance tracking purged. Module preserved for Phase 6.
    print("⚠️ Maintenance tracking is currently disabled (pending Phase 6 redesign).")
    print("The maintenance_sentinel module is preserved for reference.")
    sys.exit(0)

elif args.command == "maint-done":
    # DISABLED — maintenance tracking purged.
    print("⚠️ Maintenance 'Done' callback is currently disabled (pending Phase 6 redesign).")
    sys.exit(0)

elif args.command == "click":
    from family_assistant.clicker import click_url, build_slot_buttons, hash_url
    url = args.url
    if not url:
        print("Usage: family_calendar.py click --url 'https://www.signupgenius.com/...' [--context 'Parent-Teacher Conferences']")
        sys.exit(1)
    result = click_url(url, context_summary=args.context, dry_run=args.dry_run)
    print(json.dumps(result, indent=2, default=str, ensure_ascii=False))
    if result.get("slots"):
        print(f"\nSlot buttons:")
        buttons = build_slot_buttons(result["slots"], hash_url(url))
        for row in buttons:
            for btn in row:
                print(f"  [{btn['text']}] → {btn['callback_data']}")

elif args.command == "remind":
    from family_assistant.intent_engine import execute_intent
    if not args.message:
        print("Usage: family_calendar.py remind --message 'kids wear red shirts tomorrow'")
        sys.exit(1)
    intent = {"type": "remind", "summary": args.message, "date": None}
    # Parse through intent engine so the LLM extracts date/who/description
    from family_assistant.intent_engine import parse_intent
    intent = parse_intent(f"remind me: {args.message}")
    if intent.get("type") != "remind":
        # If LLM didn't classify as remind, force it but use its date extraction
        if intent.get("type") == "none":
            print("Couldn't extract a date from that message. Try: remind --message 'kids wear red shirts tomorrow'")
            sys.exit(1)
        # If it classified as add, convert to remind
        if intent.get("type") == "add":
            intent["type"] = "remind"
            intent["date"] = intent.get("start", "")[:10]  # Extract date portion
    print(f"Parsed intent: {json.dumps(intent, indent=2, default=str)}")
    result = execute_intent(intent, dry_run=args.dry_run)
    print(f"Result: {json.dumps(result, indent=2, default=str)}")

elif args.command == "inbound-hook":
    from family_assistant.inbound_hook import process_inbound, send_notification_to_group
    if not args.message:
        print("Usage: family_calendar.py inbound-hook --message 'Socrates, remind us...' [--reply] [--quoted 'bot msg']")
        sys.exit(1)
    is_reply = args.reply if hasattr(args, 'reply') else False
    quoted = args.quoted if hasattr(args, 'quoted') else ""
    result = process_inbound(
        message=args.message,
        is_reply_to_bot=is_reply,
        quoted_text=quoted or "",
        dry_run=args.dry_run,
    )
    if result is None:
        print(json.dumps({"status": "not_triggered", "reason": "no_wake_word_or_reply"}))
    else:
        print(json.dumps(result, indent=2, default=str))
        if result.get("notification") and not args.dry_run:
            sent = send_notification_to_group(result["notification"])
            print(f"Notification sent: {sent}")

elif args.command == "slot-callback":
    from family_assistant.slot_handler import handle_slot_callback
    if not args.callback_data:
        print("Usage: family_calendar.py slot-callback --callback-data 'slot|<url_hash>|<index>'")
        sys.exit(1)
    result = handle_slot_callback(args.callback_data, dry_run=args.dry_run)
    print(json.dumps(result, indent=2, default=str, ensure_ascii=False))
    if result.get("confirmation") and not args.dry_run:
        from family_assistant.hermes import push_pipeline_results
        # Send confirmation to the user (DM or group depending on context)
        print(f"\nConfirmation: {result['confirmation']}")

elif args.command == "slot-cache-cleanup":
    from family_assistant.slot_handler import cleanup_old_cache
    removed = cleanup_old_cache()
    print(f"Removed {removed} expired cache entries")

elif args.command == "add-recurring":
    from family_assistant.calendar_sync import create_recurring_event
    from family_assistant.rrule_builder import build_rrule, validate_recurrence
    if not args.message:
        print("Usage: family_calendar.py add-recurring --message 'OT Session every Tuesday 4pm for 10 weeks'")
        sys.exit(1)
    # Parse the message through the intent engine to extract recurrence + datetime
    from family_assistant.intent_engine import parse_intent
    intent = parse_intent(f"add recurring: {args.message}")
    if intent.get("type") not in ("add", "remind"):
        print(f"Couldn't parse as recurring event: {intent}")
        sys.exit(1)
    # Accept both 'recurrence' (correct) and 'recurring' (legacy)
    recurrence = intent.get("recurrence") or intent.get("recurring")
    # If the intent engine returned a non-standard 'recurring' key, normalize it
    if recurrence and intent.get("is_recurring") is None:
        intent["is_recurring"] = True
    if not recurrence:
        print(f"No recurrence pattern found in message. Intent: {intent}")
        sys.exit(1)
    errors = validate_recurrence(recurrence)
    if errors:
        print(f"Invalid recurrence: {'; '.join(errors)}")
        sys.exit(1)
    rrule = build_rrule(recurrence)
    print(f"RRULE: {rrule}")
    print(f"Intent: {json.dumps(intent, indent=2, default=str)}")
    if not args.dry_run:
        start_dt = intent.get("start")
        end_dt = intent.get("end")
        if not start_dt or not end_dt:
            print("Missing start/end datetime")
            sys.exit(1)
        try:
            event = create_recurring_event(
                summary=intent.get("summary", "Recurring Event"),
                start_dt=start_dt,
                end_dt=end_dt,
                recurrence=recurrence,
                description=intent.get("description", ""),
                location=intent.get("location", ""),
            )
            print(f"Created: {event.get('summary')} (ID: {event['id']})")
            print(f"Link: {event.get('htmlLink', '')}")
        except Exception as e:
            print(f"Error: {e}")
    else:
        print("(dry run — no calendar event created)")

elif args.command == "brain-query":
    from family_assistant.family_brain import answer
    if not args.question:
        print("Usage: family-assistant brain-query --question 'What do the kids need for the field trip?'")
        sys.exit(1)
    result = answer(args.question, top_k=args.top_k)
    print(result.get("answer", "No answer"))
    if args.json:
        print("\n--- Sources ---")
        for src in result.get("sources", []):
            print(f"  [{src.get('score', 0)}] {src.get('subject', '?')} ({src.get('date', '?')})")

elif args.command == "brain-stats":
    from family_assistant.family_brain import stats
    s = stats()
    print(f"Family Brain Stats:")
    print(f"  DB Path: {s.get('db_path')}")
    print(f"  Collection: {s.get('collection')}")
    print(f"  Documents: {s.get('count', 0)}")

elif args.command == "brain-purge":
    from family_assistant.family_brain import purge_old
    result = purge_old(months=12)
    print(f"Purged {result.get('purged', 0)} old documents")
    print(f"Remaining: {result.get('remaining', 0)}")

elif args.command == "brain-backfill":
    from family_assistant.pipeline import backfill_brain
    result = backfill_brain(days=args.days)
    print(f"\nBackfill Results:")
    print(f"  Emails scanned: {result.get('emails_scanned', 0)}")
    print(f"  Appointment emails ingested: {result.get('ingested_emails', 0)}")
    print(f"  Newsletters ingested: {result.get('ingested_newsletters', 0)}")
    if result.get('errors'):
        print(f"  Errors: {len(result['errors'])}")
        for err in result['errors'][:5]:
            print(f"    - {err}")

elif args.command == "resolve-location":
    from family_assistant.location_cache import resolve, format_location_enrichment
    if not args.message:
        print("Usage: family-assistant resolve-location --message 'Aurora BayCare Green Bay'")
        sys.exit(1)
    result = resolve(args.message, force_refresh=args.dry_run is False)
    if result:
        print(format_location_enrichment(result))
        if args.json:
            print(json.dumps(result, indent=2, default=str, ensure_ascii=False))
    else:
        print(f"❌ Could not resolve: {args.message}")

elif args.command == "location-cache":
    from family_assistant.location_cache import stats, purge_expired
    s = stats()
    print(f"Location Cache:")
    print(f"  Entries: {s['total_entries']}")
    print(f"  With travel time: {s['with_travel_time']}")
    print(f"  Expired: {s['expired_entries']}")
    if s.get('cached_locations'):
        print(f"  Known locations:")
        for name in s['cached_locations']:
            print(f"    • {name}")
    if args.json:
        print(json.dumps(s, indent=2, default=str))

elif args.command == "seed-locations":
    from family_assistant.location_cache import seed_known_locations
    results = seed_known_locations()
    if not results:
        print("No locations to seed. Add 'known_locations' to family.yaml.")
    else:
        print(f"Seeded {len([r for _, r in results if r])} / {len(results)} locations")

elif args.command == "dropbox":
    from family_assistant.document_sorter import cli_process
    cli_process(args)

elif args.command == "drive-setup":
    from family_assistant.document_sorter import cli_drive_setup
    cli_drive_setup(args)

elif args.command == "reset-circuit":
    from family_assistant.pipeline import reset_circuit_breaker
    reset_circuit_breaker()

elif args.command == "setup":
    run_setup(non_interactive=args.non_interactive)

elif args.command == "qa":
    run_qa_tests()

if name == 'main':
main()