feat: implement entry variant tagging, backend action handlers, batch variant loading, and rule application API.
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/opml"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/parser"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/rules"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -662,8 +663,13 @@ func (h *Handler) CreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.TrimSpace(body.Trigger) == "" {
|
||||
body.Trigger = "on_post_created"
|
||||
}
|
||||
if strings.TrimSpace(body.MatchMode) == "" {
|
||||
body.MatchMode = "all"
|
||||
if err := rules.ValidateRuleSafety(models.Rule{
|
||||
Trigger: body.Trigger,
|
||||
Conditions: body.Conditions,
|
||||
Actions: body.Actions,
|
||||
}); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := h.db.CreateRule(body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions)
|
||||
@@ -691,6 +697,15 @@ func (h *Handler) UpdateRule(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := rules.ValidateRuleSafety(models.Rule{
|
||||
Trigger: body.Trigger,
|
||||
Conditions: body.Conditions,
|
||||
Actions: body.Actions,
|
||||
}); err != nil {
|
||||
h.writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := h.db.UpdateRule(id, body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions, body.IsActive)
|
||||
if err != nil {
|
||||
h.writeError(w, http.StatusInternalServerError, err.Error())
|
||||
|
||||
+169
-28
@@ -3,7 +3,9 @@ package rules
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||
)
|
||||
@@ -56,12 +58,19 @@ func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface
|
||||
return entry.Author
|
||||
case "url":
|
||||
return entry.URL
|
||||
case "content", "rss_content":
|
||||
return entry.Content
|
||||
case "content", "raw_content", "rss_content":
|
||||
if entry.Content != "" {
|
||||
return entry.Content
|
||||
}
|
||||
return entry.RawContent
|
||||
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"
|
||||
@@ -78,14 +87,41 @@ func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface
|
||||
}
|
||||
|
||||
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 op == "neq"
|
||||
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 strings.ToLower(op) {
|
||||
switch opLower {
|
||||
case "eq", "==":
|
||||
return strings.EqualFold(actualStr, expectedStr)
|
||||
case "neq", "!=":
|
||||
@@ -94,33 +130,34 @@ func (e *Evaluator) compare(actual interface{}, op string, expected interface{})
|
||||
return strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedStr))
|
||||
case "not_contains":
|
||||
return !strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedStr))
|
||||
case "in":
|
||||
// expected can be []interface{}, []string, or comma-separated string
|
||||
switch v := expected.(type) {
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
if strings.EqualFold(actualStr, fmt.Sprintf("%v", item)) {
|
||||
return true
|
||||
}
|
||||
case "starts_with":
|
||||
return strings.HasPrefix(strings.ToLower(actualStr), strings.ToLower(expectedStr))
|
||||
case "ends_with":
|
||||
return strings.HasSuffix(strings.ToLower(actualStr), strings.ToLower(expectedStr))
|
||||
case "contains_any":
|
||||
tokens := parseStringList(expected)
|
||||
for _, token := range tokens {
|
||||
if strings.Contains(strings.ToLower(actualStr), strings.ToLower(token)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case []string:
|
||||
for _, item := range v {
|
||||
if strings.EqualFold(actualStr, item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case string:
|
||||
items := strings.Split(v, ",")
|
||||
for _, item := range items {
|
||||
if strings.EqualFold(strings.TrimSpace(actualStr), strings.TrimSpace(item)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
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 {
|
||||
@@ -131,3 +168,107 @@ func (e *Evaluator) compare(actual interface{}, op string, expected interface{})
|
||||
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
|
||||
switch v := val.(type) {
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
result = append(result, strings.TrimSpace(fmt.Sprintf("%v", item)))
|
||||
}
|
||||
case []string:
|
||||
for _, item := range v {
|
||||
result = append(result, strings.TrimSpace(item))
|
||||
}
|
||||
case string:
|
||||
parts := strings.Split(v, ",")
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(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
|
||||
}
|
||||
|
||||
@@ -25,16 +25,17 @@ func TestRuleEvaluator(t *testing.T) {
|
||||
TextValue: `["go", "compiler", "release"]`,
|
||||
},
|
||||
},
|
||||
PublishedAt: time.Now(),
|
||||
PublishedAt: time.Now().Add(-2 * time.Hour),
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
}
|
||||
|
||||
// Test 1: Title contains Go AND FeedID == feed-hn
|
||||
// Test 1: Title contains Go AND FeedID in list
|
||||
rule1 := models.Rule{
|
||||
ID: "r1",
|
||||
Name: "Tech Feeds",
|
||||
Name: "Tech Feeds in list",
|
||||
MatchMode: "all",
|
||||
Conditions: []models.RuleCondition{
|
||||
{Field: "feed_id", Operator: "eq", Value: "feed-hn"},
|
||||
{Field: "feed_id", Operator: "in", Value: []string{"feed-hn", "feed-36kr", "feed-ft"}},
|
||||
{Field: "title", Operator: "contains", Value: "Go 1.24"},
|
||||
},
|
||||
}
|
||||
@@ -42,29 +43,76 @@ func TestRuleEvaluator(t *testing.T) {
|
||||
t.Error("expected rule1 to match entry")
|
||||
}
|
||||
|
||||
// Test 2: Negative condition (author != Python Team)
|
||||
// Test 2: not_in operator
|
||||
rule2 := models.Rule{
|
||||
ID: "r2",
|
||||
Name: "Author check",
|
||||
Name: "Feed not in noise",
|
||||
MatchMode: "all",
|
||||
Conditions: []models.RuleCondition{
|
||||
{Field: "author", Operator: "neq", Value: "Python Team"},
|
||||
{Field: "feed_id", Operator: "not_in", Value: "feed-spam,feed-ads"},
|
||||
},
|
||||
}
|
||||
if !eval.Evaluate(rule2, entry) {
|
||||
t.Error("expected rule2 to match entry")
|
||||
}
|
||||
|
||||
// Test 3: Variant matching (variants.keywords contains compiler)
|
||||
// Test 3: contains_any operator
|
||||
rule3 := models.Rule{
|
||||
ID: "r3",
|
||||
Name: "Keywords check",
|
||||
Name: "Title match any keyword",
|
||||
MatchMode: "all",
|
||||
Conditions: []models.RuleCondition{
|
||||
{Field: "variants.keywords", Operator: "contains", Value: "compiler"},
|
||||
{Field: "title", Operator: "contains_any", Value: "Rust, Python, Go 1.24"},
|
||||
},
|
||||
}
|
||||
if !eval.Evaluate(rule3, entry) {
|
||||
t.Error("expected rule3 to match entry variant")
|
||||
t.Error("expected rule3 to match entry with contains_any")
|
||||
}
|
||||
|
||||
// Test 4: exists operator on variants
|
||||
rule4 := models.Rule{
|
||||
ID: "r4",
|
||||
Name: "Keywords exist",
|
||||
MatchMode: "all",
|
||||
Conditions: []models.RuleCondition{
|
||||
{Field: "variants.keywords", Operator: "exists"},
|
||||
{Field: "variants.nonexistent", Operator: "not_exists"},
|
||||
},
|
||||
}
|
||||
if !eval.Evaluate(rule4, entry) {
|
||||
t.Error("expected rule4 to evaluate variant existence correctly")
|
||||
}
|
||||
|
||||
// Test 5: Timestamp within_last_hours
|
||||
rule5 := models.Rule{
|
||||
ID: "r5",
|
||||
Name: "Recent article",
|
||||
MatchMode: "all",
|
||||
Conditions: []models.RuleCondition{
|
||||
{Field: "published_at", Operator: "within_last_hours", Value: 24},
|
||||
},
|
||||
}
|
||||
if !eval.Evaluate(rule5, entry) {
|
||||
t.Error("expected rule5 to match article within last 24h")
|
||||
}
|
||||
|
||||
// Test 6: Safety validator
|
||||
unsafeRule := models.Rule{
|
||||
Trigger: "variant_change",
|
||||
Conditions: []models.RuleCondition{
|
||||
{Field: "variants.tags", Operator: "exists"},
|
||||
},
|
||||
Actions: []models.RuleAction{
|
||||
{
|
||||
Type: "set_variant",
|
||||
Params: map[string]interface{}{
|
||||
"variant_key": "tags",
|
||||
"value": "new-tag",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := ValidateRuleSafety(unsafeRule); err == nil {
|
||||
t.Error("expected ValidateRuleSafety to detect cyclic loop")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user