package integrations import ( "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "time" ) type NtfyIntegration struct{} var ntfyHTTPClient = &http.Client{ Timeout: 15 * time.Second, } type NtfyNotificationChannelConfig struct { Endpoint string `json:"endpoint"` AuthToken string `json:"authToken"` } func (s NtfyIntegration) ProcessEvent(evt *ContactPointEvent, rawConfig string) error { config := NtfyNotificationChannelConfig{} err := json.Unmarshal([]byte(rawConfig), &config) if err != nil { return fmt.Errorf("failed to parse ntfy integration config - %w", err) } if err := s.validate(config); err != nil { return err } switch evt.Type { case ContactPointEventAlertTriggered: data := evt.AlertTriggeredEvent if data.SensorValueCondition != nil { text := fmt.Sprintf("%s is at {value}", data.Sensor.Name) if data.Alert.CustomMessage != "" { text = data.Alert.CustomMessage } text = strings.Replace(text, "{value}", strconv.FormatFloat(data.LastValue, 'f', -1, 64), -1) return s.send(config, "Alert Triggered", text) } if data.SensorLastContactCondition != nil { text := fmt.Sprintf( "%s has not reported in for last %s %s", data.Sensor.Name, strconv.FormatFloat(data.SensorLastContactCondition.Value, 'f', -1, 64), data.SensorLastContactCondition.ValueUnit, ) if data.Alert.CustomMessage != "" { text = data.Alert.CustomMessage } return s.send(config, "Alert Triggered", text) } return nil case ContactPointEventAlertResolved: data := evt.AlertResolvedEvent if data.SensorValueCondition != nil { text := fmt.Sprintf("%s is at {value}", data.Sensor.Name) if data.Alert.CustomResolvedMessage != "" { text = data.Alert.CustomResolvedMessage } text = strings.Replace(text, "{value}", strconv.FormatFloat(data.LastValue, 'f', -1, 64), -1) return s.send(config, "Alert Resolved", text) } if data.SensorLastContactCondition != nil { text := fmt.Sprintf( "%s has reported in last %s %s", data.Sensor.Name, strconv.FormatFloat(data.SensorLastContactCondition.Value, 'f', -1, 64), data.SensorLastContactCondition.ValueUnit, ) if data.Alert.CustomResolvedMessage != "" { text = data.Alert.CustomResolvedMessage } return s.send(config, "Alert Resolved", text) } return nil case ContactPointEventTest: return s.send(config, "Test", "Test message from Basic Sensor Receiver") } return nil } func (s NtfyIntegration) ValidateConfig(rawConfig string) error { config := NtfyNotificationChannelConfig{} err := json.Unmarshal([]byte(rawConfig), &config) if err != nil { return err } return s.validate(config) } func (s NtfyIntegration) validate(config NtfyNotificationChannelConfig) error { if strings.TrimSpace(config.Endpoint) == "" { return fmt.Errorf("ntfy endpoint is required") } return nil } func (s NtfyIntegration) send(config NtfyNotificationChannelConfig, title string, text string) error { url := strings.TrimSpace(config.Endpoint) req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(text)) if err != nil { return fmt.Errorf("failed to create ntfy request - %w", err) } req.Header.Set("Content-Type", "text/plain; charset=utf-8") req.Header.Set("Title", title) if config.AuthToken != "" { req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.AuthToken)) } resp, err := ntfyHTTPClient.Do(req) if err != nil { return fmt.Errorf("failed to send ntfy notification - %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode > 299 { body, readErr := io.ReadAll(resp.Body) if readErr != nil { return fmt.Errorf("ntfy returned status %d", resp.StatusCode) } return fmt.Errorf("ntfy returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } return nil }