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>
This commit is contained in:
242
bot/commands/tasks.py
Normal file
242
bot/commands/tasks.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user