Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ gem "multi_json"
# HTML/XML parsing
gem "nokogiri"

# RSS/Atom feed parsing
gem "rss"

# LLM integration
gem "ruby_llm"

Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,8 @@ GEM
uber (< 0.2.0)
retriable (3.4.1)
rexml (3.4.4)
rss (0.3.2)
rexml
rubocop (1.85.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
Expand Down Expand Up @@ -520,6 +522,7 @@ DEPENDENCIES
propshaft
puma (>= 5.0)
rails (~> 8.1.2)
rss
rubocop-rails-omakase
ruby_llm
solid_cache
Expand Down Expand Up @@ -670,6 +673,7 @@ CHECKSUMS
representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
retriable (3.4.1) sha256=fb3f114b7d492121c158c01f3d5152b5a615c5b70d5877d0bc08c7ec3725c3bc
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
rss (0.3.2) sha256=3bd0446d32d832cda00ba07f4b179401f903b52ea1fdaac0f1f08de61a501efa
rubocop (1.85.1) sha256=3dbcf9e961baa4c376eeeb2a03913dca5e3987033b04d38fa538aa1e7406cc77
rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
Expand Down
122 changes: 122 additions & 0 deletions lib/r3x/triggers/rss.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
require "rss"

module R3x
module Triggers
class Rss < Base
include Concerns::CronSchedulable
include Concerns::ChangeDetecting

validates :url, presence: true
validates_with Validators::Url, url_field: :url

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use an ActiveModel validator for RSS URL checks

validates_with Validators::Url, url_field: :url wires R3x::Validators::Url into ActiveModel, but that class is a utility with only .validate! and does not implement the ActiveModel::Validator interface (validate(record) with option-aware initialization). In practice this causes RSS trigger validation/loading to fail when trigger :rss is declared, so workflows using this new trigger cannot be registered reliably.

Useful? React with 👍 / 👎.

Comment on lines +9 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate cron on the RSS trigger before registration

This trigger is marked CronSchedulable and stores cron in options, but it never validates cron presence or format. That lets invalid/blank cron values pass DSL validation and only fail later when recurring tasks are created from trigger.cron, which breaks the expected fail-fast configuration behavior for schedulable triggers.

Useful? React with 👍 / 👎.


def initialize(url: nil, cron: nil, **options)
normalized_url = url.is_a?(String) ? url.strip : url
normalized_cron = cron.is_a?(String) ? cron.strip : cron
super(:rss, url: normalized_url, cron: normalized_cron, **options)
end

def url
options[:url]
end

def cron
options[:cron]
end

def unique_key
"rss:#{Digest::SHA256.hexdigest(url)[0..15]}"
end

def detect_changes(workflow_key:, state:)
response = Faraday.get(url)
raise Faraday::Error, "HTTP #{response.status}" unless response.success?

feed = RSS::Parser.parse(response.body, false)

current_links = extract_links(feed)
seen_links = (state[:seen_links] || []).map(&:to_s)
new_links = current_links - seen_links

if new_links.empty?
{ changed: false, state: { seen_links: current_links }, payload: nil }
else
new_items = feed.items.select { |item|
link = extract_link(item)
new_links.include?(link)
}

{
changed: true,
state: { seen_links: current_links },
payload: {
feed_title: extract_feed_title(feed),
feed_url: url,
new_items: new_items.map { |item|
{
title: extract_title(item),
link: extract_link(item),
published_at: extract_published(item),
description: extract_description(item)
}
}
}
}
end
end

private

def extract_links(feed)
feed.items.map { |item| extract_link(item) }.compact
end

def extract_link(item)
if item.respond_to?(:link) && item.link.is_a?(RSS::Atom::Feed::Link)
item.link.href
elsif item.respond_to?(:link) && item.link.is_a?(String)
item.link.presence
elsif item.respond_to?(:guid) && item.guid
item.guid.content
end
end

def extract_feed_title(feed)
title = if feed.respond_to?(:channel) && feed.channel
feed.channel.title
else
feed.title
end
extract_text(title)
end

def extract_title(item)
extract_text(item.title)
end

def extract_text(value)
return nil if value.nil?
value.respond_to?(:content) ? value.content : value.to_s
end

def extract_published(item)
if item.respond_to?(:pubDate) && item.pubDate
item.pubDate.to_s
elsif item.respond_to?(:published) && item.published
item.published.to_s
elsif item.respond_to?(:updated) && item.updated
item.updated.to_s
end
end

def extract_description(item)
if item.respond_to?(:description) && item.description
item.description
elsif item.respond_to?(:content) && item.content
item.content
elsif item.respond_to?(:summary) && item.summary
item.summary
end
end
end
end
end
9 changes: 9 additions & 0 deletions test/fixtures/workflows/rss_test_workflow/workflow.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module Workflows
class RssTestWorkflow < R3x::Workflow::Base
trigger :rss, url: "https://example.com/feed.xml", cron: "every 15 minutes"

def run(ctx)
ctx.trigger.payload
end
end
end
Loading