- DB: tasks table with scheduled_datetime, reminder_minutes_before, advance_notified, status - API: CRUD routes GET/POST /api/tasks, PATCH/DELETE /api/tasks/<id> - Scheduler: check_task_reminders() fires advance + at-time notifications, tracks advance_notified to prevent double-fire - Bot: handle_task() with add/list/done/cancel/delete actions + datetime resolution helper - AI: task interaction type + examples added to command_parser - Web: task list page with overdue/notified color coding + new task form with datetime-local picker - Nav: replaced Templates with Tasks in bottom nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
243 lines
8.6 KiB
Python
243 lines
8.6 KiB
Python
"""
|
|
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)
|