From 0021c502d0295fd5915b7d9518c8ac2dd86f9008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Wed, 25 Feb 2026 19:25:34 +0100 Subject: [PATCH 01/10] add video parsing --- .../chatbot/media/video_embed_extractor.rb | 95 ++++++++ .../whatsapp/envelopes/interactive_buttons.rb | 17 +- .../chatbot/workflows/proposals_workflow.rb | 38 ++- .../media/video_embed_extractor_spec.rb | 218 ++++++++++++++++++ .../workflows/proposals_workflow_spec.rb | 130 +++++++++++ 5 files changed, 489 insertions(+), 9 deletions(-) create mode 100644 app/services/decidim/chatbot/media/video_embed_extractor.rb create mode 100644 spec/services/media/video_embed_extractor_spec.rb 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..4e5980e --- /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 + + # Extracts a video URL from HTML content + # @param html [String] The HTML string to parse + # @return [Hash] A hash with :video_url key containing the extracted URL or nil + 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 + # Uses maxresdefault for best quality, falls back to hqdefault + def youtube_thumbnail_url + "https://img.youtube.com/vi/#{video_id}/maxresdefault.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..6ae5460 100644 --- a/app/services/decidim/chatbot/providers/whatsapp/envelopes/interactive_buttons.rb +++ b/app/services/decidim/chatbot/providers/whatsapp/envelopes/interactive_buttons.rb @@ -12,26 +12,31 @@ 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: {}.tap do |footer| + footer[:text] = data[:footer_text] + end, 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(:footer) if data[:footer_text].blank? + interactive.delete(:header) if interactive[:header].blank? + interactive.delete(:footer) if interactive[:footer].blank? end ) end diff --git a/app/services/decidim/chatbot/workflows/proposals_workflow.rb b/app/services/decidim/chatbot/workflows/proposals_workflow.rb index fbafda9..bdf15c6 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,25 @@ 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)) + + # Build body text with video URL if present + title_text = sanitize_text(proposal.title, 100) + body_text = sanitize_text(proposal.body, calculate_max_body_length(video)) + proposal_url = resource_url(proposal) + + 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 + header_image = video.thumbnail_url.presence || resource_url(proposal.photo) + 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 +82,23 @@ def send_proposal_details ) end + # Calculate maximum body length dynamically to stay within 1024 char limit + def calculate_max_body_length(video) + # WhatsApp body text limit is 1024 characters + total_limit = 1024 + + # Calculate fixed overhead + title_overhead = sanitize_text(proposal.title, 100).length + 6 # "*title*\n\n" + video_overhead = video.valid? ? video.url.length + 6 : 0 # "🎥 url\n\n" (emoji is 2 bytes) + proposal_url_overhead = resource_url(proposal).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..f340cf2 --- /dev/null +++ b/spec/services/media/video_embed_extractor_spec.rb @@ -0,0 +1,218 @@ +# 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/maxresdefault.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/maxresdefault.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/maxresdefault.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/maxresdefault.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/maxresdefault.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.

+
    +
  • Point one
  • +
  • Point two
  • +
+ +

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/maxresdefault.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..c9043f1 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/maxresdefault.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.

+
    +
  • Point one
  • +
  • Point two
  • +
