diff --git a/server/notification_channels.go b/server/notification_channels.go index 004638a..ea6965e 100644 --- a/server/notification_channels.go +++ b/server/notification_channels.go @@ -35,11 +35,14 @@ type UpdateNotificationChannelRequest struct { } type ConfigField struct { - Name string `json:"name"` - Label string `json:"label"` - Type string `json:"type"` - Required bool `json:"required"` - Placeholder string `json:"placeholder,omitempty"` + Name string `json:"name"` + Label string `json:"label"` + Type string `json:"type"` + Required bool `json:"required"` + Placeholder string `json:"placeholder,omitempty"` + Default string `json:"default,omitempty"` + Description string `json:"description,omitempty"` + Options []string `json:"options,omitempty"` } type ChannelTypeInfo struct { @@ -63,6 +66,19 @@ var channelTypeInfo = map[string]ChannelTypeInfo{ {Name: "to", Label: "Recipient Email", Type: "string", Required: true, Placeholder: "you@example.com"}, }, }, + "ntfy": { + Type: "ntfy", + Label: "ntfy", + ConfigFields: []ConfigField{ + {Name: "server", Label: "Server URL", Type: "string", Required: true, Placeholder: "https://ntfy.sh", Default: "https://ntfy.sh"}, + {Name: "topic", Label: "Topic", Type: "string", Required: true, Placeholder: "my-shelley-notifications"}, + {Name: "token", Label: "Access Token", Type: "password", Placeholder: "tk_...", Description: "Optional. For private topics, provide either an access token or username and password."}, + {Name: "username", Label: "Username", Type: "string", Description: "Optional. For private topics, use with password as an alternative to access token."}, + {Name: "password", Label: "Password", Type: "password", Description: "Optional. For private topics, use with username."}, + {Name: "done_priority", Label: "Done Priority", Type: "string", Required: true, Default: "default", Options: []string{"min", "low", "default", "high", "max"}}, + {Name: "error_priority", Label: "Error Priority", Type: "string", Required: true, Default: "high", Options: []string{"min", "low", "default", "high", "max"}}, + }, + }, } func toNotificationChannelAPI(ch generated.NotificationChannel) NotificationChannelAPI { @@ -138,6 +154,19 @@ func (s *Server) handleCreateNotificationChannel(w http.ResponseWriter, r *http. return } + // Validate config by attempting to create the channel + validationConfig := map[string]any{"type": req.ChannelType} + var configMap map[string]any + if err := json.Unmarshal(configJSON, &configMap); err == nil { + for k, v := range configMap { + validationConfig[k] = v + } + } + if _, err := notifications.CreateFromConfig(validationConfig, s.logger); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + channelID := "notif-" + uuid.New().String()[:8] var enabled int64 if req.Enabled { @@ -210,7 +239,7 @@ func (s *Server) handleGetNotificationChannel(w http.ResponseWriter, r *http.Req } func (s *Server) handleUpdateNotificationChannel(w http.ResponseWriter, r *http.Request, channelID string) { - _, err := s.db.GetNotificationChannel(r.Context(), channelID) + existing, err := s.db.GetNotificationChannel(r.Context(), channelID) if err != nil { http.Error(w, fmt.Sprintf("Channel not found: %v", err), http.StatusNotFound) return @@ -228,6 +257,19 @@ func (s *Server) handleUpdateNotificationChannel(w http.ResponseWriter, r *http. return } + // Validate config by attempting to create the channel + validationConfig := map[string]any{"type": existing.ChannelType} + var configMap map[string]any + if err := json.Unmarshal(configJSON, &configMap); err == nil { + for k, v := range configMap { + validationConfig[k] = v + } + } + if _, err := notifications.CreateFromConfig(validationConfig, s.logger); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + var enabled int64 if req.Enabled { enabled = 1 diff --git a/server/notifications/channels/ntfy.go b/server/notifications/channels/ntfy.go new file mode 100644 index 0000000..1659f4f --- /dev/null +++ b/server/notifications/channels/ntfy.go @@ -0,0 +1,180 @@ +package channels + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "shelley.exe.dev/server/notifications" +) + +var ntfyPriorities = map[string]int{ + "min": 1, + "low": 2, + "default": 3, + "high": 4, + "max": 5, +} + +func init() { + notifications.Register("ntfy", func(config map[string]any, logger *slog.Logger) (notifications.Channel, error) { + server, _ := config["server"].(string) + if server == "" { + return nil, fmt.Errorf("ntfy channel requires \"server\"") + } + + topic, _ := config["topic"].(string) + if topic == "" { + return nil, fmt.Errorf("ntfy channel requires \"topic\"") + } + + token, _ := config["token"].(string) + username, _ := config["username"].(string) + password, _ := config["password"].(string) + + donePriorityStr, _ := config["done_priority"].(string) + if donePriorityStr == "" { + donePriorityStr = "default" + } + donePriority, ok := ntfyPriorities[donePriorityStr] + if !ok { + return nil, fmt.Errorf("ntfy channel: invalid done_priority %q", donePriorityStr) + } + + errorPriorityStr, _ := config["error_priority"].(string) + if errorPriorityStr == "" { + errorPriorityStr = "high" + } + errorPriority, ok := ntfyPriorities[errorPriorityStr] + if !ok { + return nil, fmt.Errorf("ntfy channel: invalid error_priority %q", errorPriorityStr) + } + + return &ntfy{ + server: server, + topic: topic, + token: token, + username: username, + password: password, + donePriority: donePriority, + errorPriority: errorPriority, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + }, nil + }) +} + +type ntfy struct { + server string + topic string + token string + username string + password string + donePriority int + errorPriority int + client *http.Client +} + +func (n *ntfy) Name() string { return "ntfy" } + +type ntfyMessage struct { + Topic string `json:"topic"` + Title string `json:"title"` + Message string `json:"message"` + Priority int `json:"priority"` + Tags []string `json:"tags"` +} + +func (n *ntfy) Send(ctx context.Context, event notifications.Event) error { + msg := n.formatMessage(event) + if msg == nil { + return nil + } + + body, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("marshal ntfy payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.server, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create ntfy request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + if n.token != "" { + req.Header.Set("Authorization", "Bearer "+n.token) + } else if n.username != "" && n.password != "" { + req.SetBasicAuth(n.username, n.password) + } + + resp, err := n.client.Do(req) + if err != nil { + return fmt.Errorf("send ntfy notification: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if len(b) > 0 { + return fmt.Errorf("ntfy server returned %s: %s", resp.Status, bytes.TrimSpace(b)) + } + return fmt.Errorf("ntfy server returned %s", resp.Status) + } + + _, _ = io.Copy(io.Discard, resp.Body) + return nil +} + +func (n *ntfy) formatMessage(event notifications.Event) *ntfyMessage { + switch event.Type { + case notifications.EventAgentDone: + msg := &ntfyMessage{ + Topic: n.topic, + Priority: n.donePriority, + Tags: []string{"white_check_mark"}, + } + if p, ok := event.Payload.(notifications.AgentDonePayload); ok { + if p.ConversationTitle != "" { + msg.Title = fmt.Sprintf("Agent finished: %s", p.ConversationTitle) + } else { + msg.Title = "Agent finished" + } + var body string + if p.Model != "" { + body = fmt.Sprintf("Model: %s", p.Model) + } + if p.FinalResponse != "" { + if body != "" { + body += "\n" + } + body += p.FinalResponse + } + msg.Message = body + } else { + msg.Title = "Agent finished" + } + return msg + + case notifications.EventAgentError: + msg := &ntfyMessage{ + Topic: n.topic, + Title: "Agent error", + Priority: n.errorPriority, + Tags: []string{"x"}, + } + if p, ok := event.Payload.(notifications.AgentErrorPayload); ok && p.ErrorMessage != "" { + msg.Message = p.ErrorMessage + } + return msg + + default: + return nil + } +} diff --git a/ui/src/components/ConfigFieldInput.tsx b/ui/src/components/ConfigFieldInput.tsx new file mode 100644 index 0000000..f70eca7 --- /dev/null +++ b/ui/src/components/ConfigFieldInput.tsx @@ -0,0 +1,65 @@ +import React from "react"; + +interface ConfigField { + name: string; + label: string; + type: string; + required: boolean; + placeholder?: string; + description?: string; + options?: string[]; +} + +interface ConfigFieldInputProps { + field: ConfigField; + value: string; + onChange: (value: string) => void; +} + +export default function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputProps) { + const inputId = `config-${field.name}`; + const descId = `${inputId}-desc`; + + return ( +