feat: scheduled reminders + less aggressive article summary
Add scheduled messages/reminders system: - New scheduled_messages table in memory-service with CRUD endpoints - schedule_message, list_reminders, cancel_reminder tools for the bot - Background scheduler loop (30s) sends due reminders automatically - Supports one-time, daily, weekly, weekdays, monthly repeat patterns Make article URL handling non-blocking: - Show 3 options (discuss, text summary, audio) instead of forcing audio wizard - Default to passing article context to AI if user just keeps chatting - New AWAITING_LANGUAGE state for cleaner audio flow FSM Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,34 @@ def _decrypt(ciphertext: str, user_id: str) -> str:
|
||||
return ciphertext
|
||||
|
||||
|
||||
class ScheduleRequest(BaseModel):
|
||||
user_id: str
|
||||
room_id: str
|
||||
message_text: str
|
||||
scheduled_at: float # Unix timestamp
|
||||
repeat_pattern: str = "once" # once | daily | weekly | weekdays | monthly
|
||||
|
||||
@field_validator('user_id')
|
||||
@classmethod
|
||||
def user_id_not_empty(cls, v):
|
||||
if not v or not v.strip():
|
||||
raise ValueError("user_id is required")
|
||||
return v.strip()
|
||||
|
||||
@field_validator('repeat_pattern')
|
||||
@classmethod
|
||||
def valid_pattern(cls, v):
|
||||
allowed = {"once", "daily", "weekly", "weekdays", "monthly"}
|
||||
if v not in allowed:
|
||||
raise ValueError(f"repeat_pattern must be one of {allowed}")
|
||||
return v
|
||||
|
||||
|
||||
class ScheduleCancelRequest(BaseModel):
|
||||
id: int
|
||||
user_id: str
|
||||
|
||||
|
||||
class StoreRequest(BaseModel):
|
||||
user_id: str
|
||||
fact: str
|
||||
@@ -237,6 +265,27 @@ async def _init_db():
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_chunks_user_room ON conversation_chunks (user_id, room_id)
|
||||
""")
|
||||
# Scheduled messages table for reminders
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scheduled_messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
message_text TEXT NOT NULL,
|
||||
scheduled_at DOUBLE PRECISION NOT NULL,
|
||||
created_at DOUBLE PRECISION NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
repeat_pattern TEXT DEFAULT 'once',
|
||||
repeat_interval_seconds INTEGER DEFAULT 0,
|
||||
last_sent_at DOUBLE PRECISION DEFAULT 0
|
||||
)
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_user_id ON scheduled_messages (user_id)
|
||||
""")
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_status ON scheduled_messages (status, scheduled_at)
|
||||
""")
|
||||
finally:
|
||||
await owner_conn.close()
|
||||
# Create restricted pool for all request handlers (RLS applies)
|
||||
@@ -269,10 +318,12 @@ async def health():
|
||||
async with owner_pool.acquire() as conn:
|
||||
mem_count = await conn.fetchval("SELECT count(*) FROM memories")
|
||||
chunk_count = await conn.fetchval("SELECT count(*) FROM conversation_chunks")
|
||||
sched_count = await conn.fetchval("SELECT count(*) FROM scheduled_messages WHERE status = 'pending'")
|
||||
return {
|
||||
"status": "ok",
|
||||
"total_memories": mem_count,
|
||||
"total_chunks": chunk_count,
|
||||
"pending_reminders": sched_count,
|
||||
"encryption": "on" if ENCRYPTION_KEY else "off",
|
||||
}
|
||||
except Exception as e:
|
||||
@@ -525,3 +576,190 @@ async def count_user_chunks(user_id: str, _: None = Depends(verify_token)):
|
||||
"SELECT count(*) FROM conversation_chunks WHERE user_id = $1", user_id,
|
||||
)
|
||||
return {"user_id": user_id, "count": count}
|
||||
|
||||
|
||||
# --- Scheduled Messages ---
|
||||
|
||||
import calendar
|
||||
import datetime
|
||||
|
||||
|
||||
def _compute_repeat_interval(pattern: str) -> int:
|
||||
"""Compute repeat_interval_seconds from pattern name."""
|
||||
return {
|
||||
"once": 0,
|
||||
"daily": 86400,
|
||||
"weekly": 604800,
|
||||
"weekdays": 86400, # special handling in mark-sent
|
||||
"monthly": 0, # special handling in mark-sent
|
||||
}.get(pattern, 0)
|
||||
|
||||
|
||||
def _next_scheduled_at(current_ts: float, pattern: str) -> float:
|
||||
"""Compute the next scheduled_at timestamp for recurring patterns."""
|
||||
dt = datetime.datetime.fromtimestamp(current_ts, tz=datetime.timezone.utc)
|
||||
|
||||
if pattern == "daily":
|
||||
return current_ts + 86400.0
|
||||
elif pattern == "weekly":
|
||||
return current_ts + 604800.0
|
||||
elif pattern == "weekdays":
|
||||
next_dt = dt + datetime.timedelta(days=1)
|
||||
while next_dt.weekday() >= 5: # Skip Sat(5), Sun(6)
|
||||
next_dt += datetime.timedelta(days=1)
|
||||
return next_dt.timestamp()
|
||||
elif pattern == "monthly":
|
||||
month = dt.month + 1
|
||||
year = dt.year + (month - 1) // 12
|
||||
month = (month - 1) % 12 + 1
|
||||
day = min(dt.day, calendar.monthrange(year, month)[1])
|
||||
return dt.replace(year=year, month=month, day=day).timestamp()
|
||||
return current_ts
|
||||
|
||||
|
||||
MAX_REMINDERS_PER_USER = 50
|
||||
|
||||
|
||||
@app.post("/scheduled/create")
|
||||
async def create_scheduled(req: ScheduleRequest, _: None = Depends(verify_token)):
|
||||
"""Create a new scheduled message/reminder."""
|
||||
now = time.time()
|
||||
if req.scheduled_at <= now:
|
||||
raise HTTPException(400, "scheduled_at must be in the future")
|
||||
|
||||
# Check max reminders per user
|
||||
await _ensure_pool()
|
||||
async with owner_pool.acquire() as conn:
|
||||
count = await conn.fetchval(
|
||||
"SELECT count(*) FROM scheduled_messages WHERE user_id = $1 AND status = 'pending'",
|
||||
req.user_id,
|
||||
)
|
||||
if count >= MAX_REMINDERS_PER_USER:
|
||||
raise HTTPException(400, f"Maximum {MAX_REMINDERS_PER_USER} active reminders per user")
|
||||
|
||||
msg_text = req.message_text[:2000] # Truncate long messages
|
||||
interval = _compute_repeat_interval(req.repeat_pattern)
|
||||
|
||||
row_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO scheduled_messages
|
||||
(user_id, room_id, message_text, scheduled_at, created_at, status, repeat_pattern, repeat_interval_seconds)
|
||||
VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7)
|
||||
RETURNING id
|
||||
""",
|
||||
req.user_id, req.room_id, msg_text, req.scheduled_at, now,
|
||||
req.repeat_pattern, interval,
|
||||
)
|
||||
logger.info("Created reminder #%d for %s at %.0f (%s)", row_id, req.user_id, req.scheduled_at, req.repeat_pattern)
|
||||
return {"id": row_id, "created": True}
|
||||
|
||||
|
||||
@app.get("/scheduled/{user_id}")
|
||||
async def list_scheduled(user_id: str, _: None = Depends(verify_token)):
|
||||
"""List all pending/active reminders for a user."""
|
||||
await _ensure_pool()
|
||||
async with owner_pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, message_text, scheduled_at, repeat_pattern, status
|
||||
FROM scheduled_messages
|
||||
WHERE user_id = $1 AND status = 'pending'
|
||||
ORDER BY scheduled_at
|
||||
""",
|
||||
user_id,
|
||||
)
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"reminders": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"message_text": r["message_text"],
|
||||
"scheduled_at": r["scheduled_at"],
|
||||
"repeat_pattern": r["repeat_pattern"],
|
||||
"status": r["status"],
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/scheduled/{user_id}/{reminder_id}")
|
||||
async def cancel_scheduled(user_id: str, reminder_id: int, _: None = Depends(verify_token)):
|
||||
"""Cancel a reminder. Only the owner can cancel."""
|
||||
await _ensure_pool()
|
||||
async with owner_pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE scheduled_messages SET status = 'cancelled'
|
||||
WHERE id = $1 AND user_id = $2 AND status = 'pending'
|
||||
""",
|
||||
reminder_id, user_id,
|
||||
)
|
||||
count = int(result.split()[-1])
|
||||
if count == 0:
|
||||
raise HTTPException(404, "Reminder not found or already cancelled")
|
||||
logger.info("Cancelled reminder #%d for %s", reminder_id, user_id)
|
||||
return {"cancelled": True, "id": reminder_id}
|
||||
|
||||
|
||||
@app.post("/scheduled/due")
|
||||
async def get_due_messages(_: None = Depends(verify_token)):
|
||||
"""Return all messages that are due (scheduled_at <= now, status = pending)."""
|
||||
now = time.time()
|
||||
await _ensure_pool()
|
||||
async with owner_pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, user_id, room_id, message_text, scheduled_at, repeat_pattern
|
||||
FROM scheduled_messages
|
||||
WHERE scheduled_at <= $1 AND status = 'pending'
|
||||
ORDER BY scheduled_at
|
||||
LIMIT 100
|
||||
""",
|
||||
now,
|
||||
)
|
||||
return {
|
||||
"due": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"user_id": r["user_id"],
|
||||
"room_id": r["room_id"],
|
||||
"message_text": r["message_text"],
|
||||
"scheduled_at": r["scheduled_at"],
|
||||
"repeat_pattern": r["repeat_pattern"],
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/scheduled/{reminder_id}/mark-sent")
|
||||
async def mark_sent(reminder_id: int, _: None = Depends(verify_token)):
|
||||
"""Mark a reminder as sent. For recurring, compute next scheduled_at."""
|
||||
now = time.time()
|
||||
await _ensure_pool()
|
||||
async with owner_pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT repeat_pattern, scheduled_at FROM scheduled_messages WHERE id = $1",
|
||||
reminder_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "Reminder not found")
|
||||
|
||||
if row["repeat_pattern"] == "once":
|
||||
await conn.execute(
|
||||
"UPDATE scheduled_messages SET status = 'sent', last_sent_at = $1 WHERE id = $2",
|
||||
now, reminder_id,
|
||||
)
|
||||
else:
|
||||
next_at = _next_scheduled_at(row["scheduled_at"], row["repeat_pattern"])
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE scheduled_messages
|
||||
SET scheduled_at = $1, last_sent_at = $2
|
||||
WHERE id = $3
|
||||
""",
|
||||
next_at, now, reminder_id,
|
||||
)
|
||||
logger.info("Marked reminder #%d as sent (pattern=%s)", reminder_id, row["repeat_pattern"])
|
||||
return {"marked": True}
|
||||
|
||||
Reference in New Issue
Block a user