// Copyright (C) 2025 wangyusong // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package rule import ( "strings" "time" "github.com/pkg/errors" "github.com/glidea/zenfeed/pkg/component" "github.com/glidea/zenfeed/pkg/model" "github.com/glidea/zenfeed/pkg/storage/feed" "github.com/glidea/zenfeed/pkg/storage/feed/block" ) // --- Interface code block --- type Rule interface { component.Component Config() *Config } type Config struct { Name string Query string Threshold float32 LabelFilters []string Labels map[string]string labels model.Labels // Periodic type. EveryDay string // e.g. "00:00~23:59", or "-22:00~7:00" (yesterday 22:00 to today 07:00) start, end time.Time crossDay bool // Watch type. WatchInterval time.Duration } var ( timeSep = "~" timeYesterdayPrefix = "-" timeFmt = "15:04" ) func (c *Config) Validate() error { //nolint:cyclop,gocognit if c.Name == "" { return errors.New("name is required") } if c.Threshold == 0 { c.Threshold = 0.5 } if c.Threshold < 0 || c.Threshold > 1 { return errors.New("threshold must be between 0 and 1") } if len(c.Labels) > 0 { c.labels.FromMap(c.Labels) } if c.EveryDay != "" && c.WatchInterval != 0 { return errors.New("every_day and watch_interval cannot both be set") } switch c.EveryDay { case "": if c.WatchInterval < 10*time.Minute { c.WatchInterval = 10 * time.Minute } default: times := strings.Split(c.EveryDay, timeSep) if len(times) != 2 { return errors.New("every_day must be in format 'start~end'") } start, end := strings.TrimSpace(times[0]), strings.TrimSpace(times[1]) isYesterday := strings.HasPrefix(start, timeYesterdayPrefix) if isYesterday { start = start[1:] // Remove the "-" prefix c.crossDay = true } // Parse start time. startTime, err := time.ParseInLocation(timeFmt, start, time.Local) if err != nil { return errors.Wrap(err, "parse start time") } // Parse end time. endTime, err := time.ParseInLocation(timeFmt, end, time.Local) if err != nil { return errors.Wrap(err, "parse end time") } // For non-yesterday time range, end time must be after start time. if !isYesterday && endTime.Before(startTime) { return errors.New("end time must be after start time") } c.start, c.end = startTime, endTime } return nil } type Dependencies struct { FeedStorage feed.Storage Out chan<- *Result } type Result struct { Rule string Time time.Time Feeds []*block.FeedVO } // --- Factory code block --- type Factory component.Factory[Rule, Config, Dependencies] func NewFactory(mockOn ...component.MockOption) Factory { if len(mockOn) > 0 { return component.FactoryFunc[Rule, Config, Dependencies]( func(instance string, config *Config, dependencies Dependencies) (Rule, error) { m := &mockRule{} component.MockOptions(mockOn).Apply(&m.Mock) return m, nil }, ) } return component.FactoryFunc[Rule, Config, Dependencies](new) } func new(instance string, config *Config, dependencies Dependencies) (Rule, error) { if err := config.Validate(); err != nil { return nil, errors.Wrap(err, "validate config") } switch config.EveryDay { case "": return newWatch(instance, config, dependencies) default: return newPeriodic(instance, config, dependencies) } } // --- Implementation code block --- type mockRule struct { component.Mock } func (m *mockRule) Config() *Config { args := m.Called() return args.Get(0).(*Config) }