300 lines
7.4 KiB
Go
300 lines
7.4 KiB
Go
package rules
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
|
)
|
|
|
|
type Evaluator struct{}
|
|
|
|
func NewEvaluator() *Evaluator {
|
|
return &Evaluator{}
|
|
}
|
|
|
|
// Evaluate checks whether a FeedEntry satisfies a Rule's conditions.
|
|
func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool {
|
|
if len(rule.Conditions) == 0 {
|
|
return true
|
|
}
|
|
|
|
matchMode := strings.ToLower(rule.MatchMode)
|
|
if matchMode == "" || matchMode == "all" {
|
|
for _, cond := range rule.Conditions {
|
|
if !e.evaluateCondition(cond, entry) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// matchMode == "any"
|
|
for _, cond := range rule.Conditions {
|
|
if e.evaluateCondition(cond, entry) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (e *Evaluator) evaluateCondition(cond models.RuleCondition, entry models.FeedEntry) bool {
|
|
actualValue := e.resolveField(cond.Field, entry)
|
|
return e.compare(actualValue, cond.Operator, cond.Value)
|
|
}
|
|
|
|
func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface{} {
|
|
switch strings.ToLower(field) {
|
|
case "feed_id":
|
|
return entry.FeedID
|
|
case "feed_title":
|
|
return entry.FeedTitle
|
|
case "title":
|
|
return entry.Title
|
|
case "author":
|
|
return entry.Author
|
|
case "url":
|
|
return entry.URL
|
|
case "content", "raw_content", "rss_content":
|
|
var parts []string
|
|
if entry.Content != "" {
|
|
parts = append(parts, entry.Content)
|
|
}
|
|
if entry.RawContent != "" && entry.RawContent != entry.Content {
|
|
parts = append(parts, entry.RawContent)
|
|
}
|
|
if entry.Variants != nil {
|
|
if v, ok := entry.Variants["readability_full_text"]; ok && v.TextValue != "" {
|
|
parts = append(parts, v.TextValue)
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
case "is_read":
|
|
return entry.IsRead
|
|
case "is_starred":
|
|
return entry.IsStarred
|
|
case "published_at":
|
|
return entry.PublishedAt
|
|
case "created_at":
|
|
return entry.CreatedAt
|
|
}
|
|
|
|
// Variant field resolution: "variants.keywords", "variants.readability_full_text", "variants.tags"
|
|
if strings.HasPrefix(field, "variants.") {
|
|
variantKey := strings.TrimPrefix(field, "variants.")
|
|
if entry.Variants != nil {
|
|
if v, ok := entry.Variants[variantKey]; ok {
|
|
return v.TextValue
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *Evaluator) compare(actual interface{}, op string, expected interface{}) bool {
|
|
opLower := strings.ToLower(strings.TrimSpace(op))
|
|
|
|
// Handle exists / not_exists operators
|
|
if opLower == "exists" {
|
|
if actual == nil {
|
|
return false
|
|
}
|
|
if s, ok := actual.(string); ok && s == "" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
if opLower == "not_exists" {
|
|
if actual == nil {
|
|
return true
|
|
}
|
|
if s, ok := actual.(string); ok && s == "" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
if actual == nil {
|
|
return opLower == "neq" || opLower == "!=" || opLower == "not_in" || opLower == "not_contains"
|
|
}
|
|
|
|
// Handle Time/Timestamp comparisons
|
|
if actualTime, ok := actual.(time.Time); ok {
|
|
return e.compareTime(actualTime, opLower, expected)
|
|
}
|
|
|
|
actualStr := fmt.Sprintf("%v", actual)
|
|
expectedStr := fmt.Sprintf("%v", expected)
|
|
|
|
switch opLower {
|
|
case "eq", "==":
|
|
return strings.EqualFold(actualStr, expectedStr)
|
|
case "neq", "!=":
|
|
return !strings.EqualFold(actualStr, expectedStr)
|
|
case "contains":
|
|
expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n")
|
|
return strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedClean))
|
|
case "not_contains":
|
|
expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n")
|
|
return !strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedClean))
|
|
case "starts_with":
|
|
expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n")
|
|
return strings.HasPrefix(strings.ToLower(actualStr), strings.ToLower(expectedClean))
|
|
case "ends_with":
|
|
expectedClean := strings.Trim(expectedStr, "\"'` \t\r\n")
|
|
return strings.HasSuffix(strings.ToLower(actualStr), strings.ToLower(expectedClean))
|
|
case "contains_any":
|
|
tokens := parseStringList(expected)
|
|
for _, token := range tokens {
|
|
if strings.Contains(strings.ToLower(actualStr), strings.ToLower(token)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
case "in":
|
|
tokens := parseStringList(expected)
|
|
for _, item := range tokens {
|
|
if strings.EqualFold(actualStr, item) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
case "not_in":
|
|
tokens := parseStringList(expected)
|
|
for _, item := range tokens {
|
|
if strings.EqualFold(actualStr, item) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
case "matches", "regex":
|
|
re, err := regexp.Compile(expectedStr)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return re.MatchString(actualStr)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (e *Evaluator) compareTime(t time.Time, op string, expected interface{}) bool {
|
|
now := time.Now()
|
|
switch op {
|
|
case "within_last_hours":
|
|
hours, err := toFloat64(expected)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
cutoff := now.Add(-time.Duration(hours * float64(time.Hour)))
|
|
return t.After(cutoff)
|
|
case "older_than_days":
|
|
days, err := toFloat64(expected)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
cutoff := now.Add(-time.Duration(days * 24 * float64(time.Hour)))
|
|
return t.Before(cutoff)
|
|
case ">=", "after":
|
|
targetTime, err := parseTime(expected)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return t.After(targetTime) || t.Equal(targetTime)
|
|
case "<=", "before":
|
|
targetTime, err := parseTime(expected)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return t.Before(targetTime) || t.Equal(targetTime)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseStringList(val interface{}) []string {
|
|
var result []string
|
|
clean := func(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.Trim(s, "\"'`")
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
switch v := val.(type) {
|
|
case []interface{}:
|
|
for _, item := range v {
|
|
itemStr := clean(fmt.Sprintf("%v", item))
|
|
if itemStr != "" {
|
|
result = append(result, itemStr)
|
|
}
|
|
}
|
|
case []string:
|
|
for _, item := range v {
|
|
itemStr := clean(item)
|
|
if itemStr != "" {
|
|
result = append(result, itemStr)
|
|
}
|
|
}
|
|
case string:
|
|
parts := strings.Split(v, ",")
|
|
for _, part := range parts {
|
|
trimmed := clean(part)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func toFloat64(val interface{}) (float64, error) {
|
|
switch v := val.(type) {
|
|
case float64:
|
|
return v, nil
|
|
case int:
|
|
return float64(v), nil
|
|
case int64:
|
|
return float64(v), nil
|
|
case string:
|
|
return strconv.ParseFloat(strings.TrimSpace(v), 64)
|
|
}
|
|
return 0, fmt.Errorf("cannot convert %v to float64", val)
|
|
}
|
|
|
|
func parseTime(val interface{}) (time.Time, error) {
|
|
if t, ok := val.(time.Time); ok {
|
|
return t, nil
|
|
}
|
|
s := fmt.Sprintf("%v", val)
|
|
return time.Parse(time.RFC3339, s)
|
|
}
|
|
|
|
// ValidateRuleSafety checks for potential cyclic/endless loops at rule creation time.
|
|
func ValidateRuleSafety(rule models.Rule) error {
|
|
trigger := strings.ToLower(rule.Trigger)
|
|
|
|
// If trigger is on_variant_updated / variant_change, check for recursive set_variant on same key
|
|
if trigger == "on_variant_updated" || trigger == "variant_change" {
|
|
for _, action := range rule.Actions {
|
|
if action.Type == "set_variant" {
|
|
targetKey, _ := action.Params["variant_key"].(string)
|
|
if targetKey == "" {
|
|
targetKey, _ = action.Params["key"].(string)
|
|
}
|
|
|
|
// Check if trigger targets this exact key without safe conditions
|
|
for _, cond := range rule.Conditions {
|
|
if cond.Field == "variants."+targetKey && (cond.Operator == "exists" || cond.Operator == "eq") {
|
|
return fmt.Errorf("cyclic rule detected: rule triggers on variant '%s' and unconditionally writes to '%s'", targetKey, targetKey)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|