// Package alerts evaluates incoming DX-cluster spots against user-defined rules // and reports which rules fire, so the app can notify the operator (sound / // visual / e-mail) when a wanted station is spotted — like Log4OM's Alert // Management. Rules are persisted as JSON (global, machine-local). package alerts import ( "encoding/json" "os" "regexp" "strconv" "strings" "sync" "time" ) // Rule is one alert definition. Every filter dimension is optional: an empty // list/string matches ANY value, and the dimensions are ANDed together, so a // rule with Countries=[France] and Bands=[20m] fires only for French stations // on 20m. Calls / SpotterCall accept wildcards (IW3*, */P). type Rule struct { ID string `json:"id"` Name string `json:"name"` Enabled bool `json:"enabled"` // DX filters. Calls []string `json:"calls,omitempty"` // wildcard patterns on the DX call Countries []string `json:"countries,omitempty"` // DXCC entity names Continents []string `json:"continents,omitempty"` // AF/AN/AS/EU/NA/OC/SA // Band / mode filters. Bands []string `json:"bands,omitempty"` Modes []string `json:"modes,omitempty"` // Spotter (origin) filters. SpotterCall string `json:"spotter_call,omitempty"` // wildcard SpotterContinents []string `json:"spotter_continents,omitempty"` SpotterCountries []string `json:"spotter_countries,omitempty"` // Actions. Sound bool `json:"sound"` Visual bool `json:"visual"` Email bool `json:"email"` // Throttling: minutes to wait before re-alerting the same callsign. 0 = only // once per session, -1 = always, >0 = that many minutes. AgainAfterMin int `json:"again_after_min"` // Skip a spot whose DX call is already worked on this band+mode. SkipWorked bool `json:"skip_worked"` } // Spot is the subset of a cluster spot the engine matches against. type Spot struct { DXCall string Band string Mode string // inferred (see InferMode) Country string // DXCC entity name Continent string Spotter string SpotterCountry string SpotterContinent string } // Store persists the rule set as a JSON file. type Store struct { mu sync.Mutex path string rules []Rule // last fired time per (ruleID|call), for the AgainAfter throttle. lastFired map[string]time.Time } // Open loads the store (empty when the file is absent). func Open(path string) (*Store, error) { s := &Store{path: path, lastFired: map[string]time.Time{}} b, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return s, nil } return nil, err } if len(b) > 0 { _ = json.Unmarshal(b, &s.rules) // corrupt file → start empty } return s, nil } func (s *Store) save() error { b, err := json.MarshalIndent(s.rules, "", " ") if err != nil { return err } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, b, 0o644); err != nil { return err } return os.Rename(tmp, s.path) } // List returns a copy of all rules. func (s *Store) List() []Rule { s.mu.Lock() defer s.mu.Unlock() out := make([]Rule, len(s.rules)) copy(out, s.rules) return out } func newID() string { return strconv.FormatInt(time.Now().UnixNano(), 36) } // Save upserts a rule (creating an id when empty) and returns it. func (s *Store) Save(r Rule) (Rule, error) { s.mu.Lock() defer s.mu.Unlock() if strings.TrimSpace(r.ID) == "" { r.ID = newID() s.rules = append(s.rules, r) } else { found := false for i := range s.rules { if s.rules[i].ID == r.ID { s.rules[i] = r found = true break } } if !found { s.rules = append(s.rules, r) } } return r, s.save() } // Delete removes a rule by id. func (s *Store) Delete(id string) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.rules { if s.rules[i].ID == id { s.rules = append(s.rules[:i], s.rules[i+1:]...) return s.save() } } return nil } // Match is a rule that fired for a spot (returned by Evaluate). type Match struct { Rule Rule Spot Spot } // Evaluate returns every enabled rule that matches the spot AND isn't currently // throttled. It records the fire time for matched rules so the AgainAfter window // is honoured. workedFn (may be nil) reports whether the DX call is already // worked on this band+mode — used by rules with SkipWorked. func (s *Store) Evaluate(sp Spot, now time.Time, workedFn func(call, band, mode string) bool) []Match { s.mu.Lock() defer s.mu.Unlock() var out []Match for _, r := range s.rules { if !r.Enabled || !ruleMatches(r, sp) { continue } if r.SkipWorked && workedFn != nil && workedFn(sp.DXCall, sp.Band, sp.Mode) { continue } key := r.ID + "|" + strings.ToUpper(sp.DXCall) if r.AgainAfterMin >= 0 { last, seen := s.lastFired[key] if seen { if r.AgainAfterMin == 0 { continue // once per session } if now.Sub(last) < time.Duration(r.AgainAfterMin)*time.Minute { continue } } } s.lastFired[key] = now out = append(out, Match{Rule: r, Spot: sp}) } return out } // ruleMatches reports whether every specified filter dimension matches the spot. func ruleMatches(r Rule, sp Spot) bool { if len(r.Calls) > 0 && !anyWildcard(r.Calls, sp.DXCall) { return false } if len(r.Countries) > 0 && !containsFold(r.Countries, sp.Country) { return false } if len(r.Continents) > 0 && !containsFold(r.Continents, sp.Continent) { return false } if len(r.Bands) > 0 && !containsFold(r.Bands, sp.Band) { return false } if len(r.Modes) > 0 && !containsFold(r.Modes, sp.Mode) { return false } if strings.TrimSpace(r.SpotterCall) != "" && !matchWildcard(r.SpotterCall, sp.Spotter) { return false } if len(r.SpotterContinents) > 0 && !containsFold(r.SpotterContinents, sp.SpotterContinent) { return false } if len(r.SpotterCountries) > 0 && !containsFold(r.SpotterCountries, sp.SpotterCountry) { return false } return true } func containsFold(list []string, v string) bool { v = strings.TrimSpace(v) if v == "" { return false } for _, x := range list { if strings.EqualFold(strings.TrimSpace(x), v) { return true } } return false } func anyWildcard(patterns []string, v string) bool { for _, p := range patterns { if matchWildcard(p, v) { return true } } return false } // matchWildcard does a case-insensitive full-string match where '*' matches any // run of characters and '?' matches one (e.g. "IW3*", "*/P", "F?BPO"). func matchWildcard(pattern, v string) bool { pattern = strings.TrimSpace(pattern) v = strings.TrimSpace(v) if pattern == "" { return true } var b strings.Builder b.WriteString("(?i)^") for _, r := range pattern { switch r { case '*': b.WriteString(".*") case '?': b.WriteString(".") default: b.WriteString(regexp.QuoteMeta(string(r))) } } b.WriteString("$") re, err := regexp.Compile(b.String()) if err != nil { return strings.EqualFold(pattern, v) } return re.MatchString(v) } // InferMode guesses a spot's mode from its comment and frequency. Cluster spots // don't carry a mode field, so we read common tags (FT8/FT4/CW/RTTY/…) then fall // back to the digital watering holes and the band-plan CW/phone split. func InferMode(comment string, freqHz int64) string { c := strings.ToUpper(comment) switch { case strings.Contains(c, "FT8"): return "FT8" case strings.Contains(c, "FT4"): return "FT4" case strings.Contains(c, "RTTY"): return "RTTY" case strings.Contains(c, "PSK"): return "PSK" case strings.Contains(c, "JS8"): return "JS8" case strings.Contains(c, "CW"): return "CW" case strings.Contains(c, "SSB") || strings.Contains(c, "USB") || strings.Contains(c, "LSB") || strings.Contains(c, "PH"): return "SSB" } khz := float64(freqHz) / 1000 // FT8 watering holes (…074) and FT4 (…080/…140) as a fallback. for _, f := range []float64{1840, 3573, 7074, 10136, 14074, 18100, 21074, 24915, 28074, 50313} { if khz >= f-1 && khz <= f+3 { return "FT8" } } // Band-plan CW segments (bottom of each band). switch { case khz >= 1810 && khz <= 1840, khz >= 3500 && khz <= 3570, khz >= 7000 && khz <= 7040, khz >= 10100 && khz <= 10130, khz >= 14000 && khz <= 14070, khz >= 18068 && khz <= 18095, khz >= 21000 && khz <= 21070, khz >= 24890 && khz <= 24910, khz >= 28000 && khz <= 28070: return "CW" } return "SSB" }