+ 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 From cbdd6fd59921ed45dcc8711a7f6dfd8b3622e137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Wed, 25 Feb 2026 19:37:49 +0100 Subject: [PATCH 02/10] Update app/services/decidim/chatbot/workflows/proposals_workflow.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/services/decidim/chatbot/workflows/proposals_workflow.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/decidim/chatbot/workflows/proposals_workflow.rb b/app/services/decidim/chatbot/workflows/proposals_workflow.rb index bdf15c6..be660fe 100644 --- a/app/services/decidim/chatbot/workflows/proposals_workflow.rb +++ b/app/services/decidim/chatbot/workflows/proposals_workflow.rb @@ -89,7 +89,7 @@ def calculate_max_body_length(video) # Calculate fixed overhead title_overhead = sanitize_text(proposal.title, 100).length + 6 # "*title*\n\n" - video_overhead = video.valid? ? video.url.length + 6 : 0 # "🎥 url\n\n" (emoji is 2 bytes) + video_overhead = video.valid? ? video.url.length + 4 : 0 # "🎥 url\n\n" proposal_url_overhead = resource_url(proposal).length + 2 # "\n\nurl" # Reserve space for newlines and formatting From ef7b83d52f49bdc88e4d44ecb0b69dd753d2482d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Wed, 25 Feb 2026 19:38:01 +0100 Subject: [PATCH 03/10] Update app/services/decidim/chatbot/media/video_embed_extractor.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/services/decidim/chatbot/media/video_embed_extractor.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/decidim/chatbot/media/video_embed_extractor.rb b/app/services/decidim/chatbot/media/video_embed_extractor.rb index 4e5980e..c4927d6 100644 --- a/app/services/decidim/chatbot/media/video_embed_extractor.rb +++ b/app/services/decidim/chatbot/media/video_embed_extractor.rb @@ -7,7 +7,7 @@ module Media # 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 + 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 # Extracts a video URL from HTML content From ec8e7db127decf00ad24ff1bb23b5ae42beaaf77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Wed, 25 Feb 2026 19:38:16 +0100 Subject: [PATCH 04/10] Update app/services/decidim/chatbot/media/video_embed_extractor.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/services/decidim/chatbot/media/video_embed_extractor.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/services/decidim/chatbot/media/video_embed_extractor.rb b/app/services/decidim/chatbot/media/video_embed_extractor.rb index c4927d6..3648d2a 100644 --- a/app/services/decidim/chatbot/media/video_embed_extractor.rb +++ b/app/services/decidim/chatbot/media/video_embed_extractor.rb @@ -10,9 +10,8 @@ class VideoEmbedExtractor 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 - # Extracts a video URL from HTML content - # @param html [String] The HTML string to parse - # @return [Hash] A hash with :video_url key containing the extracted URL or nil + # 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 From c7e1649cc70822d1be184b65c7462d9ffc25b90d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Wed, 25 Feb 2026 19:39:07 +0100 Subject: [PATCH 05/10] Update app/services/decidim/chatbot/providers/whatsapp/envelopes/interactive_buttons.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../chatbot/providers/whatsapp/envelopes/interactive_buttons.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 6ae5460..03d02b3 100644 --- a/app/services/decidim/chatbot/providers/whatsapp/envelopes/interactive_buttons.rb +++ b/app/services/decidim/chatbot/providers/whatsapp/envelopes/interactive_buttons.rb @@ -36,7 +36,7 @@ def body } }.tap do |interactive| interactive.delete(:header) if interactive[:header].blank? - interactive.delete(:footer) if interactive[:footer].blank? + interactive.delete(:footer) if data[:footer_text].blank? end ) end From 0eebe12973f17f52a9218058b0395a7af63123e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Wed, 25 Feb 2026 19:39:28 +0100 Subject: [PATCH 06/10] Update spec/services/media/video_embed_extractor_spec.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../media/video_embed_extractor_spec.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/spec/services/media/video_embed_extractor_spec.rb b/spec/services/media/video_embed_extractor_spec.rb index f340cf2..38e029d 100644 --- a/spec/services/media/video_embed_extractor_spec.rb +++ b/spec/services/media/video_embed_extractor_spec.rb @@ -111,6 +111,23 @@ module Media expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.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/maxresdefault.jpg") + end + end end context "when html contains a Vimeo iframe" do From b7770905ecabd6e4e5162de2522b01f95b985a26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Wed, 25 Feb 2026 19:40:24 +0100 Subject: [PATCH 07/10] Update app/services/decidim/chatbot/workflows/proposals_workflow.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/services/decidim/chatbot/workflows/proposals_workflow.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/decidim/chatbot/workflows/proposals_workflow.rb b/app/services/decidim/chatbot/workflows/proposals_workflow.rb index be660fe..a534e06 100644 --- a/app/services/decidim/chatbot/workflows/proposals_workflow.rb +++ b/app/services/decidim/chatbot/workflows/proposals_workflow.rb @@ -88,7 +88,7 @@ def calculate_max_body_length(video) total_limit = 1024 # Calculate fixed overhead - title_overhead = sanitize_text(proposal.title, 100).length + 6 # "*title*\n\n" + title_overhead = sanitize_text(proposal.title, 100).length + 4 # "*title*\n\n" video_overhead = video.valid? ? video.url.length + 4 : 0 # "🎥 url\n\n" proposal_url_overhead = resource_url(proposal).length + 2 # "\n\nurl" From 595686118d35103142774b7c064c0fb59b918622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Wed, 25 Feb 2026 19:48:05 +0100 Subject: [PATCH 08/10] use always existing thumb --- app/services/decidim/chatbot/media/video_embed_extractor.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/services/decidim/chatbot/media/video_embed_extractor.rb b/app/services/decidim/chatbot/media/video_embed_extractor.rb index 3648d2a..2dfcb9b 100644 --- a/app/services/decidim/chatbot/media/video_embed_extractor.rb +++ b/app/services/decidim/chatbot/media/video_embed_extractor.rb @@ -75,9 +75,10 @@ def extract_vimeo end # Returns YouTube thumbnail URL - # Uses maxresdefault for best quality, falls back to hqdefault def youtube_thumbnail_url - "https://img.youtube.com/vi/#{video_id}/maxresdefault.jpg" + # 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 From 3f480fb50495e80ccd84436f4354532234708422 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Wed, 25 Feb 2026 19:49:50 +0100 Subject: [PATCH 09/10] refactor: optimize calculate_max_body_length to avoid redundant method calls Pass pre-calculated title_text and proposal_url as parameters to calculate_max_body_length instead of recalculating them inside the method. This eliminates redundant calls to sanitize_text() and resource_url(). --- .../chatbot/workflows/proposals_workflow.rb | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/app/services/decidim/chatbot/workflows/proposals_workflow.rb b/app/services/decidim/chatbot/workflows/proposals_workflow.rb index a534e06..643d147 100644 --- a/app/services/decidim/chatbot/workflows/proposals_workflow.rb +++ b/app/services/decidim/chatbot/workflows/proposals_workflow.rb @@ -56,11 +56,14 @@ def send_proposal_details # Check if proposal body contains a video iframe video = Decidim::Chatbot::Media::VideoEmbedExtractor.new(translated_attribute(proposal.body)) - # Build body text with video URL if present + # Pre-calculate title and URL to avoid redundant method calls title_text = sanitize_text(proposal.title, 100) - body_text = sanitize_text(proposal.body, calculate_max_body_length(video)) 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}" @@ -83,14 +86,18 @@ def send_proposal_details end # Calculate maximum body length dynamically to stay within 1024 char limit - def calculate_max_body_length(video) + # @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 - title_overhead = sanitize_text(proposal.title, 100).length + 4 # "*title*\n\n" + # 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 = resource_url(proposal).length + 2 # "\n\nurl" + proposal_url_overhead = proposal_url.length + 2 # "\n\nurl" # Reserve space for newlines and formatting reserved_space = title_overhead + video_overhead + proposal_url_overhead From 43aba7b7d524cae88270176c4fb31351bdff55a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Wed, 25 Feb 2026 20:52:47 +0100 Subject: [PATCH 10/10] fix: ensure header_image always has a value with fallback, restore min body length to 100 - Fixes failing tests that expected header_image to always be present --- .../whatsapp/envelopes/interactive_buttons.rb | 6 +++--- .../chatbot/workflows/proposals_workflow.rb | 6 +++--- spec/services/media/video_embed_extractor_spec.rb | 14 +++++++------- spec/services/workflows/proposals_workflow_spec.rb | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) 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 03d02b3..1e10224 100644 --- a/app/services/decidim/chatbot/providers/whatsapp/envelopes/interactive_buttons.rb +++ b/app/services/decidim/chatbot/providers/whatsapp/envelopes/interactive_buttons.rb @@ -26,9 +26,9 @@ def body body: { text: data[:body_text] }, - footer: {}.tap do |footer| - footer[:text] = data[:footer_text] - end, + footer: { + text: data[:footer_text] + }, action: { buttons: data[:buttons].map do |button| { type: "reply", reply: { id: button[:id], title: button[:title] } } diff --git a/app/services/decidim/chatbot/workflows/proposals_workflow.rb b/app/services/decidim/chatbot/workflows/proposals_workflow.rb index 643d147..7cc11c8 100644 --- a/app/services/decidim/chatbot/workflows/proposals_workflow.rb +++ b/app/services/decidim/chatbot/workflows/proposals_workflow.rb @@ -59,7 +59,7 @@ def send_proposal_details # 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)) @@ -68,8 +68,8 @@ def send_proposal_details 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 - header_image = video.thumbnail_url.presence || resource_url(proposal.photo) + # 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, diff --git a/spec/services/media/video_embed_extractor_spec.rb b/spec/services/media/video_embed_extractor_spec.rb index 38e029d..a3ce73f 100644 --- a/spec/services/media/video_embed_extractor_spec.rb +++ b/spec/services/media/video_embed_extractor_spec.rb @@ -57,7 +57,7 @@ module Media it "extracts the YouTube thumbnail URL" do extractor = described_class.new(html) - expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg") + expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg") end end @@ -74,7 +74,7 @@ module Media it "extracts the thumbnail URL" do extractor = described_class.new(html) - expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg") + expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg") end end @@ -91,7 +91,7 @@ module Media it "extracts the thumbnail URL" do extractor = described_class.new(html) - expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg") + expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg") end end @@ -108,7 +108,7 @@ module Media it "extracts the thumbnail URL" do extractor = described_class.new(html) - expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg") + expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg") end end @@ -125,7 +125,7 @@ module Media it "extracts the thumbnail URL" do extractor = described_class.new(html) - expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg") + expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg") end end end @@ -190,7 +190,7 @@ module Media 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/maxresdefault.jpg") + expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg") end end @@ -212,7 +212,7 @@ module Media 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/maxresdefault.jpg") + expect(extractor.thumbnail_url).to eq("https://img.youtube.com/vi/test123/hqdefault.jpg") end end diff --git a/spec/services/workflows/proposals_workflow_spec.rb b/spec/services/workflows/proposals_workflow_spec.rb index c9043f1..29239a1 100644 --- a/spec/services/workflows/proposals_workflow_spec.rb +++ b/spec/services/workflows/proposals_workflow_spec.rb @@ -340,7 +340,7 @@ module Workflows expect(adapter).to receive(:send_message!).with( hash_including( type: :interactive_buttons, - header_image: "https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" + header_image: "https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg" ) ) subject.start