""" Tasks command handler - bot-side hooks for one-off tasks/appointments """ from datetime import datetime, timedelta from bot.command_registry import register_module import ai.parser as ai_parser def _resolve_datetime(dt_str, user_now): """Resolve a natural language date string to an ISO datetime string. Handles: ISO strings, 'today', 'tomorrow', day names, plus 'HH:MM' time.""" if not dt_str: return None dt_str = dt_str.lower().strip() # Try direct ISO parse first for fmt in ("%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M", "%Y-%m-%d"): try: return datetime.strptime(dt_str, fmt).isoformat(timespec="minutes") except ValueError: pass # Split into date word + time word time_part = None date_word = dt_str # Try to extract HH:MM from the end parts = dt_str.rsplit(" ", 1) if len(parts) == 2: possible_time = parts[1] if ":" in possible_time: try: h, m = [int(x) for x in possible_time.split(":")] time_part = (h, m) date_word = parts[0] except ValueError: pass else: # Might be just a bare hour like "14" try: h = int(possible_time) if 0 <= h <= 23: time_part = (h, 0) date_word = parts[0] except ValueError: pass # Resolve the date part target = user_now.date() if "tomorrow" in date_word: target = target + timedelta(days=1) elif "today" in date_word or not date_word: pass else: day_map = { "monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6, } for name, num in day_map.items(): if name in date_word: days_ahead = (num - target.weekday()) % 7 if days_ahead == 0: days_ahead = 7 # next occurrence target = target + timedelta(days=days_ahead) break if time_part: return datetime( target.year, target.month, target.day, time_part[0], time_part[1] ).isoformat(timespec="minutes") return datetime(target.year, target.month, target.day, 9, 0).isoformat(timespec="minutes") def _find_task_by_title(token, title): """Find a pending task by fuzzy title match. Returns task dict or None.""" resp, status = api_request("get", "/api/tasks", token) if status != 200: return None tasks = resp if isinstance(resp, list) else [] title_lower = title.lower() # Exact match first for t in tasks: if t.get("title", "").lower() == title_lower: return t # Partial match for t in tasks: if title_lower in t.get("title", "").lower() or t.get("title", "").lower() in title_lower: return t return None def _format_datetime(dt_str): """Format ISO datetime string for display.""" try: dt = datetime.fromisoformat(str(dt_str)) return dt.strftime("%a %b %-d at %-I:%M %p") except Exception: return str(dt_str) async def handle_task(message, session, parsed): action = parsed.get("action", "unknown") token = session["token"] # Get user's current time for datetime resolution from core import tz as tz_mod user_uuid = session.get("user_uuid") try: user_now = tz_mod.user_now_for(user_uuid) except Exception: user_now = datetime.utcnow() if action == "add": title = parsed.get("title", "").strip() dt_str = parsed.get("datetime", "") reminder_min = parsed.get("reminder_minutes_before", 15) if not title: await message.channel.send("What's the title of the task?") return resolved_dt = _resolve_datetime(dt_str, user_now) if dt_str else None if not resolved_dt: await message.channel.send( "When is this task? Tell me the date and time (e.g. 'tomorrow at 3pm', 'friday 14:00')." ) return task_data = { "title": title, "scheduled_datetime": resolved_dt, "reminder_minutes_before": int(reminder_min), } description = parsed.get("description", "") if description: task_data["description"] = description resp, status = api_request("post", "/api/tasks", token, task_data) if status == 201: reminder_text = f" (reminder {reminder_min} min before)" if int(reminder_min) > 0 else "" await message.channel.send( f"✅ Added **{title}** for {_format_datetime(resolved_dt)}{reminder_text}" ) else: await message.channel.send(f"Error: {resp.get('error', 'Failed to create task')}") elif action == "list": resp, status = api_request("get", "/api/tasks", token) if status == 200: tasks = resp if isinstance(resp, list) else [] if not tasks: await message.channel.send("No pending tasks. Add one with 'remind me about...'") else: lines = [] for t in tasks: status_emoji = "🔔" if t.get("status") == "notified" else "📋" lines.append(f"{status_emoji} **{t['title']}** — {_format_datetime(t.get('scheduled_datetime', ''))}") await message.channel.send("**Your upcoming tasks:**\n" + "\n".join(lines)) else: await message.channel.send(f"Error: {resp.get('error', 'Failed to fetch tasks')}") elif action in ("done", "complete"): title = parsed.get("title", "").strip() if not title: await message.channel.send("Which task is done?") return task = _find_task_by_title(token, title) if not task: await message.channel.send(f"Couldn't find a task matching '{title}'.") return resp, status = api_request("patch", f"/api/tasks/{task['id']}", token, {"status": "completed"}) if status == 200: await message.channel.send(f"✅ Marked **{task['title']}** as done!") else: await message.channel.send(f"Error: {resp.get('error', 'Failed to update task')}") elif action == "cancel": title = parsed.get("title", "").strip() if not title: await message.channel.send("Which task should I cancel?") return task = _find_task_by_title(token, title) if not task: await message.channel.send(f"Couldn't find a task matching '{title}'.") return resp, status = api_request("patch", f"/api/tasks/{task['id']}", token, {"status": "cancelled"}) if status == 200: await message.channel.send(f"❌ Cancelled **{task['title']}**.") else: await message.channel.send(f"Error: {resp.get('error', 'Failed to cancel task')}") elif action == "delete": title = parsed.get("title", "").strip() if not title: await message.channel.send("Which task should I delete?") return task = _find_task_by_title(token, title) if not task: await message.channel.send(f"Couldn't find a task matching '{title}'.") return resp, status = api_request("delete", f"/api/tasks/{task['id']}", token) if status == 200: await message.channel.send(f"🗑️ Deleted **{task['title']}**.") else: await message.channel.send(f"Error: {resp.get('error', 'Failed to delete task')}") else: await message.channel.send( f"Unknown action: {action}. Try: add, list, done, cancel." ) def api_request(method, endpoint, token, data=None): import requests import os API_URL = os.getenv("API_URL", "http://app:5000") url = f"{API_URL}{endpoint}" headers = {"Content-Type": "application/json", "Authorization": f"Bearer {token}"} try: resp = getattr(requests, method)(url, headers=headers, json=data, timeout=10) try: return resp.json(), resp.status_code except ValueError: return {}, resp.status_code except requests.RequestException: return {"error": "API unavailable"}, 503 def validate_task_json(data): errors = [] if not isinstance(data, dict): return ["Response must be a JSON object"] if "error" in data: return [] if "action" not in data: errors.append("Missing required field: action") return errors register_module("task", handle_task) ai_parser.register_validator("task", validate_task_json)