diff --git a/app/services/decidim/chatbot/media/video_embed_extractor.rb b/app/services/decidim/chatbot/media/video_embed_extractor.rb new file mode 100644 index 0000000..2dfcb9b --- /dev/null +++ b/app/services/decidim/chatbot/media/video_embed_extractor.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Decidim + module Chatbot + module Media + # Service class to extract video URLs from HTML iframes + # Supports YouTube and Vimeo embeds + class VideoEmbedExtractor + # Regex patterns to match iframe src attributes for supported video platforms + YOUTUBE_PATTERN = %r{]+src=["\']https?://(?:www\.)?(?:youtube\.com/embed/|youtu\.be/|youtube-nocookie\.com/embed/)([a-zA-Z0-9_-]+)(?:[?&][^"\']*)?["\'][^>]*>}i + VIMEO_PATTERN = %r{]+src=["\']https?://(?:www\.)?player\.vimeo\.com/video/(\d+)(?:[?&][^"\']*)?["\'][^>]*>}i + + # Initializes a new extractor with the given HTML content + # @param html [String] The HTML string to parse for embedded videos + def initialize(html) + @html = html + end + + attr_reader :html + + def url + return nil if html.blank? + + @url ||= extract_youtube || extract_vimeo + end + + def valid? + url.present? + end + + # Returns the video thumbnail URL + # @return [String, nil] The thumbnail URL or nil if no video found + def thumbnail_url + return nil unless valid? + + @thumbnail_url ||= if youtube? + youtube_thumbnail_url + elsif vimeo? + vimeo_thumbnail_url + end + end + + private + + def video_id + @video_id ||= extract_video_id + end + + def youtube? + html.match?(YOUTUBE_PATTERN) + end + + def vimeo? + html.match?(VIMEO_PATTERN) + end + + def extract_video_id + if youtube? + html.match(YOUTUBE_PATTERN)&.[](1) + elsif vimeo? + html.match(VIMEO_PATTERN)&.[](1) + end + end + + def extract_youtube + return nil unless youtube? + + "https://www.youtube.com/watch?v=#{video_id}" + end + + def extract_vimeo + return nil unless vimeo? + + "https://vimeo.com/#{video_id}" + end + + # Returns YouTube thumbnail URL + def youtube_thumbnail_url + # hqdefault (480×360) is guaranteed to exist for every published video. + # maxresdefault (1280×720) is only generated for HD uploads and 404s otherwise. + "https://img.youtube.com/vi/#{video_id}/hqdefault.jpg" + end + + # Returns Vimeo thumbnail URL using oEmbed API pattern + # Note: This could be enhanced to fetch actual thumbnail via HTTP request + def vimeo_thumbnail_url + # Vimeo doesn't have a predictable thumbnail URL pattern like YouTube + # We'd need to make an API call to get it, but for now return nil + # or fetch it via: https://vimeo.com/api/oembed.json?url=https://vimeo.com/{video_id} + nil + end + end + end + end +end diff --git a/app/services/decidim/chatbot/providers/whatsapp/envelopes/interactive_buttons.rb b/app/services/decidim/chatbot/providers/whatsapp/envelopes/interactive_buttons.rb index 9f49c45..1e10224 100644 --- a/app/services/decidim/chatbot/providers/whatsapp/envelopes/interactive_buttons.rb +++ b/app/services/decidim/chatbot/providers/whatsapp/envelopes/interactive_buttons.rb @@ -12,25 +12,30 @@ def body interactive: { type: "button", header: {}.tap do |header| - if data[:header_text].present? - header[:type] = "text" - header[:text] = data[:header_text] + if data[:header_video].present? + header[:type] = "video" + header[:video] = { link: data[:header_video] } elsif data[:header_image].present? header[:type] = "image" header[:image] = { link: data[:header_image] } + elsif data[:header_text].present? + header[:type] = "text" + header[:text] = data[:header_text] end end, body: { text: data[:body_text] }, - footer: { text: data[:footer_text] }, + footer: { + text: data[:footer_text] + }, action: { buttons: data[:buttons].map do |button| { type: "reply", reply: { id: button[:id], title: button[:title] } } end } }.tap do |interactive| - interactive.delete(:header) if data[:header_text].blank? && data[:header_image].blank? + interactive.delete(:header) if interactive[:header].blank? interactive.delete(:footer) if data[:footer_text].blank? end ) diff --git a/app/services/decidim/chatbot/workflows/proposals_workflow.rb b/app/services/decidim/chatbot/workflows/proposals_workflow.rb index fbafda9..7cc11c8 100644 --- a/app/services/decidim/chatbot/workflows/proposals_workflow.rb +++ b/app/services/decidim/chatbot/workflows/proposals_workflow.rb @@ -39,11 +39,12 @@ def send_cards type: :interactive_carousel, body_text: body, cards: current_proposals.map do |proposal| + video = Decidim::Chatbot::Media::VideoEmbedExtractor.new(translated_attribute(proposal.body)) { id: proposal.id, title: I18n.t("decidim.chatbot.workflows.proposals.buttons.view_proposal"), body_text: sanitize_text(proposal.title, 60).presence || I18n.t("decidim.chatbot.workflows.proposals.buttons.view_proposal"), - image_url: resource_url(proposal.photo, fallback_image: true) + image_url: video.thumbnail_url.presence || resource_url(proposal.photo, fallback_image: true) } end ) @@ -52,11 +53,28 @@ def send_cards def send_proposal_details return process_unprocessable_input unless proposal - body = "*#{sanitize_text(proposal.title, 100)}*\n\n#{sanitize_text(proposal.body, 800)}\n\n#{resource_url(proposal)}" + # Check if proposal body contains a video iframe + video = Decidim::Chatbot::Media::VideoEmbedExtractor.new(translated_attribute(proposal.body)) + + # Pre-calculate title and URL to avoid redundant method calls + title_text = sanitize_text(proposal.title, 100) + proposal_url = resource_url(proposal) + + # Calculate available space for body text and sanitize accordingly + body_text = sanitize_text(proposal.body, calculate_max_body_length(video, title_text, proposal_url)) + + # Build body text with video URL if present + body = "*#{title_text}*\n\n" + body += "🎥 #{video.url}\n\n" if video.valid? + body += "#{body_text}\n\n#{proposal_url}" + + # Use video thumbnail as header image if available, otherwise use proposal photo with fallback + header_image = video.thumbnail_url.presence || resource_url(proposal.photo, fallback_image: true) + send_message!( type: :interactive_buttons, body_text: body, - header_image: resource_url(proposal.photo), + header_image:, footer_text: sanitize_text(proposal.creator_author&.presenter&.name, 60), buttons: [ { @@ -67,6 +85,27 @@ def send_proposal_details ) end + # Calculate maximum body length dynamically to stay within 1024 char limit + # @param video [VideoEmbedExtractor] The video extractor instance + # @param title_text [String] The sanitized title text + # @param proposal_url [String] The proposal URL + # @return [Integer] Maximum allowed length for body text + def calculate_max_body_length(video, title_text, proposal_url) + # WhatsApp body text limit is 1024 characters + total_limit = 1024 + + # Calculate fixed overhead using pre-calculated values + title_overhead = title_text.length + 4 # "*title*\n\n" + video_overhead = video.valid? ? video.url.length + 4 : 0 # "🎥 url\n\n" + proposal_url_overhead = proposal_url.length + 2 # "\n\nurl" + + # Reserve space for newlines and formatting + reserved_space = title_overhead + video_overhead + proposal_url_overhead + + # Return available space for body text, with minimum of 100 chars + [total_limit - reserved_space, 100].max + end + def send_continuation body = I18n.t("decidim.chatbot.workflows.proposals.remaining_proposals", count: remaining_proposals_count) send_message!( diff --git a/spec/services/media/video_embed_extractor_spec.rb b/spec/services/media/video_embed_extractor_spec.rb new file mode 100644 index 0000000..a3ce73f --- /dev/null +++ b/spec/services/media/video_embed_extractor_spec.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Chatbot + module Media + describe VideoEmbedExtractor do + describe "#url, #valid?, #thumbnail_url" do + context "when html is blank" do + it "returns nil url and is not valid" do + expect(described_class.new(nil).url).to be_nil + expect(described_class.new(nil).valid?).to be false + expect(described_class.new(nil).thumbnail_url).to be_nil + + expect(described_class.new("").url).to be_nil + expect(described_class.new("").valid?).to be false + expect(described_class.new("").thumbnail_url).to be_nil + + expect(described_class.new(" ").url).to be_nil + expect(described_class.new(" ").valid?).to be false + expect(described_class.new(" ").thumbnail_url).to be_nil + end + end + + context "when html has no video iframe" do + let(:html) do + <<-HTML +

