// 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 } if r.deps.Vision == nil { writeJSON(w, 503, M{"error": "vision not available"}); return } var body struct { ImageURL string `json:"image_url"` ImageBase64 string `json:"image_base64"` // raw base64 or data URL Prompt string `json:"prompt"` } json.NewDecoder(req.Body).Decode(&body) imageRef := body.ImageURL if imageRef == "" && body.ImageBase64 != "" { imageRef = body.ImageBase64 if !strings.HasPrefix(imageRef, "data:") { imageRef = "data:image/jpeg;base64," + imageRef } } if imageRef == "" { writeJSON(w, 400, M{"error": "missing image_url or image_base64"}) return } if body.Prompt == "" { body.Prompt = "描述这张图片的内容。" } analysis, err := r.deps.Vision.AnalyzeImage(req.Context(), body.Prompt, imageRef) if err != nil { writeJSON(w, 500, M{"error": err.Error()}) return } writeJSON(w, 200, M{"analysis": analysis, "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, }) }