feat(gateway): automation rule engine and reminder scheduler

- engine: 5-field cron parser (*/n, ranges, lists) firing at most once
  per minute; device-state triggers with edge detection; manual trigger
  via POST /automation/rules/:id/trigger; actions run through the AI
  pipeline so rules can call any tool (IoT control, search, ...)
- reminders: due-scan scheduler delivering via WebSocket broadcast,
  optional LLM-polished reminder text with plain-title fallback;
  Reminder gains Done flag with ListDue/MarkDone
- tests: cron matching and validation, once-per-minute dedup, device
  edge triggering, manual trigger, reminder delivery
This commit is contained in:
Aska committed 2026-08-21 12:54:11 +08:00
1 parent 9f30cdd086
commit d80710d85a
7 files changed
+679 -2

No files matched your search

+38 -2
View File
@@ -8,6 +8,8 @@ import (
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/engine"
)
// ===== Automation Rules =====
@@ -57,6 +59,7 @@ type Reminder struct {
Title string `json:"title"`
DueAt string `json:"due_at"` // RFC3339
UserID string `json:"user_id"`
Done bool `json:"done"`
CreatedAt string `json:"created_at"`
}
@@ -76,6 +79,25 @@ func (s *ReminderStore) List(userID string) []*Reminder {
return result
}
// ListDue returns undelivered reminders whose time has come.
func (s *ReminderStore) ListDue(now time.Time) []engine.Reminder {
s.mu.RLock(); defer s.mu.RUnlock()
var due []engine.Reminder
for _, r := range s.reminders {
if r.Done { continue }
t, err := time.Parse(time.RFC3339, r.DueAt)
if err != nil || t.After(now) { continue }
due = append(due, engine.Reminder{ID: r.ID, Title: r.Title, DueAt: t, UserID: r.UserID})
}
return due
}
// MarkDone marks a reminder as delivered.
func (s *ReminderStore) MarkDone(id string) {
s.mu.Lock(); defer s.mu.Unlock()
if r, ok := s.reminders[id]; ok { r.Done = true }
}
func (s *ReminderStore) Save(r *Reminder) {
s.mu.Lock(); defer s.mu.Unlock()
if r.ID == "" { r.ID = fmt.Sprintf("rem-%d", time.Now().UnixNano()) }
@@ -123,8 +145,22 @@ func (r *Router) automationHandler(w http.ResponseWriter, req *http.Request) {
func (r *Router) automationItemHandler(w http.ResponseWriter, req *http.Request) {
store := r.deps.Automation
if store == nil { writeJSON(w, 503, M{"error": "not available"}); return }
id := strings.TrimPrefix(req.URL.Path, "/api/v1/automation/rules/")
if req.Method == "DELETE" { store.Delete(id); writeJSON(w, 204, nil); return }
rest := strings.TrimPrefix(req.URL.Path, "/api/v1/automation/rules/")
// POST /api/v1/automation/rules/{id}/trigger — fire immediately
if strings.HasSuffix(rest, "/trigger") {
if req.Method != "POST" { writeJSON(w, 405, M{"error": "POST required"}); return }
if r.deps.AutomationEng == nil { writeJSON(w, 503, M{"error": "automation engine not running"}); return }
id := strings.TrimSuffix(rest, "/trigger")
if err := r.deps.AutomationEng.Trigger(req.Context(), id); err != nil {
writeJSON(w, 404, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"ok": true})
return
}
if req.Method == "DELETE" { store.Delete(rest); writeJSON(w, 204, nil); return }
writeJSON(w, 405, M{"error": "method not allowed"})
}
+6
View File
@@ -86,6 +86,11 @@ type BridgeConfigManager interface {
DeleteAdapter(name string) error
}
// AutomationEngine fires automation rules immediately.
type AutomationEngine interface {
Trigger(ctx context.Context, id string) error
}
// ChatStorer persists chat sessions and messages.
type ChatStorer interface {
CreateSession(ctx context.Context, id, userID, title string) (*store.Session, error)
@@ -110,6 +115,7 @@ type Dependencies struct {
ChatStore ChatStorer
ModelConfig *ModelConfigStore
Automation *AutomationStore
AutomationEng AutomationEngine
Reminders *ReminderStore
PluginList []map[string]string
}