spaxel/mothership/internal/notifications/pushover.go
jedarden b432a1f6b3 feat: implement notification delivery clients (ntfy, pushover, webhook)
- Add ntfy.sh delivery client with HTTP POST to custom topics
  - Supports self-hosted URLs via SetURL()
  - Authentication via Bearer token
  - Priority levels: urgent/high/default/low/min
  - PNG image attachment via base64 data URL
  - Graceful error handling with logging
  - Comprehensive table-driven tests

- Add Pushover delivery client with multipart form uploads
  - App token and user key authentication
  - Priority levels -2 to 2 with emergency retry/expire
  - PNG image attachment support
  - Device targeting, sound customization
  - Full test coverage

- Add Webhook delivery client with JSON payloads
  - Configurable HTTP method and custom headers
  - Rich payload structure for all event types
  - Helper functions for common payloads (fall, zone, anomaly)
  - PNG image attachment via base64

- Extend NotificationManager tests with batching scenarios

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 19:18:31 -04:00

298 lines
6.9 KiB
Go

// Package notifications provides notification delivery clients.
package notifications
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"net/textproto"
"time"
)
// PushoverClient delivers notifications to the Pushover API.
type PushoverClient struct {
// AppToken is the Pushover application token
AppToken string
// UserKey is the Pushover user key
UserKey string
// APIURL is the Pushover API endpoint (defaults to https://api.pushover.net/1/messages.json)
APIURL string
// Device is the optional device name to send to
Device string
// Title is the default message title
Title string
// URL is an optional URL to include
URL string
// URLTitle is an optional title for the URL
URLTitle string
// Priority is the default message priority (-2 to 2)
// -2: lowest, -1: low, 0: normal, 1: high, 2: emergency
Priority int
// Sound is the notification sound (defaults to pushover default)
Sound string
// HTTPClient is the HTTP client to use
HTTPClient *http.Client
}
// PushoverMessage represents a notification message for Pushover.
type PushoverMessage struct {
Message string
Title string
Priority int
Device string
URL string
URLTitle string
Sound string
Timestamp int64
// PNGImageData is optional PNG image data to attach
PNGImageData []byte
// Emergency settings for priority 2
Retry int // Retry in seconds (min 30)
Expire int // Expire in seconds (max 10800)
}
// NewPushoverClient creates a new Pushover client.
func NewPushoverClient(appToken, userKey string) *PushoverClient {
return &PushoverClient{
AppToken: appToken,
UserKey: userKey,
APIURL: "https://api.pushover.net/1/messages.json",
Priority: 0,
Sound: "pushover",
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// Send delivers a notification to Pushover.
func (c *PushoverClient) Send(msg PushoverMessage) error {
if c == nil {
return fmt.Errorf("pushover client is nil")
}
if c.AppToken == "" {
return fmt.Errorf("app token is required")
}
if c.UserKey == "" {
return fmt.Errorf("user key is required")
}
// Use client defaults if not specified
if msg.Title == "" && c.Title != "" {
msg.Title = c.Title
}
if msg.Device == "" && c.Device != "" {
msg.Device = c.Device
}
if msg.URL == "" && c.URL != "" {
msg.URL = c.URL
}
if msg.URLTitle == "" && c.URLTitle != "" {
msg.URLTitle = c.URLTitle
}
if msg.Sound == "" && c.Sound != "" {
msg.Sound = c.Sound
}
if msg.Message == "" {
return fmt.Errorf("message is required")
}
// Create multipart form body
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Add required fields
writeField(writer, "token", c.AppToken)
writeField(writer, "user", c.UserKey)
writeField(writer, "message", msg.Message)
// Add optional fields
if msg.Title != "" {
writeField(writer, "title", msg.Title)
}
// Use message priority if set, otherwise use client default
priority := c.Priority
if msg.Priority != 0 {
priority = msg.Priority
}
// Validate priority range
if priority < -2 || priority > 2 {
priority = 0
}
writeField(writer, "priority", fmt.Sprintf("%d", priority))
if msg.Device != "" {
writeField(writer, "device", msg.Device)
}
if msg.URL != "" {
writeField(writer, "url", msg.URL)
}
if msg.URLTitle != "" {
writeField(writer, "url_title", msg.URLTitle)
}
if msg.Sound != "" {
writeField(writer, "sound", msg.Sound)
}
if msg.Timestamp > 0 {
writeField(writer, "timestamp", fmt.Sprintf("%d", msg.Timestamp))
}
// Emergency settings for priority 2
if priority == 2 {
if msg.Retry > 0 {
// Minimum retry is 30 seconds
if msg.Retry < 30 {
msg.Retry = 30
}
writeField(writer, "retry", fmt.Sprintf("%d", msg.Retry))
}
if msg.Expire > 0 {
// Maximum expire is 10800 seconds (3 hours)
if msg.Expire > 10800 {
msg.Expire = 10800
}
writeField(writer, "expire", fmt.Sprintf("%d", msg.Expire))
}
}
// Add PNG attachment if provided
if len(msg.PNGImageData) > 0 {
// Validate it's a PNG by checking the signature
if len(msg.PNGImageData) >= 8 && string(msg.PNGImageData[1:4]) == "PNG" {
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", `form-data; name="attachment"; filename="notification.png"`)
h.Set("Content-Type", "image/png")
part, err := writer.CreatePart(h)
if err != nil {
return fmt.Errorf("create attachment part: %w", err)
}
if _, err := part.Write(msg.PNGImageData); err != nil {
return fmt.Errorf("write attachment data: %w", err)
}
}
}
// Close multipart writer
if err := writer.Close(); err != nil {
return fmt.Errorf("close multipart writer: %w", err)
}
// Create request
url := c.APIURL
if url == "" {
url = "https://api.pushover.net/1/messages.json"
}
req, err := http.NewRequest("POST", url, body)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
// Use client's HTTP client or default
client := c.HTTPClient
if client == nil {
client = &http.Client{
Timeout: 30 * time.Second,
}
}
// Send request
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
// Check response
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("pushover returned status %d: %s", resp.StatusCode, string(respBody))
}
log.Printf("[INFO] pushover notification sent: title=%s", msg.Title)
return nil
}
// SetPriority sets the default message priority.
func (c *PushoverClient) SetPriority(priority int) {
if priority >= -2 && priority <= 2 {
c.Priority = priority
}
}
// SetSound sets the notification sound.
func (c *PushoverClient) SetSound(sound string) {
c.Sound = sound
}
// SetDevice sets the target device.
func (c *PushoverClient) SetDevice(device string) {
c.Device = device
}
// SetURL sets the URL to include with notifications.
func (c *PushoverClient) SetURL(url, urlTitle string) {
c.URL = url
c.URLTitle = urlTitle
}
// writeField is a helper that writes a form field and logs errors.
func writeField(writer *multipart.Writer, key, value string) {
if err := writer.WriteField(key, value); err != nil {
log.Printf("[WARN] Failed to write form field %s: %v", key, err)
}
}
// AttachPNGBase64 decodes a base64 string and returns PNG data.
func AttachPNGBase64(base64Data string) ([]byte, error) {
data, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return nil, fmt.Errorf("decode base64: %w", err)
}
// Verify PNG signature
if len(data) < 8 {
return nil, fmt.Errorf("data too short to be PNG")
}
// PNG signature: 137 80 78 71 13 10 26 10
pngSig := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
for i := 0; i < 8; i++ {
if data[i] != pngSig[i] {
return nil, fmt.Errorf("data does not appear to be PNG")
}
}
return data, nil
}