Files
Cyrene_v2/backend/services/gateway/api/handlers_more.go
T
Aska d80710d85a 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
2026-08-21 12:54:11 +08:00

289 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package api — Automation, Reminders, Notifications, Message Search, Session Export, Image, Host Exec
package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/services/gateway/engine"
)
// ===== Automation Rules =====
type AutomationRule struct {
ID string `json:"id"`
Name string `json:"name"`
Trigger string `json:"trigger"` // "time" | "event" | "webhook"
Condition map[string]string `json:"condition"`
Action string `json:"action"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
}
type AutomationStore struct {
mu sync.RWMutex
rules map[string]*AutomationRule
}
func NewAutomationStore() *AutomationStore {
return &AutomationStore{rules: make(map[string]*AutomationRule)}
}
func (s *AutomationStore) List() []*AutomationRule {
s.mu.RLock(); defer s.mu.RUnlock()
result := make([]*AutomationRule, 0, len(s.rules))
for _, r := range s.rules { result = append(result, r) }
return result
}
func (s *AutomationStore) Save(r *AutomationRule) {
s.mu.Lock(); defer s.mu.Unlock()
if r.ID == "" { r.ID = fmt.Sprintf("rule-%d", time.Now().UnixNano()) }
if r.CreatedAt == "" { r.CreatedAt = time.Now().Format(time.RFC3339) }
s.rules[r.ID] = r
}
func (s *AutomationStore) Delete(id string) {
s.mu.Lock(); defer s.mu.Unlock()
delete(s.rules, id)
}
// ===== Reminders =====
type Reminder struct {
ID string `json:"id"`
Title string `json:"title"`
DueAt string `json:"due_at"` // RFC3339
UserID string `json:"user_id"`
Done bool `json:"done"`
CreatedAt string `json:"created_at"`
}
type ReminderStore struct {
mu sync.RWMutex
reminders map[string]*Reminder
}
func NewReminderStore() *ReminderStore { return &ReminderStore{reminders: make(map[string]*Reminder)} }
func (s *ReminderStore) List(userID string) []*Reminder {
s.mu.RLock(); defer s.mu.RUnlock()
result := make([]*Reminder, 0)
for _, r := range s.reminders {
if r.UserID == userID { result = append(result, r) }
}
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()) }
if r.CreatedAt == "" { r.CreatedAt = time.Now().Format(time.RFC3339) }
s.reminders[r.ID] = r
}
func (s *ReminderStore) Delete(id string) {
s.mu.Lock(); defer s.mu.Unlock()
delete(s.reminders, id)
}
// ===== Notification (API interface for future push server) =====
type Notification struct {
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Level string `json:"level"` // "info" | "warning" | "error"
UserID string `json:"user_id"`
CreatedAt string `json:"created_at"`
}
// NotifyFunc is called when a notification is pushed (injected by main.go).
var NotifyFunc func(n Notification)
// ===== Handlers on Router =====
func (r *Router) automationHandler(w http.ResponseWriter, req *http.Request) {
store := r.deps.Automation
if store == nil { writeJSON(w, 503, M{"error": "not available"}); return }
switch req.Method {
case "GET":
writeJSON(w, 200, M{"rules": store.List()})
case "POST":
var rule AutomationRule
json.NewDecoder(req.Body).Decode(&rule)
store.Save(&rule)
writeJSON(w, 201, M{"rule": rule})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
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 }
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"})
}
func (r *Router) remindersHandler(w http.ResponseWriter, req *http.Request) {
store := r.deps.Reminders
if store == nil { writeJSON(w, 503, M{"error": "not available"}); return }
uid := req.Header.Get("X-User-ID")
switch req.Method {
case "GET":
writeJSON(w, 200, M{"reminders": store.List(uid)})
case "POST":
var rem Reminder
json.NewDecoder(req.Body).Decode(&rem)
rem.UserID = uid
store.Save(&rem)
writeJSON(w, 201, M{"reminder": rem})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
func (r *Router) reminderItemHandler(w http.ResponseWriter, req *http.Request) {
store := r.deps.Reminders
if store == nil { writeJSON(w, 503, M{"error": "not available"}); return }
id := strings.TrimPrefix(req.URL.Path, "/api/v1/reminders/")
if req.Method == "DELETE" { store.Delete(id); writeJSON(w, 204, nil); return }
writeJSON(w, 405, M{"error": "method not allowed"})
}
func (r *Router) notificationHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" { writeJSON(w, 405, M{"error": "POST required"}); return }
var n Notification
json.NewDecoder(req.Body).Decode(&n)
n.ID = fmt.Sprintf("notif-%d", time.Now().UnixNano())
n.CreatedAt = time.Now().Format(time.RFC3339)
if NotifyFunc != nil { go NotifyFunc(n) }
writeJSON(w, 200, M{"ok": true, "id": n.ID})
}
func (r *Router) messageSearchHandler(w http.ResponseWriter, req *http.Request) {
q := req.URL.Query().Get("q")
uid := r.userID(req)
if q == "" { writeJSON(w, 400, M{"error": "missing query"}); return }
if r.deps.ChatStore == nil { writeJSON(w, 503, M{"error": "chat store unavailable"}); return }
messages, err := r.deps.ChatStore.SearchMessages(req.Context(), uid, q, 20)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"messages": messages, "query": q, "total": len(messages)})
}
func (r *Router) sessionExportHandler(w http.ResponseWriter, req *http.Request) {
id := strings.TrimSuffix(strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/"), "/export")
if r.deps.ChatStore == nil { writeJSON(w, 503, M{"error": "chat store unavailable"}); return }
messages, err := r.deps.ChatStore.ListMessages(req.Context(), id, 200)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
short := id
if len(short) > 8 { short = short[:8] }
format := req.URL.Query().Get("format")
exportedAt := time.Now().Format(time.RFC3339)
switch format {
case "json":
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.json", short))
writeJSON(w, 200, M{"session_id": id, "exported_at": exportedAt, "messages": messages})
case "txt":
var sb strings.Builder
fmt.Fprintf(&sb, "Cyrene Chat - Session %s (exported %s)\n\n", short, exportedAt)
for _, m := range messages {
fmt.Fprintf(&sb, "[%s] %s: %s\n\n", m.CreatedAt.Format("2006-01-02 15:04"), m.Role, m.Content)
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.txt", short))
w.WriteHeader(200)
w.Write([]byte(sb.String()))
default: // markdown
var sb strings.Builder
fmt.Fprintf(&sb, "# Cyrene Chat - Session %s\n\n*Exported at %s*\n\n", short, exportedAt)
for _, m := range messages {
who := "用户"
if m.Role == "assistant" { who = "昔涟" }
fmt.Fprintf(&sb, "**%s**(%s):\n\n%s\n\n---\n\n", who, m.CreatedAt.Format("2006-01-02 15:04"), m.Content)
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.md", short))
w.WriteHeader(200)
w.Write([]byte(sb.String()))
}
}
func (r *Router) imageAnalyzeHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" { writeJSON(w, 405, M{"error": "POST required"}); return }
var body struct {
ImageURL string `json:"image_url"`
Prompt string `json:"prompt"`
}
json.NewDecoder(req.Body).Decode(&body)
// Stub: requires multimodal LLM (GPT-4o / Qwen-VL)
writeJSON(w, 200, M{
"analysis": "图片分析需要多模态大模型。当前为预留接口。请配置支持 vision 的 LLM provider。",
"image_url": body.ImageURL, "prompt": body.Prompt,
})
}
func (r *Router) hostExecHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" { writeJSON(w, 405, M{"error": "POST required"}); return }
var body struct {
Command string `json:"command"`
Args []string `json:"args"`
Timeout int `json:"timeout_sec"`
}
json.NewDecoder(req.Body).Decode(&body)
// Stub: safe execution requires Docker sandbox
writeJSON(w, 200, M{
"result": "主机执行需要沙箱环境。当前为预留接口。配置 HOST_EXEC_BACKEND=docker 后可用。",
"command": body.Command,
})
}