Files
Synculous-2/bot/commands/tasks.py
chelsea bebc609091 Add one-off tasks/appointments feature
- 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>
2026-02-19 16:43:42 -06:00

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)