b35151418a
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
236 lines
7.8 KiB
Go
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(¤t)
|
|
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
|
|
}
|