// Package alert owns durable, one-shot fall-event evidence. package alert import ( "encoding/json" "fmt" "image" "image/png" "os" "path/filepath" "regexp" "sync" "time" "silverpose/v2/internal/fall" ) var eventIDPattern = regexp.MustCompile(`^[A-Za-z0-9-]+$`) type Record struct { Event fall.Event ScreenshotPath string LogPath string CreatedAtUTC time.Time Written bool } // Dispatcher de-duplicates events before writing to avoid screenshot overwrite, // duplicate JSONL records, duplicate sound, or duplicate popups downstream. type Dispatcher struct { eventDirectory string sourceID string mu sync.Mutex seen map[string]struct{} } func NewDispatcher(eventDirectory, sourceID string) *Dispatcher { return &Dispatcher{ eventDirectory: eventDirectory, sourceID: sourceID, seen: make(map[string]struct{}), } } func (dispatcher *Dispatcher) Dispatch(event fall.Event, canvas image.Image, now time.Time) (Record, error) { if event.State != fall.Confirmed { return Record{}, fmt.Errorf("only confirmed events can be persisted") } if !eventIDPattern.MatchString(event.EventID) { return Record{}, fmt.Errorf("event ID is invalid") } if canvas == nil { return Record{}, fmt.Errorf("event screenshot is missing") } if now.IsZero() { now = time.Now() } now = now.UTC() dispatcher.mu.Lock() if _, alreadyWritten := dispatcher.seen[event.EventID]; alreadyWritten { dispatcher.mu.Unlock() return Record{Event: event, Written: false}, nil } dispatcher.seen[event.EventID] = struct{}{} dispatcher.mu.Unlock() record, err := dispatcher.write(event, canvas, now) if err != nil { dispatcher.mu.Lock() delete(dispatcher.seen, event.EventID) dispatcher.mu.Unlock() return Record{}, err } return record, nil } func (dispatcher *Dispatcher) write(event fall.Event, canvas image.Image, now time.Time) (Record, error) { if dispatcher.eventDirectory == "" || dispatcher.sourceID == "" { return Record{}, fmt.Errorf("event directory and source ID must be configured") } dayDirectory := filepath.Join(dispatcher.eventDirectory, now.Format("20060102")) if err := os.MkdirAll(dayDirectory, 0o755); err != nil { return Record{}, fmt.Errorf("create event directory: %w", err) } screenshotPath := filepath.Join(dayDirectory, event.EventID+".png") file, err := os.Create(screenshotPath) if err != nil { return Record{}, fmt.Errorf("create screenshot: %w", err) } encodeErr := png.Encode(file, canvas) closeErr := file.Close() if encodeErr != nil || closeErr != nil { _ = os.Remove(screenshotPath) if encodeErr != nil { return Record{}, fmt.Errorf("encode screenshot: %w", encodeErr) } return Record{}, fmt.Errorf("close screenshot: %w", closeErr) } logPath := filepath.Join(dayDirectory, "events.jsonl") relativeScreenshot := filepath.ToSlash(filepath.Join(now.Format("20060102"), event.EventID+".png")) line, err := json.Marshal(struct { EventID string `json:"event_id"` TrackID string `json:"track_id"` ConfigVersion string `json:"config_version"` SourceID string `json:"source_id"` State fall.State `json:"state"` ConfirmedAtUTC string `json:"confirmed_at_utc"` SuspectedAtMonotonic float64 `json:"suspected_at_monotonic"` ConfirmedAtMonotonic float64 `json:"confirmed_at_monotonic"` LatencySeconds float64 `json:"latency_seconds"` Screenshot string `json:"screenshot"` }{ EventID: event.EventID, TrackID: event.TrackID, ConfigVersion: event.ConfigVersion, SourceID: dispatcher.sourceID, State: event.State, ConfirmedAtUTC: now.Format(time.RFC3339Nano), SuspectedAtMonotonic: event.SuspectedAtMonotonic, ConfirmedAtMonotonic: event.ConfirmedAtMonotonic, LatencySeconds: event.LatencySeconds, Screenshot: relativeScreenshot, }) if err != nil { _ = os.Remove(screenshotPath) return Record{}, fmt.Errorf("encode event record: %w", err) } log, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { _ = os.Remove(screenshotPath) return Record{}, fmt.Errorf("open event log: %w", err) } if _, err := log.Write(append(line, '\n')); err != nil { _ = log.Close() _ = os.Remove(screenshotPath) return Record{}, fmt.Errorf("write event log: %w", err) } if err := log.Close(); err != nil { return Record{}, fmt.Errorf("close event log: %w", err) } return Record{ Event: event, ScreenshotPath: screenshotPath, LogPath: logPath, CreatedAtUTC: now, Written: true, }, nil }