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:
Christian Gick
2026-03-09 06:55:14 +02:00
parent 19abea01ca
commit 964a3f6075
4 changed files with 558 additions and 8 deletions

View File

@@ -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}