This is a proposal with no video

+
Some content here
+ HTML + end + + it "returns nil url and is not valid" do + extractor = described_class.new(html) + expect(extractor.url).to be_nil + expect(extractor.valid?).to be false + expect(extractor.thumbnail_url).to be_nil + end + end + + context "when html contains a YouTube iframe" do + context "with standard embed format" do + let(:html) do + <<-HTML +

Check out this video:

+ +

Great video!

+ HTML + end + + it "extracts and normalizes the YouTube URL" do + extractor = described_class.new(html) + expect(extractor.url).to eq("https://www.youtube.com/watch?v=dQw4w9WgXcQ") + expect(extractor.valid?).to be true + end + + it "extracts the YouTube thumbnail URL" do + extractor = described_class.new(html) + expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg") + end + end + + context "with embed URL containing query parameters" do + let(:html) do + '' + end + + it "extracts the video ID and normalizes to watch URL" do + extractor = described_class.new(html) + expect(extractor.url).to eq("https://www.youtube.com/watch?v=dQw4w9WgXcQ") + expect(extractor.valid?).to be true + end + + it "extracts the thumbnail URL" do + extractor = described_class.new(html) + expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg") + end + end + + context "with youtu.be short URL" do + let(:html) do + '' + end + + it "extracts and normalizes the YouTube URL" do + extractor = described_class.new(html) + expect(extractor.url).to eq("https://www.youtube.com/watch?v=dQw4w9WgXcQ") + expect(extractor.valid?).to be true + end + + it "extracts the thumbnail URL" do + extractor = described_class.new(html) + expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg") + end + end + + context "with single quotes in src attribute" do + let(:html) do + "" + end + + it "extracts the YouTube URL" do + extractor = described_class.new(html) + expect(extractor.url).to eq("https://www.youtube.com/watch?v=dQw4w9WgXcQ") + expect(extractor.valid?).to be true + end + + it "extracts the thumbnail URL" do + extractor = described_class.new(html) + expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg") + end + end + + context "with a youtube-nocookie.com embed URL" do + let(:html) do + '' + end + + it "extracts and normalizes the YouTube URL" do + extractor = described_class.new(html) + expect(extractor.url).to eq("https://www.youtube.com/watch?v=dQw4w9WgXcQ") + expect(extractor.valid?).to be true + end + + it "extracts the thumbnail URL" do + extractor = described_class.new(html) + expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg") + end + end + end + + context "when html contains a Vimeo iframe" do + context "with standard embed format" do + let(:html) do + <<-HTML +

