Files
Maven/internal/store/reminders.go
kami b35151418a mavend: give the voice handler a CoreAPI that can serve the day plan
wireVoice runs before the tick loop exists, so it could only be handed
the bare store adapter — and that adapter answers DayPlan with "not
available via direct store API", because a day plan is assembled by the
tick loop and is not a table to read. So queryDayPlan, which the query
chain reaches for "какие у меня планы на сегодня", failed for every
caller on the deployed daemon.

main already back-patches the other direction (daemonAPI.chatFn =
handler.handleText). This is the same seam in reverse, at both wiring
sites. No recursion risk: nothing in the voice path calls api.Chat.

With the plan reachable, it recited its reminders as literal JSON. The
payload unwrapper existed but was private to the phraser, so the day
plan had its own non-unwrapping copy. One owner now, store.ReminderText,
with the phraser delegating to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 23:17:10 +04:00

236 lines
7.8 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/robfig/cron/v3"
)
// Reminder — user-stated future intent. fires once or recurring (if cron set).
type Reminder struct {
ID int64
CreatedTs time.Time
FireTs time.Time
NextFireTs time.Time // computed next fire (for recurring) or same as FireTs
Payload string // raw json
Status string // pending | fired | cancelled
Cron string // cron expression, empty for one-shot
// Collapsed — set only on a synthetic digest reminder (ID=0): the original
// due reminders it stands in for. Not persisted. The dispatcher completes
// (marks fired / reschedules) each of these after the digest delivers.
Collapsed []Reminder
}
// Text — what the user actually asked for, out of the raw-JSON payload.
//
// The router's reminder slot extraction owns the payload shape and the
// conventional field is "text". A payload that is not JSON, or that lacks the
// field, is returned as-is: he said it, so they are his words, and showing
// them beats showing nothing.
//
// Here rather than in a caller because there is more than one caller and they
// disagreed. The phraser unwrapped the payload; the day plan did not, so
// "какие у меня планы на сегодня" recited a reminder as the literal string
// {"text":"..."} on the deployed daemon, 01-08-2026.
func (r Reminder) Text() string { return ReminderText(r.Payload) }
// ReminderText — Reminder.Text for callers holding a bare payload string.
func ReminderText(payload string) string {
var m map[string]any
if err := json.Unmarshal([]byte(payload), &m); err == nil {
if t, ok := m["text"].(string); ok && t != "" {
return t
}
if t, ok := m["text"]; ok {
return fmt.Sprintf("%v", t)
}
}
return strings.TrimSpace(payload)
}
// Reminder lifecycle states. Named for the same reason DigestStatus is: a
// caller filtering on the string literal "pending" is one typo away from a
// filter that silently matches nothing.
const (
ReminderPending = "pending"
ReminderFired = "fired"
ReminderCancelled = "cancelled"
)
var (
ErrReminderNotFound = errors.New("store: reminder not found")
ErrReminderState = errors.New("store: reminder not in a mutable state")
)
func scanReminder(sc scanner) (Reminder, error) {
var r Reminder
var created, fire, nextFire int64
var cron *string
if err := sc.Scan(&r.ID, &created, &fire, &nextFire, &r.Payload, &r.Status, &cron); err != nil {
return Reminder{}, err
}
r.CreatedTs = time.UnixMilli(created).UTC()
r.FireTs = time.UnixMilli(fire).UTC()
r.NextFireTs = time.UnixMilli(nextFire).UTC()
if cron != nil {
r.Cron = *cron
}
return r, nil
}
// CreateReminder persists a reminder with a resolved absolute fire time.
// The caller (router/capture path) MUST have already converted "in 4h" → now+4h.
// We do not accept strings here. cron is a cron expression for recurring
// reminders; empty for one-shot.
func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
now := time.Now().UTC()
var cronPtr *string
if cron != "" {
cronPtr = &cron
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO reminders (created_ts, fire_ts, next_fire_ts, payload, status, cron) VALUES (?,?,?,?, 'pending', ?)`,
now.UnixMilli(), fire.UnixMilli(), fire.UnixMilli(), payload, cronPtr)
if err != nil {
return 0, fmt.Errorf("create reminder: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("create reminder: last insert id: %w", err)
}
return id, nil
}
// DueReminders returns pending reminders with next_fire_ts <= now, oldest first.
// This is the predicate input from the loop side: `next_fire_ts <= now AND status='pending'`.
func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
FROM reminders
WHERE status = 'pending' AND next_fire_ts <= ?
ORDER BY next_fire_ts ASC`, now.UnixMilli())
if err != nil {
return nil, fmt.Errorf("due reminders: %w", err)
}
defer rows.Close()
var out []Reminder
for rows.Next() {
r, err := scanReminder(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// PendingReminders returns the pending reminders whose next fire time falls in
// [from, to), earliest first.
//
// The day plan used to take the newest 500 rows out of ListReminders, which
// orders by creation, and then filter them by day. A reminder stated long ago
// for today fell off the end of that scan while a reminder stated this morning
// for next year stayed on it. Bounding by fire time drops what is out of range
// instead of what is old.
func (s *Store) PendingReminders(ctx context.Context, from, to time.Time) ([]Reminder, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
FROM reminders
WHERE status = ? AND next_fire_ts >= ? AND next_fire_ts < ?
ORDER BY next_fire_ts ASC, id ASC`,
ReminderPending, from.UnixMilli(), to.UnixMilli())
if err != nil {
return nil, fmt.Errorf("pending reminders: %w", err)
}
defer rows.Close()
var out []Reminder
for rows.Next() {
r, err := scanReminder(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// MarkReminder sets a reminder's status. Only valid transitions: pending→fired,
// pending→cancelled. Anything else is a programming error.
func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error {
if status != "fired" && status != "cancelled" {
return fmt.Errorf("%w: %s", ErrReminderState, status)
}
// pending → fired|cancelled only.
var current string
err := s.db.QueryRowContext(ctx, "SELECT status FROM reminders WHERE id = ?", id).Scan(&current)
if errors.Is(err, sql.ErrNoRows) {
return ErrReminderNotFound
}
if err != nil {
return err
}
if current != "pending" {
return fmt.Errorf("%w: currently %s", ErrReminderState, current)
}
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = ? WHERE id = ?", status, id)
return err
}
// ListReminders returns the n most recent reminders, newest first.
func (s *Store) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
FROM reminders ORDER BY created_ts DESC, id DESC LIMIT ?`, n)
if err != nil {
return nil, fmt.Errorf("list reminders: %w", err)
}
defer rows.Close()
var out []Reminder
for rows.Next() {
r, err := scanReminder(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// RescheduleReminder computes the next fire time for a recurring reminder and
// updates next_fire_ts. Returns ErrReminderState if the reminder is not
// recurring or not pending. If no more valid fire times exist, marks it fired.
func (s *Store) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
row := s.db.QueryRowContext(ctx, `
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
FROM reminders WHERE id = ?`, id)
r, err := scanReminder(row)
if err != nil {
return err
}
if r.Cron == "" {
return fmt.Errorf("%w: not a recurring reminder", ErrReminderState)
}
if r.Status != "pending" {
return fmt.Errorf("%w: currently %s", ErrReminderState, r.Status)
}
sched, err := cron.ParseStandard(r.Cron)
if err != nil {
return fmt.Errorf("parse cron %q: %w", r.Cron, err)
}
next := sched.Next(r.NextFireTs.Add(time.Minute))
if next.IsZero() || next.Before(now) {
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = 'fired' WHERE id = ?", id)
return err
}
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET next_fire_ts = ? WHERE id = ?", next.UnixMilli(), id)
return err
}