Build reusable bot framework
This commit is contained in:
1
modules/__init__.py
Normal file
1
modules/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Feature packages discovered by core.registry."""
|
||||
25
modules/reminders/__init__.py
Normal file
25
modules/reminders/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Reference reminders feature module."""
|
||||
|
||||
from modules.reminders.commands import handleReminder, validateReminder
|
||||
from modules.reminders.prompts import REMINDER_PROMPT
|
||||
from modules.reminders.routes import registerRoutes
|
||||
from modules.reminders.service import JOB_TYPE, runReminderJob
|
||||
|
||||
|
||||
def register(registry):
|
||||
registry.describe("Create, list, cancel, and deliver scheduled reminders")
|
||||
registry.register_command(
|
||||
"reminder",
|
||||
handleReminder,
|
||||
prompt=REMINDER_PROMPT,
|
||||
validator=validateReminder,
|
||||
description="Create, list, cancel, or configure reminders",
|
||||
help_text=[
|
||||
"remind me tomorrow at 9 AM to call the dentist",
|
||||
"list my reminders",
|
||||
"cancel a reminder by its ID",
|
||||
"set my timezone to America/Chicago",
|
||||
],
|
||||
)
|
||||
registry.register_routes(registerRoutes)
|
||||
registry.register_job(JOB_TYPE, runReminderJob)
|
||||
109
modules/reminders/commands.py
Normal file
109
modules/reminders/commands.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Discord-facing reminder command handler and parser validation."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from core import users
|
||||
from modules.reminders import service
|
||||
|
||||
|
||||
VALID_ACTIONS = {"create", "list", "cancel", "set_timezone"}
|
||||
|
||||
|
||||
def validateReminder(data):
|
||||
if not isinstance(data, dict):
|
||||
return ["Response must be a JSON object"]
|
||||
if data.get("needs_clarification"):
|
||||
return []
|
||||
|
||||
errors = []
|
||||
action = data.get("action")
|
||||
if action not in VALID_ACTIONS:
|
||||
errors.append("action must be create, list, cancel, or set_timezone")
|
||||
return errors
|
||||
|
||||
if action == "create":
|
||||
if not isinstance(data.get("message"), str) or not data["message"].strip():
|
||||
errors.append("create requires a reminder message")
|
||||
try:
|
||||
runAt = datetime.fromisoformat(
|
||||
str(data.get("run_at", "")).replace("Z", "+00:00")
|
||||
)
|
||||
if runAt.tzinfo is None:
|
||||
errors.append("run_at must include a timezone offset")
|
||||
elif runAt.astimezone(timezone.utc) <= datetime.now(timezone.utc):
|
||||
errors.append("run_at must be in the future")
|
||||
except ValueError:
|
||||
errors.append("create requires an ISO-8601 run_at")
|
||||
try:
|
||||
service.normalizeRecurrence(data.get("recurrence"))
|
||||
except ValueError as error:
|
||||
errors.append(str(error))
|
||||
elif action == "cancel" and not data.get("reminder_id"):
|
||||
errors.append("cancel requires reminder_id from the reminder list")
|
||||
elif action == "set_timezone" and not users.isValidTimezone(data.get("timezone")):
|
||||
errors.append("set_timezone requires a valid IANA timezone")
|
||||
return errors
|
||||
|
||||
|
||||
async def handleReminder(context, parsed):
|
||||
action = parsed["action"]
|
||||
if action == "create":
|
||||
result, status = await context.api.request(
|
||||
"post",
|
||||
"/api/reminders",
|
||||
{
|
||||
"message": parsed["message"],
|
||||
"run_at": parsed["run_at"],
|
||||
"recurrence": parsed.get("recurrence"),
|
||||
},
|
||||
)
|
||||
if status == 201:
|
||||
recurrence = " (recurring)" if result.get("recurrence") else ""
|
||||
await context.reply(
|
||||
f"Reminder set for **{result['next_run_at']}**{recurrence}: "
|
||||
f"{result['message']}"
|
||||
)
|
||||
else:
|
||||
await context.reply(_errorMessage(result, "I couldn't create that reminder."))
|
||||
return
|
||||
|
||||
if action == "list":
|
||||
result, status = await context.api.request("get", "/api/reminders")
|
||||
if status != 200:
|
||||
await context.reply(_errorMessage(result, "I couldn't list reminders."))
|
||||
return
|
||||
reminders = result.get("reminders", [])
|
||||
if not reminders:
|
||||
await context.reply("You have no active reminders.")
|
||||
return
|
||||
lines = [
|
||||
f"- `{item['id']}` — {item['next_run_at']}: {item['message']}"
|
||||
for item in reminders
|
||||
]
|
||||
await context.reply("**Active reminders:**\n" + "\n".join(lines))
|
||||
return
|
||||
|
||||
if action == "cancel":
|
||||
result, status = await context.api.request(
|
||||
"delete", f"/api/reminders/{parsed['reminder_id']}"
|
||||
)
|
||||
if status == 200:
|
||||
await context.reply(f"Cancelled reminder: {result['message']}")
|
||||
else:
|
||||
await context.reply(_errorMessage(result, "I couldn't cancel that reminder."))
|
||||
return
|
||||
|
||||
result, status = await context.api.request(
|
||||
"put", "/api/user/me/timezone", {"timezone": parsed["timezone"]}
|
||||
)
|
||||
if status == 200:
|
||||
context.api.timezone = result["timezone"]
|
||||
context.timezone = result["timezone"]
|
||||
await context.reply(f"Your timezone is now **{result['timezone']}**.")
|
||||
else:
|
||||
await context.reply(_errorMessage(result, "I couldn't update your timezone."))
|
||||
|
||||
|
||||
def _errorMessage(result, fallback):
|
||||
detail = result.get("error") if isinstance(result, dict) else None
|
||||
return f"{fallback} {detail}" if detail else fallback
|
||||
21
modules/reminders/migrations/0001_reminders.sql
Normal file
21
modules/reminders/migrations/0001_reminders.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS reminders (
|
||||
id UUID PRIMARY KEY,
|
||||
user_uuid UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
message TEXT NOT NULL,
|
||||
timezone VARCHAR(64) NOT NULL,
|
||||
recurrence JSONB,
|
||||
next_run_at TIMESTAMPTZ NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
cancelled_at TIMESTAMPTZ,
|
||||
CONSTRAINT reminders_status_check
|
||||
CHECK (status IN ('active', 'completed', 'cancelled')),
|
||||
CONSTRAINT reminders_recurrence_check
|
||||
CHECK (recurrence IS NULL OR jsonb_typeof(recurrence) = 'object')
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS reminders_user_active_idx
|
||||
ON reminders (user_uuid, next_run_at)
|
||||
WHERE status = 'active';
|
||||
18
modules/reminders/prompts.py
Normal file
18
modules/reminders/prompts.py
Normal file
@@ -0,0 +1,18 @@
|
||||
REMINDER_PROMPT = {
|
||||
"system": (
|
||||
"You parse reminder commands. Return only a JSON object and do not "
|
||||
"invent missing dates, messages, timezones, or reminder IDs."
|
||||
),
|
||||
"user_template": (
|
||||
"User timezone: {timezone}\n"
|
||||
"Current UTC time: {current_time}\n"
|
||||
"Conversation context:\n{history_context}\n\n"
|
||||
"User message: \"{user_input}\"\n\n"
|
||||
"Return an action: create, list, cancel, or set_timezone. "
|
||||
"For create include message, an ISO-8601 run_at with UTC offset, and "
|
||||
"recurrence as null or an object with frequency daily/weekly and "
|
||||
"optional interval. For cancel include reminder_id. For set_timezone "
|
||||
"include an IANA timezone. If required information is missing, include "
|
||||
"needs_clarification instead."
|
||||
),
|
||||
}
|
||||
71
modules/reminders/routes.py
Normal file
71
modules/reminders/routes.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Authenticated reminder API routes."""
|
||||
|
||||
from datetime import datetime
|
||||
import flask
|
||||
import uuid
|
||||
|
||||
from api.security import jsonObject, requireUser
|
||||
from modules.reminders import service
|
||||
|
||||
|
||||
def registerRoutes(app):
|
||||
@app.route("/api/reminders", methods=["GET"])
|
||||
@requireUser()
|
||||
def api_listReminders():
|
||||
includeFinished = flask.request.args.get("include_finished", "false").lower()
|
||||
includeFinished = includeFinished in {"1", "true", "yes"}
|
||||
reminders = service.listReminders(
|
||||
flask.g.user_uuid,
|
||||
includeFinished=includeFinished,
|
||||
limit=flask.request.args.get("limit", 50),
|
||||
)
|
||||
return flask.jsonify(
|
||||
{"reminders": [_serializeReminder(item) for item in reminders]}
|
||||
), 200
|
||||
|
||||
@app.route("/api/reminders", methods=["POST"])
|
||||
@requireUser()
|
||||
def api_createReminder():
|
||||
data = jsonObject()
|
||||
if data is None:
|
||||
return flask.jsonify({"error": "JSON object required"}), 400
|
||||
try:
|
||||
reminder = service.createReminder(
|
||||
flask.g.user_uuid,
|
||||
data.get("message"),
|
||||
data.get("run_at"),
|
||||
data.get("timezone") or _userTimezone(),
|
||||
recurrence=data.get("recurrence"),
|
||||
)
|
||||
except ValueError as error:
|
||||
return flask.jsonify({"error": str(error)}), 400
|
||||
return flask.jsonify(_serializeReminder(reminder)), 201
|
||||
|
||||
@app.route("/api/reminders/<reminderID>", methods=["DELETE"])
|
||||
@requireUser()
|
||||
def api_cancelReminder(reminderID):
|
||||
try:
|
||||
reminder = service.cancelReminder(flask.g.user_uuid, reminderID)
|
||||
except (TypeError, ValueError):
|
||||
reminder = None
|
||||
if not reminder:
|
||||
return flask.jsonify({"error": "active reminder not found"}), 404
|
||||
return flask.jsonify(_serializeReminder(reminder)), 200
|
||||
|
||||
|
||||
def _userTimezone():
|
||||
from core import users
|
||||
|
||||
return users.getUserTimezone(flask.g.user_uuid) or "UTC"
|
||||
|
||||
|
||||
def _serializeReminder(reminder):
|
||||
serialized = {}
|
||||
for key, value in reminder.items():
|
||||
if isinstance(value, datetime):
|
||||
serialized[key] = value.isoformat()
|
||||
elif isinstance(value, uuid.UUID):
|
||||
serialized[key] = str(value)
|
||||
else:
|
||||
serialized[key] = value
|
||||
return serialized
|
||||
246
modules/reminders/service.py
Normal file
246
modules/reminders/service.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Database and scheduling operations for the reminders feature."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import uuid
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from psycopg2.extras import Json
|
||||
|
||||
from core import jobs, outbox, postgres, users
|
||||
|
||||
|
||||
JOB_TYPE = "reminders.deliver"
|
||||
CHANNEL = "discord_dm"
|
||||
|
||||
|
||||
def _asUtc(value, field="run_at"):
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{field} must be an ISO-8601 datetime") from error
|
||||
if not isinstance(value, datetime) or value.tzinfo is None:
|
||||
raise ValueError(f"{field} must include a timezone offset")
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def normalizeRecurrence(recurrence):
|
||||
if recurrence in (None, False, "none", "once"):
|
||||
return None
|
||||
if isinstance(recurrence, str):
|
||||
recurrence = {"frequency": recurrence}
|
||||
if not isinstance(recurrence, dict):
|
||||
raise ValueError("recurrence must be null or an object")
|
||||
|
||||
frequency = str(recurrence.get("frequency", "")).strip().lower()
|
||||
if frequency not in {"daily", "weekly"}:
|
||||
raise ValueError("recurrence frequency must be daily or weekly")
|
||||
try:
|
||||
interval = int(recurrence.get("interval", 1))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError("recurrence interval must be a number") from error
|
||||
if interval < 1 or interval > 365:
|
||||
raise ValueError("recurrence interval must be between 1 and 365")
|
||||
return {"frequency": frequency, "interval": interval}
|
||||
|
||||
|
||||
def _jobKey(reminderID, scheduledFor):
|
||||
return f"reminder:{reminderID}:{scheduledFor.isoformat()}"
|
||||
|
||||
|
||||
def createReminder(userUUID, message, runAt, timezoneName, recurrence=None):
|
||||
if not isinstance(message, str) or not message.strip():
|
||||
raise ValueError("reminder message is required")
|
||||
message = message.strip()
|
||||
if len(message) > 1800:
|
||||
raise ValueError("reminder message must be at most 1800 characters")
|
||||
timezoneName = users.normalizeTimezone(timezoneName)
|
||||
runAt = _asUtc(runAt)
|
||||
if runAt <= datetime.now(timezone.utc):
|
||||
raise ValueError("reminder time must be in the future")
|
||||
recurrence = normalizeRecurrence(recurrence)
|
||||
reminderID = str(uuid.uuid4())
|
||||
|
||||
with postgres.get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO reminders (
|
||||
id, user_uuid, message, timezone, recurrence, next_run_at
|
||||
) VALUES (
|
||||
%(id)s, %(user_uuid)s, %(message)s, %(timezone)s,
|
||||
%(recurrence)s, %(next_run_at)s
|
||||
)
|
||||
RETURNING *
|
||||
""",
|
||||
{
|
||||
"id": reminderID,
|
||||
"user_uuid": userUUID,
|
||||
"message": message,
|
||||
"timezone": timezoneName,
|
||||
"recurrence": Json(recurrence) if recurrence else None,
|
||||
"next_run_at": runAt,
|
||||
},
|
||||
)
|
||||
reminder = dict(cursor.fetchone())
|
||||
jobs.create_job(
|
||||
JOB_TYPE,
|
||||
{"reminder_id": reminderID, "scheduled_for": runAt.isoformat()},
|
||||
runAt,
|
||||
user_uuid=userUUID,
|
||||
idempotency_key=_jobKey(reminderID, runAt),
|
||||
cursor=cursor,
|
||||
)
|
||||
return reminder
|
||||
|
||||
|
||||
def listReminders(userUUID, includeFinished=False, limit=50):
|
||||
try:
|
||||
limit = min(max(int(limit), 1), 100)
|
||||
except (TypeError, ValueError):
|
||||
limit = 50
|
||||
statusClause = "" if includeFinished else "AND status = 'active'"
|
||||
return postgres.execute(
|
||||
f"""
|
||||
SELECT * FROM reminders
|
||||
WHERE user_uuid = %(user_uuid)s {statusClause}
|
||||
ORDER BY next_run_at, created_at
|
||||
LIMIT %(limit)s
|
||||
""",
|
||||
{"user_uuid": userUUID, "limit": limit},
|
||||
)
|
||||
|
||||
|
||||
def getReminder(userUUID, reminderID, cursor=None, forUpdate=False):
|
||||
try:
|
||||
uuid.UUID(str(reminderID))
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return None
|
||||
lock = " FOR UPDATE" if forUpdate else ""
|
||||
query = (
|
||||
"SELECT * FROM reminders WHERE id = %(id)s AND user_uuid = %(user_uuid)s"
|
||||
+ lock
|
||||
)
|
||||
if cursor is not None:
|
||||
cursor.execute(query, {"id": reminderID, "user_uuid": userUUID})
|
||||
record = cursor.fetchone()
|
||||
return dict(record) if record else None
|
||||
rows = postgres.execute(query, {"id": reminderID, "user_uuid": userUUID})
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
def cancelReminder(userUUID, reminderID):
|
||||
with postgres.get_cursor() as cursor:
|
||||
reminder = getReminder(userUUID, reminderID, cursor=cursor, forUpdate=True)
|
||||
if not reminder or reminder["status"] != "active":
|
||||
return None
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE reminders
|
||||
SET status = 'cancelled', cancelled_at = NOW(), updated_at = NOW()
|
||||
WHERE id = %(id)s
|
||||
RETURNING *
|
||||
""",
|
||||
{"id": reminderID},
|
||||
)
|
||||
cancelled = dict(cursor.fetchone())
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE scheduled_jobs
|
||||
SET status = 'cancelled', cancelled_at = NOW(), updated_at = NOW(),
|
||||
leased_by = NULL, lease_until = NULL
|
||||
WHERE user_uuid = %(user_uuid)s
|
||||
AND job_type = %(job_type)s
|
||||
AND payload->>'reminder_id' = %(reminder_id)s
|
||||
AND status IN ('pending', 'running')
|
||||
""",
|
||||
{
|
||||
"user_uuid": userUUID,
|
||||
"job_type": JOB_TYPE,
|
||||
"reminder_id": reminderID,
|
||||
},
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE outbound_messages
|
||||
SET status = 'cancelled', cancelled_at = NOW(), updated_at = NOW(),
|
||||
leased_by = NULL, lease_until = NULL
|
||||
WHERE user_uuid = %(user_uuid)s
|
||||
AND payload->>'reminder_id' = %(reminder_id)s
|
||||
AND status = 'pending'
|
||||
""",
|
||||
{"user_uuid": userUUID, "reminder_id": reminderID},
|
||||
)
|
||||
return cancelled
|
||||
|
||||
|
||||
def _nextRun(scheduledFor, recurrence, timezoneName, now=None):
|
||||
now = now or datetime.now(timezone.utc)
|
||||
localRun = _asUtc(scheduledFor, "scheduled_for").astimezone(
|
||||
ZoneInfo(timezoneName)
|
||||
)
|
||||
interval = recurrence.get("interval", 1)
|
||||
days = interval if recurrence["frequency"] == "daily" else interval * 7
|
||||
nextLocal = localRun + timedelta(days=days)
|
||||
while nextLocal.astimezone(timezone.utc) <= now:
|
||||
nextLocal += timedelta(days=days)
|
||||
return nextLocal.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def runReminderJob(job, workerID):
|
||||
payload = job.get("payload") or {}
|
||||
reminderID = payload.get("reminder_id")
|
||||
scheduledFor = _asUtc(payload.get("scheduled_for"), "scheduled_for")
|
||||
|
||||
with postgres.get_cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT * FROM reminders WHERE id = %s FOR UPDATE", (reminderID,)
|
||||
)
|
||||
record = cursor.fetchone()
|
||||
reminder = dict(record) if record else None
|
||||
if not reminder or reminder["status"] != "active":
|
||||
return jobs.complete_job(job["id"], workerID, cursor=cursor)
|
||||
|
||||
outbox.enqueue_message(
|
||||
reminder["user_uuid"],
|
||||
CHANNEL,
|
||||
{
|
||||
"content": f"Reminder: {reminder['message']}",
|
||||
"reminder_id": str(reminder["id"]),
|
||||
"scheduled_for": scheduledFor.isoformat(),
|
||||
},
|
||||
idempotency_key=_jobKey(reminder["id"], scheduledFor),
|
||||
cursor=cursor,
|
||||
)
|
||||
|
||||
recurrence = reminder.get("recurrence")
|
||||
if recurrence:
|
||||
nextRun = _nextRun(scheduledFor, recurrence, reminder["timezone"])
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE reminders
|
||||
SET next_run_at = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(nextRun, reminder["id"]),
|
||||
)
|
||||
jobs.create_job(
|
||||
JOB_TYPE,
|
||||
{
|
||||
"reminder_id": str(reminder["id"]),
|
||||
"scheduled_for": nextRun.isoformat(),
|
||||
},
|
||||
nextRun,
|
||||
user_uuid=reminder["user_uuid"],
|
||||
idempotency_key=_jobKey(reminder["id"], nextRun),
|
||||
cursor=cursor,
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE reminders
|
||||
SET status = 'completed', completed_at = NOW(), updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(reminder["id"],),
|
||||
)
|
||||
return jobs.complete_job(job["id"], workerID, cursor=cursor)
|
||||
Reference in New Issue
Block a user