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:
1 parent
9f30cdd086
commit
d80710d85a
7 files changed
+679
-2
No files matched your search
@@ -43,6 +43,7 @@ import (
|
|||||||
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/persona"
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/persona"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/subsession"
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/subsession"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/api"
|
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/api"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/engine"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/store"
|
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/store"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/ws"
|
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/ws"
|
||||||
"git.yeij.top/AskaEth/Cyrene_v2/services/memory"
|
"git.yeij.top/AskaEth/Cyrene_v2/services/memory"
|
||||||
@@ -306,6 +307,35 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// === 12.7 Automation engine ===
|
||||||
|
autoEngine := engine.New(
|
||||||
|
automationSourceAdapter{gwDeps.Automation},
|
||||||
|
func(ctx context.Context, sessionID, userID, message string) {
|
||||||
|
tokenCh, _, err := orch.ProcessInput(ctx, sessionID, userID, message)
|
||||||
|
if err != nil {
|
||||||
|
logger.Infof("[automation] action failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for range tokenCh {
|
||||||
|
}
|
||||||
|
},
|
||||||
|
iotQuerierAdapter{reg},
|
||||||
|
30*time.Second,
|
||||||
|
)
|
||||||
|
autoEngine.Start(context.Background())
|
||||||
|
gwDeps.AutomationEng = autoEngine
|
||||||
|
|
||||||
|
// === 12.8 Reminder scheduler ===
|
||||||
|
remSched := engine.NewReminderScheduler(
|
||||||
|
gwDeps.Reminders,
|
||||||
|
func(userID, title, body string) {
|
||||||
|
wsHub.Broadcast(map[string]any{"type": "reminder", "title": title, "content": body})
|
||||||
|
},
|
||||||
|
&memory.LLMAdapterBridge{Adapter: llmSelector.DefaultAdapter()},
|
||||||
|
30*time.Second,
|
||||||
|
)
|
||||||
|
remSched.Start(context.Background())
|
||||||
|
|
||||||
apiRouter := api.New(gwDeps)
|
apiRouter := api.New(gwDeps)
|
||||||
|
|
||||||
wsMux := http.NewServeMux()
|
wsMux := http.NewServeMux()
|
||||||
@@ -394,6 +424,32 @@ func (a *perfAdapter) History() []map[string]any {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// automationSourceAdapter adapts api.AutomationStore to engine.RuleSource.
|
||||||
|
type automationSourceAdapter struct{ s *api.AutomationStore }
|
||||||
|
|
||||||
|
func (a automationSourceAdapter) List() []engine.Rule {
|
||||||
|
rules := a.s.List()
|
||||||
|
out := make([]engine.Rule, len(rules))
|
||||||
|
for i, r := range rules {
|
||||||
|
out[i] = engine.Rule{
|
||||||
|
ID: r.ID, Name: r.Name, Trigger: r.Trigger,
|
||||||
|
Condition: r.Condition, Action: r.Action, Enabled: r.Enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// iotQuerierAdapter adapts the plugin registry to engine.DeviceQuerier.
|
||||||
|
type iotQuerierAdapter struct{ reg *plugin.Registry }
|
||||||
|
|
||||||
|
func (a iotQuerierAdapter) ListDevices(ctx context.Context) ([]plugin.Device, error) {
|
||||||
|
iot := a.reg.GetIoTProvider()
|
||||||
|
if iot == nil {
|
||||||
|
return nil, fmt.Errorf("no iot provider")
|
||||||
|
}
|
||||||
|
return iot.ListDevices(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// chatPersisterAdapter adapts store.ChatStore to aiCoreCtx.Persister.
|
// chatPersisterAdapter adapts store.ChatStore to aiCoreCtx.Persister.
|
||||||
type chatPersisterAdapter struct{ s *store.ChatStore }
|
type chatPersisterAdapter struct{ s *store.ChatStore }
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/engine"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ===== Automation Rules =====
|
// ===== Automation Rules =====
|
||||||
@@ -57,6 +59,7 @@ type Reminder struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
DueAt string `json:"due_at"` // RFC3339
|
DueAt string `json:"due_at"` // RFC3339
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
|
Done bool `json:"done"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +79,25 @@ func (s *ReminderStore) List(userID string) []*Reminder {
|
|||||||
return result
|
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) {
|
func (s *ReminderStore) Save(r *Reminder) {
|
||||||
s.mu.Lock(); defer s.mu.Unlock()
|
s.mu.Lock(); defer s.mu.Unlock()
|
||||||
if r.ID == "" { r.ID = fmt.Sprintf("rem-%d", time.Now().UnixNano()) }
|
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) {
|
func (r *Router) automationItemHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
store := r.deps.Automation
|
store := r.deps.Automation
|
||||||
if store == nil { writeJSON(w, 503, M{"error": "not available"}); return }
|
if store == nil { writeJSON(w, 503, M{"error": "not available"}); return }
|
||||||
id := strings.TrimPrefix(req.URL.Path, "/api/v1/automation/rules/")
|
rest := strings.TrimPrefix(req.URL.Path, "/api/v1/automation/rules/")
|
||||||
if req.Method == "DELETE" { store.Delete(id); writeJSON(w, 204, nil); return }
|
|
||||||
|
// 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"})
|
writeJSON(w, 405, M{"error": "method not allowed"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ type BridgeConfigManager interface {
|
|||||||
DeleteAdapter(name string) error
|
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.
|
// ChatStorer persists chat sessions and messages.
|
||||||
type ChatStorer interface {
|
type ChatStorer interface {
|
||||||
CreateSession(ctx context.Context, id, userID, title string) (*store.Session, error)
|
CreateSession(ctx context.Context, id, userID, title string) (*store.Session, error)
|
||||||
@@ -110,6 +115,7 @@ type Dependencies struct {
|
|||||||
ChatStore ChatStorer
|
ChatStore ChatStorer
|
||||||
ModelConfig *ModelConfigStore
|
ModelConfig *ModelConfigStore
|
||||||
Automation *AutomationStore
|
Automation *AutomationStore
|
||||||
|
AutomationEng AutomationEngine
|
||||||
Reminders *ReminderStore
|
Reminders *ReminderStore
|
||||||
PluginList []map[string]string
|
PluginList []map[string]string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Package engine provides the automation rule engine and reminder scheduler.
|
||||||
|
// Rules fire actions through the AI pipeline (which can call tools like IoT control).
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cronField matches one field of a cron expression.
|
||||||
|
type cronField struct{ values map[int]bool }
|
||||||
|
|
||||||
|
// parseCronField parses "*", "*/n", "a", "a,b,c", "a-b", "a-b/n".
|
||||||
|
func parseCronField(s string, min, max int) (cronField, error) {
|
||||||
|
f := cronField{values: map[int]bool{}}
|
||||||
|
for _, part := range strings.Split(s, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
return f, fmt.Errorf("empty cron field part")
|
||||||
|
}
|
||||||
|
|
||||||
|
step := 1
|
||||||
|
base := part
|
||||||
|
if i := strings.Index(part, "/"); i >= 0 {
|
||||||
|
base = part[:i]
|
||||||
|
n, err := strconv.Atoi(part[i+1:])
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return f, fmt.Errorf("invalid step %q", part)
|
||||||
|
}
|
||||||
|
step = n
|
||||||
|
}
|
||||||
|
|
||||||
|
lo, hi := min, max
|
||||||
|
switch {
|
||||||
|
case base == "*":
|
||||||
|
case strings.Contains(base, "-"):
|
||||||
|
segs := strings.SplitN(base, "-", 2)
|
||||||
|
a, err1 := strconv.Atoi(segs[0])
|
||||||
|
b, err2 := strconv.Atoi(segs[1])
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return f, fmt.Errorf("invalid range %q", base)
|
||||||
|
}
|
||||||
|
lo, hi = a, b
|
||||||
|
default:
|
||||||
|
n, err := strconv.Atoi(base)
|
||||||
|
if err != nil {
|
||||||
|
return f, fmt.Errorf("invalid value %q", base)
|
||||||
|
}
|
||||||
|
lo, hi = n, n
|
||||||
|
}
|
||||||
|
if lo < min || hi > max || lo > hi {
|
||||||
|
return f, fmt.Errorf("value out of range %q", base)
|
||||||
|
}
|
||||||
|
for v := lo; v <= hi; v += step {
|
||||||
|
f.values[v] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CronSchedule is a parsed 5-field cron expression (min hour dom month dow).
|
||||||
|
type CronSchedule struct {
|
||||||
|
min, hour, dom, month, dow cronField
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCron parses a standard 5-field cron expression.
|
||||||
|
func ParseCron(expr string) (*CronSchedule, error) {
|
||||||
|
parts := strings.Fields(expr)
|
||||||
|
if len(parts) != 5 {
|
||||||
|
return nil, fmt.Errorf("cron must have 5 fields, got %d", len(parts))
|
||||||
|
}
|
||||||
|
bounds := [5][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 6}}
|
||||||
|
var fields [5]cronField
|
||||||
|
for i, p := range parts {
|
||||||
|
f, err := parseCronField(p, bounds[i][0], bounds[i][1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("field %d: %w", i+1, err)
|
||||||
|
}
|
||||||
|
fields[i] = f
|
||||||
|
}
|
||||||
|
return &CronSchedule{fields[0], fields[1], fields[2], fields[3], fields[4]}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match reports whether t matches the schedule (minute precision).
|
||||||
|
func (c *CronSchedule) Match(t time.Time) bool {
|
||||||
|
return c.min.values[t.Minute()] &&
|
||||||
|
c.hour.values[t.Hour()] &&
|
||||||
|
c.dom.values[t.Day()] &&
|
||||||
|
c.month.values[int(t.Month())] &&
|
||||||
|
c.dow.values[int(t.Weekday())]
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rule is an automation rule. Trigger types: "time" (cron), "event" (device state).
|
||||||
|
// The action is a natural-language instruction executed through the AI pipeline,
|
||||||
|
// so it can call any tool (IoT control, search, etc.).
|
||||||
|
type Rule struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Trigger string // "time" | "event" | "webhook"
|
||||||
|
Condition map[string]string // time: {"cron": "..."}; event: {"device_id","field","value"}
|
||||||
|
Action string
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleSource provides the current automation rules.
|
||||||
|
type RuleSource interface {
|
||||||
|
List() []Rule
|
||||||
|
}
|
||||||
|
|
||||||
|
// AIFunc executes an action prompt through the AI pipeline (fire and forget).
|
||||||
|
type AIFunc func(ctx context.Context, sessionID, userID, message string)
|
||||||
|
|
||||||
|
// DeviceQuerier polls IoT device states for event triggers.
|
||||||
|
type DeviceQuerier interface {
|
||||||
|
ListDevices(ctx context.Context) ([]plugin.Device, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine evaluates automation rules on a ticker.
|
||||||
|
type Engine struct {
|
||||||
|
src RuleSource
|
||||||
|
ai AIFunc
|
||||||
|
devices DeviceQuerier
|
||||||
|
interval time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
lastCronFire map[string]string // ruleID → fired minute key
|
||||||
|
deviceMatches map[string]bool // ruleID → condition held last poll
|
||||||
|
stop chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates an automation engine.
|
||||||
|
func New(src RuleSource, ai AIFunc, devices DeviceQuerier, interval time.Duration) *Engine {
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 30 * time.Second
|
||||||
|
}
|
||||||
|
return &Engine{
|
||||||
|
src: src,
|
||||||
|
ai: ai,
|
||||||
|
devices: devices,
|
||||||
|
interval: interval,
|
||||||
|
lastCronFire: map[string]string{},
|
||||||
|
deviceMatches: map[string]bool{},
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the evaluation loop.
|
||||||
|
func (e *Engine) Start(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(e.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-e.stop:
|
||||||
|
return
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case t := <-ticker.C:
|
||||||
|
e.check(ctx, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
logger.Infof("[automation] engine started (interval=%v)", e.interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop shuts down the engine.
|
||||||
|
func (e *Engine) Stop() { e.stopOnce.Do(func() { close(e.stop) }) }
|
||||||
|
|
||||||
|
// Trigger fires a rule immediately (manual / webhook).
|
||||||
|
func (e *Engine) Trigger(ctx context.Context, id string) error {
|
||||||
|
for _, r := range e.src.List() {
|
||||||
|
if r.ID == id {
|
||||||
|
e.fire(ctx, r, "manual")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("rule %s not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// check evaluates all rules once.
|
||||||
|
func (e *Engine) check(ctx context.Context, now time.Time) {
|
||||||
|
for _, r := range e.src.List() {
|
||||||
|
if !r.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch r.Trigger {
|
||||||
|
case "time":
|
||||||
|
e.checkCron(ctx, r, now)
|
||||||
|
case "event":
|
||||||
|
e.checkDevice(ctx, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) checkCron(ctx context.Context, r Rule, now time.Time) {
|
||||||
|
expr := r.Condition["cron"]
|
||||||
|
if expr == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sched, err := ParseCron(expr)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !sched.Match(now) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Fire at most once per minute per rule
|
||||||
|
minuteKey := now.Format("2006-01-02 15:04")
|
||||||
|
e.mu.Lock()
|
||||||
|
if e.lastCronFire[r.ID] == minuteKey {
|
||||||
|
e.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.lastCronFire[r.ID] = minuteKey
|
||||||
|
e.mu.Unlock()
|
||||||
|
e.fire(ctx, r, "cron")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) checkDevice(ctx context.Context, r Rule) {
|
||||||
|
if e.devices == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
devID, field, want := r.Condition["device_id"], r.Condition["field"], r.Condition["value"]
|
||||||
|
if devID == "" || field == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := e.devices.ListDevices(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
held := false
|
||||||
|
for _, d := range devices {
|
||||||
|
if d.ID != devID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, ok := d.State[field]; ok && fmt.Sprint(v) == want {
|
||||||
|
held = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge trigger: only fire when the condition starts holding
|
||||||
|
e.mu.Lock()
|
||||||
|
was := e.deviceMatches[r.ID]
|
||||||
|
e.deviceMatches[r.ID] = held
|
||||||
|
e.mu.Unlock()
|
||||||
|
if held && !was {
|
||||||
|
e.fire(ctx, r, "device_state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) fire(ctx context.Context, r Rule, reason string) {
|
||||||
|
logger.Infof("[automation] rule %q fired (%s): %s", r.Name, reason, r.Action)
|
||||||
|
if e.ai != nil && r.Action != "" {
|
||||||
|
go e.ai(ctx, "automation-"+r.ID, "automation", r.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- cron ---
|
||||||
|
|
||||||
|
func TestParseCronMatch(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
expr string
|
||||||
|
at time.Time
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"* * * * *", time.Date(2026, 8, 21, 10, 30, 0, 0, time.Local), true},
|
||||||
|
{"30 10 * * *", time.Date(2026, 8, 21, 10, 30, 0, 0, time.Local), true},
|
||||||
|
{"31 10 * * *", time.Date(2026, 8, 21, 10, 30, 0, 0, time.Local), false},
|
||||||
|
{"*/15 * * * *", time.Date(2026, 8, 21, 10, 45, 0, 0, time.Local), true},
|
||||||
|
{"*/15 * * * *", time.Date(2026, 8, 21, 10, 46, 0, 0, time.Local), false},
|
||||||
|
{"0 8-20/2 * * *", time.Date(2026, 8, 21, 10, 0, 0, 0, time.Local), true},
|
||||||
|
{"0 8-20/2 * * *", time.Date(2026, 8, 21, 9, 0, 0, 0, time.Local), false},
|
||||||
|
{"0 9 21 8 *", time.Date(2026, 8, 21, 9, 0, 0, 0, time.Local), true},
|
||||||
|
{"0 9 * * 5", time.Date(2026, 8, 21, 9, 0, 0, 0, time.Local), true}, // 2026-08-21 is Friday
|
||||||
|
{"0 9 * * 1", time.Date(2026, 8, 21, 9, 0, 0, 0, time.Local), false},
|
||||||
|
{"0,30 9 * * *", time.Date(2026, 8, 21, 9, 30, 0, 0, time.Local), true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
s, err := ParseCron(tt.expr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCron(%q): %v", tt.expr, err)
|
||||||
|
}
|
||||||
|
if got := s.Match(tt.at); got != tt.want {
|
||||||
|
t.Errorf("Match(%q, %v) = %v, want %v", tt.expr, tt.at, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCronInvalid(t *testing.T) {
|
||||||
|
for _, expr := range []string{"* * * *", "61 * * * *", "a b c d e", "*/0 * * * *", "5-1 * * * *"} {
|
||||||
|
if _, err := ParseCron(expr); err == nil {
|
||||||
|
t.Errorf("ParseCron(%q) should fail", expr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- engine ---
|
||||||
|
|
||||||
|
type fakeRuleSource struct{ rules []Rule }
|
||||||
|
|
||||||
|
func (f fakeRuleSource) List() []Rule { return f.rules }
|
||||||
|
|
||||||
|
type fakeDevices struct{ devices []plugin.Device }
|
||||||
|
|
||||||
|
func (f fakeDevices) ListDevices(context.Context) ([]plugin.Device, error) { return f.devices, nil }
|
||||||
|
|
||||||
|
type firedRecorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
calls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *firedRecorder) record(_ context.Context, sessionID, _, message string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.calls = append(f.calls, sessionID+"|"+message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *firedRecorder) count() int {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return len(f.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventually waits until the recorder reaches n calls (fires are async).
|
||||||
|
func (f *firedRecorder) eventually(t *testing.T, n int) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if f.count() >= n {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("expected %d fires, got %d", n, f.count())
|
||||||
|
}
|
||||||
|
|
||||||
|
// stillSmall waits briefly and asserts the count stays at n.
|
||||||
|
func (f *firedRecorder) stillAt(t *testing.T, n int) {
|
||||||
|
t.Helper()
|
||||||
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
if f.count() != n {
|
||||||
|
t.Fatalf("expected count to stay at %d, got %d", n, f.count())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronRuleFiresOncePerMinute(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 21, 10, 30, 0, 0, time.Local)
|
||||||
|
src := fakeRuleSource{rules: []Rule{{
|
||||||
|
ID: "r1", Name: "晨检", Trigger: "time", Enabled: true,
|
||||||
|
Condition: map[string]string{"cron": "30 10 * * *"},
|
||||||
|
Action: "检查一下设备",
|
||||||
|
}}}
|
||||||
|
rec := &firedRecorder{}
|
||||||
|
e := New(src, rec.record, nil, time.Minute)
|
||||||
|
|
||||||
|
e.check(context.Background(), now)
|
||||||
|
e.check(context.Background(), now) // same minute — must not refire
|
||||||
|
rec.eventually(t, 1)
|
||||||
|
|
||||||
|
e.check(context.Background(), now.Add(time.Minute))
|
||||||
|
rec.stillAt(t, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceRuleEdgeTrigger(t *testing.T) {
|
||||||
|
src := fakeRuleSource{rules: []Rule{{
|
||||||
|
ID: "r2", Name: "灯开联动", Trigger: "event", Enabled: true,
|
||||||
|
Condition: map[string]string{"device_id": "light-1", "field": "power", "value": "true"},
|
||||||
|
Action: "灯开了,说点什么",
|
||||||
|
}}}
|
||||||
|
devices := &fakeDevices{devices: []plugin.Device{
|
||||||
|
{ID: "light-1", State: map[string]any{"power": false}},
|
||||||
|
}}
|
||||||
|
rec := &firedRecorder{}
|
||||||
|
e := New(src, rec.record, devices, time.Minute)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
e.check(ctx, time.Now()) // off → no fire
|
||||||
|
rec.stillAt(t, 0)
|
||||||
|
|
||||||
|
devices.devices[0].State["power"] = true
|
||||||
|
e.check(ctx, time.Now()) // edge → fire
|
||||||
|
rec.eventually(t, 1)
|
||||||
|
|
||||||
|
e.check(ctx, time.Now()) // held → no refire
|
||||||
|
rec.stillAt(t, 1)
|
||||||
|
|
||||||
|
devices.devices[0].State["power"] = false
|
||||||
|
e.check(ctx, time.Now())
|
||||||
|
devices.devices[0].State["power"] = true
|
||||||
|
e.check(ctx, time.Now()) // new edge → fire again
|
||||||
|
rec.eventually(t, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualTrigger(t *testing.T) {
|
||||||
|
src := fakeRuleSource{rules: []Rule{{
|
||||||
|
ID: "r3", Name: "手动", Trigger: "webhook", Enabled: false, Action: "执行",
|
||||||
|
}}}
|
||||||
|
rec := &firedRecorder{}
|
||||||
|
e := New(src, rec.record, nil, time.Minute)
|
||||||
|
|
||||||
|
if err := e.Trigger(context.Background(), "r3"); err != nil {
|
||||||
|
t.Fatalf("Trigger: %v", err)
|
||||||
|
}
|
||||||
|
rec.eventually(t, 1)
|
||||||
|
if err := e.Trigger(context.Background(), "nope"); err == nil {
|
||||||
|
t.Error("expected error for unknown rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reminder scheduler ---
|
||||||
|
|
||||||
|
type fakeReminderSource struct {
|
||||||
|
reminders []Reminder
|
||||||
|
done []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReminderSource) ListDue(now time.Time) []Reminder {
|
||||||
|
var out []Reminder
|
||||||
|
for _, r := range f.reminders {
|
||||||
|
if !r.DueAt.After(now) {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReminderSource) MarkDone(id string) { f.done = append(f.done, id) }
|
||||||
|
|
||||||
|
func TestReminderDelivery(t *testing.T) {
|
||||||
|
src := &fakeReminderSource{reminders: []Reminder{
|
||||||
|
{ID: "rem1", Title: "吃药", DueAt: time.Now().Add(-time.Minute), UserID: "u1"},
|
||||||
|
{ID: "rem2", Title: "未来的事", DueAt: time.Now().Add(time.Hour), UserID: "u1"},
|
||||||
|
}}
|
||||||
|
var notified []string
|
||||||
|
sched := NewReminderScheduler(src, func(userID, title, body string) {
|
||||||
|
notified = append(notified, userID+"|"+title+"|"+body)
|
||||||
|
}, nil, time.Minute)
|
||||||
|
|
||||||
|
sched.deliver(context.Background(), time.Now())
|
||||||
|
|
||||||
|
if len(notified) != 1 || notified[0] != "u1|吃药|吃药" {
|
||||||
|
t.Errorf("unexpected notifications: %v", notified)
|
||||||
|
}
|
||||||
|
if len(src.done) != 1 || src.done[0] != "rem1" {
|
||||||
|
t.Errorf("expected rem1 marked done, got %v", src.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reminder is a due notification for a user.
|
||||||
|
type Reminder struct {
|
||||||
|
ID string
|
||||||
|
Title string
|
||||||
|
DueAt time.Time
|
||||||
|
UserID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReminderSource provides due reminders and marks them delivered.
|
||||||
|
type ReminderSource interface {
|
||||||
|
// ListDue returns reminders that are due and not yet delivered.
|
||||||
|
ListDue(now time.Time) []Reminder
|
||||||
|
// MarkDone marks a reminder as delivered.
|
||||||
|
MarkDone(id string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReminderNotifier delivers a reminder to the user (e.g. via WebSocket).
|
||||||
|
type ReminderNotifier func(userID, title, body string)
|
||||||
|
|
||||||
|
// Copywriter polishes reminder text via the LLM (optional).
|
||||||
|
type Copywriter interface {
|
||||||
|
Chat(ctx context.Context, messages []model.LLMMessage) (*model.LLMResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReminderScheduler scans for due reminders and delivers them.
|
||||||
|
type ReminderScheduler struct {
|
||||||
|
src ReminderSource
|
||||||
|
notify ReminderNotifier
|
||||||
|
llm Copywriter
|
||||||
|
interval time.Duration
|
||||||
|
stop chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewReminderScheduler creates a scheduler scanning at the given interval.
|
||||||
|
func NewReminderScheduler(src ReminderSource, notify ReminderNotifier, llm Copywriter, interval time.Duration) *ReminderScheduler {
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 30 * time.Second
|
||||||
|
}
|
||||||
|
return &ReminderScheduler{src: src, notify: notify, llm: llm, interval: interval, stop: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the scan loop.
|
||||||
|
func (s *ReminderScheduler) Start(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(s.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case t := <-ticker.C:
|
||||||
|
s.deliver(ctx, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
logger.Infof("[reminder] scheduler started (interval=%v)", s.interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop shuts down the scheduler.
|
||||||
|
func (s *ReminderScheduler) Stop() { s.stopOnce.Do(func() { close(s.stop) }) }
|
||||||
|
|
||||||
|
func (s *ReminderScheduler) deliver(ctx context.Context, now time.Time) {
|
||||||
|
for _, r := range s.src.ListDue(now) {
|
||||||
|
body := s.polish(ctx, r.Title)
|
||||||
|
logger.Infof("[reminder] due: %q for %s", r.Title, r.UserID)
|
||||||
|
if s.notify != nil {
|
||||||
|
s.notify(r.UserID, r.Title, body)
|
||||||
|
}
|
||||||
|
s.src.MarkDone(r.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// polish asks the LLM for a warmer reminder text; falls back to the title.
|
||||||
|
func (s *ReminderScheduler) polish(ctx context.Context, title string) string {
|
||||||
|
if s.llm == nil {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
c, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := s.llm.Chat(c, []model.LLMMessage{
|
||||||
|
{Role: "system", Content: "你是昔涟。用一两句温柔自然的话提醒主人一件事,不要解释,直接输出提醒内容。"},
|
||||||
|
{Role: "user", Content: fmt.Sprintf("请提醒我:%s", title)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
if text := strings.TrimSpace(resp.Content); text != "" && len([]rune(text)) <= 120 {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return title
|
||||||
|
}
|
||||||
Reference in new issue
Block a user