Build reusable bot framework
Some checks failed
CI / test (push) Has been cancelled
CI / compose-smoke (push) Has been cancelled

This commit is contained in:
Chelsea Lee
2026-07-19 21:53:24 -05:00
parent 7ecc1107b2
commit fbdf33e894
66 changed files with 8428 additions and 0 deletions

1
modules/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Feature packages discovered by core.registry."""

View 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)

View 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

View 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';

View 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."
),
}

View 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

View 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)