Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 48 additions & 6 deletions server/notification_channels.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
180 changes: 180 additions & 0 deletions server/notifications/channels/ntfy.go
Original file line number Diff line number Diff line change
@@ -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
}
}
65 changes: 65 additions & 0 deletions ui/src/components/ConfigFieldInput.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="form-group">
<label htmlFor={inputId}>
{field.label}
{field.required && " *"}
</label>
{field.options && field.options.length > 0 ? (
<select
id={inputId}
className="form-input"
value={value}
onChange={(e) => onChange(e.target.value)}
aria-describedby={field.description ? descId : undefined}
>
<option value="">Select...</option>
{field.options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
) : (
<input
id={inputId}
className="form-input"
type={field.type === "password" ? "password" : "text"}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
aria-describedby={field.description ? descId : undefined}
/>
)}
{field.description && (
<span
id={descId}
style={{ fontSize: "0.75rem", color: "var(--text-secondary)", marginTop: "0.25rem", display: "block" }}
>
{field.description}
</span>
)}
</div>
);
}
Loading
Loading