spaxel/mothership/internal/analytics/alert_handler.go
jedarden c0ae81a6b0
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
style(mothership): run go fmt to format all Go code
Ran gofmt across the entire mothership codebase to ensure consistent
code formatting per Go standards. All tests pass after formatting.
2026-05-24 15:30:02 -04:00

204 lines
5.7 KiB
Go

// Package analytics provides alert handling for anomaly detection using the notification service.
package analytics
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/spaxel/mothership/internal/events"
)
// NotificationAlertHandler implements AlertHandler using a notification service.
type NotificationAlertHandler struct {
notifyService NotificationService
httpClient *http.Client
webhookURL string
escalationURL string
}
// NotificationService is the interface needed from the notify package.
type NotificationService interface {
Send(notif Notification) error
GenerateFloorPlanThumbnail(width, height int, blobs []struct {
X, Y, Z float64
Identity string
IsFall bool
}) ([]byte, error)
}
// Notification represents a notification to send.
type Notification struct {
Title string
Body string
Priority int
Tags []string
Image []byte
ImageType string
Data map[string]interface{}
Timestamp time.Time
}
// NewNotificationAlertHandler creates a new alert handler backed by the notification service.
func NewNotificationAlertHandler(notifyService NotificationService) *NotificationAlertHandler {
return &NotificationAlertHandler{
notifyService: notifyService,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// SetWebhookURL sets the webhook URL for anomaly alerts.
func (h *NotificationAlertHandler) SetWebhookURL(url string) {
h.webhookURL = url
}
// SetEscalationURL sets the escalation webhook URL.
func (h *NotificationAlertHandler) SetEscalationURL(url string) {
h.escalationURL = url
}
// SendAlert sends an alert notification.
func (h *NotificationAlertHandler) SendAlert(event events.AnomalyEvent, immediate bool) error {
// Generate floor plan thumbnail
thumbnail, err := h.notifyService.GenerateFloorPlanThumbnail(400, 300, []struct {
X, Y, Z float64
Identity string
IsFall bool
}{
{
X: event.Position.X,
Y: event.Position.Y,
Z: event.Position.Z,
Identity: event.PersonName,
IsFall: false,
},
})
if err != nil {
log.Printf("[WARN] Failed to generate thumbnail for alert: %v", err)
}
notif := Notification{
Title: "Anomaly Detected",
Body: event.Description,
Priority: 2, // High priority for anomalies
Tags: []string{"spaxel", "anomaly"},
Image: thumbnail,
ImageType: "image/png",
Data: map[string]interface{}{
"anomaly_id": event.ID,
"anomaly_type": string(event.Type),
"zone_id": event.ZoneID,
"zone_name": event.ZoneName,
"person_id": event.PersonID,
"person_name": event.PersonName,
"timestamp": event.Timestamp.Format(time.RFC3339),
"immediate": immediate,
},
Timestamp: time.Now(),
}
// Set higher priority for security mode
if immediate {
notif.Priority = 4 // Emergency priority
notif.Tags = append(notif.Tags, "security")
}
return h.notifyService.Send(notif)
}
// SendWebhook sends a webhook notification.
func (h *NotificationAlertHandler) SendWebhook(event events.AnomalyEvent, immediate bool) error {
if h.webhookURL == "" {
return nil // No webhook configured
}
payload := map[string]interface{}{
"anomaly_id": event.ID,
"type": string(event.Type),
"score": event.Score,
"description": event.Description,
"timestamp": event.Timestamp.Format(time.RFC3339),
"zone_id": event.ZoneID,
"zone_name": event.ZoneName,
"person_id": event.PersonID,
"person_name": event.PersonName,
"device_mac": event.DeviceMAC,
"position": event.Position,
"hour_of_week": event.HourOfWeek,
"expected_occupancy": event.ExpectedOccupancy,
"immediate": immediate,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal webhook payload: %w", err)
}
req, err := http.NewRequest("POST", h.webhookURL, bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("create webhook request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Spaxel/1.0")
resp, err := h.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send webhook: %w", err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode >= 400 {
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
}
log.Printf("[INFO] Anomaly webhook sent: %s (status: %d)", event.ID, resp.StatusCode)
return nil
}
// SendEscalation sends an escalation webhook notification.
func (h *NotificationAlertHandler) SendEscalation(event events.AnomalyEvent) error {
if h.escalationURL == "" {
return nil // No escalation webhook configured
}
payload := map[string]interface{}{
"anomaly_id": event.ID,
"type": string(event.Type),
"score": event.Score,
"description": event.Description,
"timestamp": event.Timestamp.Format(time.RFC3339),
"zone_id": event.ZoneID,
"zone_name": event.ZoneName,
"person_id": event.PersonID,
"person_name": event.PersonName,
"escalation": true,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal escalation payload: %w", err)
}
req, err := http.NewRequest("POST", h.escalationURL, bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("create escalation request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Spaxel/1.0")
resp, err := h.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send escalation: %w", err)
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode >= 400 {
return fmt.Errorf("escalation returned status %d", resp.StatusCode)
}
log.Printf("[INFO] Anomaly escalation sent: %s (status: %d)", event.ID, resp.StatusCode)
return nil
}