Check out this Vimeo video:

+ +

Amazing content!

+ HTML + end + + it "extracts and normalizes the Vimeo URL" do + extractor = described_class.new(html) + expect(extractor.url).to eq("https://vimeo.com/123456789") + expect(extractor.valid?).to be true + end + + it "returns nil for thumbnail_url (Vimeo requires API call)" do + extractor = described_class.new(html) + expect(extractor.thumbnail_url).to be_nil + end + end + + context "with embed URL containing query parameters" do + let(:html) do + '' + end + + it "extracts the video ID and normalizes to vimeo.com URL" do + extractor = described_class.new(html) + expect(extractor.url).to eq("https://vimeo.com/123456789") + expect(extractor.valid?).to be true + end + end + + context "with www subdomain" do + let(:html) do + '' + end + + it "extracts the Vimeo URL" do + extractor = described_class.new(html) + expect(extractor.url).to eq("https://vimeo.com/987654321") + expect(extractor.valid?).to be true + end + end + end + + context "when html contains multiple video iframes" do + let(:html) do + <<-HTML +

Check out these videos:

+ + + HTML + end + + it "returns the first video found (YouTube)" do + extractor = described_class.new(html) + expect(extractor.url).to eq("https://www.youtube.com/watch?v=dQw4w9WgXcQ") + expect(extractor.valid?).to be true + expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg") + end + end + + context "when html contains mixed content with iframe" do + let(:html) do + <<-HTML +

My Proposal Title

+

This is my proposal description with bold text and italic text.

+ + +

More text after the video.

+ HTML + end + + it "correctly extracts the video URL from mixed content" do + extractor = described_class.new(html) + expect(extractor.url).to eq("https://www.youtube.com/watch?v=test123") + expect(extractor.valid?).to be true + expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/test123/hqdefault.jpg") + end + end + + context "when iframe is not a video iframe" do + let(:html) do + '' + end + + it "returns nil url and is not valid" do + extractor = described_class.new(html) + expect(extractor.url).to be_nil + expect(extractor.valid?).to be false + expect(extractor.thumbnail_url).to be_nil + end + end + end + end + end + end +end diff --git a/spec/services/workflows/proposals_workflow_spec.rb b/spec/services/workflows/proposals_workflow_spec.rb index 55a6320..29239a1 100644 --- a/spec/services/workflows/proposals_workflow_spec.rb +++ b/spec/services/workflows/proposals_workflow_spec.rb @@ -307,6 +307,136 @@ module Workflows ) subject.start end + + context "when proposal body contains a YouTube iframe" do + let(:body_with_youtube) do + { + en: <<-HTML +

My Proposal

+

Check out this video:

+ +

More details about the proposal.

+ HTML + } + end + + before do + clicked_proposal.update!(body: body_with_youtube) + end + + it "sends interactive_buttons message with video URL in body text" do + expect(adapter).to receive(:send_message!).with( + hash_including( + type: :interactive_buttons, + buttons: [hash_including(id: "comment-#{clicked_proposal.id}")] + ) + ) do |args| + expect(args[:body_text]).to include("🎥 https://www.youtube.com/watch?v=dQw4w9WgXcQ") + end + subject.start + end + + it "uses video thumbnail as header_image" do + expect(adapter).to receive(:send_message!).with( + hash_including( + type: :interactive_buttons, + header_image: "https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg" + ) + ) + subject.start + end + + it "respects the 1024 character body limit" do + expect(adapter).to receive(:send_message!) do |args| + expect(args[:body_text].length).to be <= 1024 + end + subject.start + end + end + + context "when proposal body contains a Vimeo iframe" do + let(:body_with_vimeo) do + { + en: <<-HTML +

My Proposal

+

Check out this Vimeo video:

+ +

More details.

+ HTML + } + end + + before do + clicked_proposal.update!(body: body_with_vimeo) + end + + it "sends interactive_buttons message with video URL in body text" do + expect(adapter).to receive(:send_message!).with( + hash_including( + type: :interactive_buttons, + buttons: [hash_including(id: "comment-#{clicked_proposal.id}")] + ) + ) do |args| + expect(args[:body_text]).to include("🎥 https://vimeo.com/123456789") + end + subject.start + end + + it "falls back to proposal photo for header_image (Vimeo has no thumbnail)" do + expect(adapter).to receive(:send_message!) do |args| + expect(args[:header_image]).to be_present + expect(args[:header_image]).not_to include("vimeo") + end + subject.start + end + end + + context "when proposal body contains no video iframe" do + let(:body_without_video) do + { + en: <<-HTML +

My Proposal

+

This is a text-only proposal with no video.

+ + HTML + } + end + + before do + clicked_proposal.update!(body: body_without_video) + end + + it "sends interactive_buttons message without video URL" do + expect(adapter).to receive(:send_message!).with( + hash_including( + type: :interactive_buttons, + buttons: [hash_including(id: "comment-#{clicked_proposal.id}")] + ) + ) do |args| + expect(args[:body_text]).not_to include("🎥") + expect(args[:body_text]).not_to include("youtube.com") + expect(args[:body_text]).not_to include("vimeo.com") + end + subject.start + end + + it "uses proposal photo for header_image" do + expect(adapter).to receive(:send_message!) do |args| + expect(args[:header_image]).to be_present + end + subject.start + end + + it "respects the 1024 character body limit" do + expect(adapter).to receive(:send_message!) do |args| + expect(args[:body_text].length).to be <= 1024 + end + subject.start + end + end end context "when a comment button is clicked" do