From fe0d42b78e73093a3981b582676a637fcf01e791 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 18:02:58 +0000 Subject: [PATCH 1/2] Hold new users' first forum posts containing links for moderation A user's first-ever forum post that contains a link (http/https URL, www. link, or markdown link) is now held for moderation instead of being auto-approved: - The post, its topic (when it starts a new thread), and the author's thredded_user_detail are all set to pending_moderation, so any further posts they make before review are held too. Approving any of their posts from /forum/moderation approves the user again via Thredded::ModeratePost. Forum moderators/admins and site admins are exempt. - Threads are only announced to the forums Discord webhook once approved: NotifyDiscordOfThreadJob now skips non-approved threads, and held threads are announced when a moderator approves them (with a guard so a thread approved within the 1-minute announce delay isn't posted twice). - New NotifyDiscordOfPendingPostJob pings a moderator Discord channel (DISCORD_MODERATION_WEBHOOK) whenever a post enters the moderation queue - held first posts, reported posts, and posts by still-pending users - limited to one ping per user while they have posts waiting. Pending posts remain publicly visible (Thredded.content_visible_while_pending_moderation is unchanged). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UxYdrUQipvTQzr9v2ENbhv --- .../notify_discord_of_pending_post_job.rb | 27 ++++ app/jobs/notify_discord_of_thread_job.rb | 23 +++- lib/extensions/thredded/post.rb | 83 +++++++++++- lib/extensions/thredded/topic.rb | 13 ++ .../jobs/notify_discord_of_thread_job_test.rb | 67 ++++++++++ .../forum_first_post_moderation_test.rb | 122 ++++++++++++++++++ 6 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 app/jobs/notify_discord_of_pending_post_job.rb create mode 100644 test/jobs/notify_discord_of_thread_job_test.rb create mode 100644 test/models/forum_first_post_moderation_test.rb diff --git a/app/jobs/notify_discord_of_pending_post_job.rb b/app/jobs/notify_discord_of_pending_post_job.rb new file mode 100644 index 000000000..6e80ddf11 --- /dev/null +++ b/app/jobs/notify_discord_of_pending_post_job.rb @@ -0,0 +1,27 @@ +class NotifyDiscordOfPendingPostJob < ApplicationJob + require 'discordrb/webhooks' + + queue_as :low_priority + + def perform(post_id) + post = Thredded::Post.find_by(id: post_id) + return if post.nil? + return unless post.moderation_state == "pending_moderation" + + webhook_url = ENV.fetch('DISCORD_MODERATION_WEBHOOK', '').freeze + return if webhook_url.blank? + + author = post.user ? post.user.display_name : 'an anonymous user' + client = Discordrb::Webhooks::Client.new(url: webhook_url) + client.execute do |builder| + builder.content = "Post by **#{author}** in **#{post.messageboard.name}** is waiting for review" + builder.add_embed do |embed| + embed.title = post.postable.title + embed.description = post.content.truncate(140) + embed.timestamp = Time.now + embed.url = "https://www.notebook.ai/forum/moderation" + embed.colour = 15158332 + end + end + end +end diff --git a/app/jobs/notify_discord_of_thread_job.rb b/app/jobs/notify_discord_of_thread_job.rb index 4c08cbc2e..a27954ce4 100644 --- a/app/jobs/notify_discord_of_thread_job.rb +++ b/app/jobs/notify_discord_of_thread_job.rb @@ -3,13 +3,20 @@ class NotifyDiscordOfThreadJob < ApplicationJob queue_as :low_priority - def perform(*args) - thread_id = args.shift - thread = Thredded::Topic.find_by(id: thread_id) - raise "No thread found for new ID #{thread.id.inspect}" unless thread - return if thread.moderation_state == "blocked" + # from_moderation is true when this job was enqueued because a moderator + # approved a thread that was held for moderation (rather than at creation). + def perform(thread_id, from_moderation = false) + thread = Thredded::Topic.find_by(id: thread_id) + return if thread.nil? # deleted before the announcement went out + return unless thread.moderation_state == "approved" + + # Threads that went through the moderation queue get announced by the + # approval-time enqueue; skip the creation-time enqueue so approving a + # thread within the 1-minute announcement delay can't announce it twice. + return if !from_moderation && went_through_moderation?(thread) webhook_url = ENV.fetch('DISCORD_FORUMS_WEBHOOK', '').freeze + return if webhook_url.blank? client = Discordrb::Webhooks::Client.new(url: webhook_url) client.execute do |builder| @@ -22,6 +29,12 @@ def perform(*args) embed.colour = 2201331 end end + end + + private + def went_through_moderation?(thread) + first_post = thread.first_post + first_post.present? && Thredded::PostModerationRecord.where(post_id: first_post.id).exists? end end diff --git a/lib/extensions/thredded/post.rb b/lib/extensions/thredded/post.rb index a2acafda3..b41725af1 100644 --- a/lib/extensions/thredded/post.rb +++ b/lib/extensions/thredded/post.rb @@ -5,10 +5,89 @@ module Extensions module Thredded module Post extend ActiveSupport::Concern - + + # Matches http(s) URLs, protocol-less www. links, and markdown-style links. + LINK_PATTERN = %r{https?://|\bwww\.|\[[^\]]*\]\([^)\s]+\)}i + included do acts_as_paranoid + + before_create :hold_first_post_with_links_for_moderation + after_create :cascade_pending_moderation_to_topic + after_commit :notify_moderators_of_new_pending_post, on: :create + after_commit :notify_moderators_of_newly_pended_post, on: :update + end + + def contains_link? + content.to_s.match?(LINK_PATTERN) + end + + private + + # Anti-spam: a user's first-ever forum post containing a link goes to the + # moderation queue instead of being auto-approved. Pending the user's + # thredded_user_detail (rather than just this post) means any further posts + # they make before review are also held, and approving any of their posts + # from the moderation queue approves the user again (see Thredded::ModeratePost). + def hold_first_post_with_links_for_moderation + return unless first_post_with_links_by_new_user? + + # In the new-topic flow the topic's save has already inserted the user's + # detail row, while this post's user_detail association still points at a + # separate unsaved instance -- always pend the persisted row and repoint + # the association at it. + detail = ::Thredded::UserDetail.find_or_create_by!(user_id: user_id) + detail.update!(moderation_state: :pending_moderation) + self.user_detail = detail + self.moderation_state = :pending_moderation + end + + def first_post_with_links_by_new_user? + return false if user.nil? + return false unless approved? # already pending/blocked users are handled by Thredded + return false if user.forum_moderator? || user.forum_administrator? || user.site_administrator? + return false unless contains_link? + + !::Thredded::Post.where(user_id: user_id).exists? + end + + # Topics are saved before their first post (see Thredded::TopicForm#save), + # so when that first post is held for moderation the topic has already been + # created as approved; bring it in line so it is held too. + def cascade_pending_moderation_to_topic + return unless pending_moderation? + + topic = postable + return unless topic.is_a?(::Thredded::Topic) + return unless topic.approved? + return unless topic.first_post.nil? || topic.first_post.id == id + + topic.update_columns(moderation_state: ::Thredded::Topic.moderation_states[:pending_moderation]) + end + + # Ping the moderator Discord channel whenever a post lands in the moderation + # queue (held first post, reported post, or a post by a still-pending user). + # Only one ping per user while they already have posts waiting in the queue. + # Note: saved_change_to_moderation_state? can't be checked on create -- in + # the TopicForm flow the post is inserted by the topic's autosave and then + # saved again, which clears the change tracking before commit callbacks run. + def notify_moderators_of_new_pending_post + return unless pending_moderation? + + notify_moderators_of_pending_post + end + + def notify_moderators_of_newly_pended_post + return unless pending_moderation? && saved_change_to_moderation_state? + + notify_moderators_of_pending_post + end + + def notify_moderators_of_pending_post + return if ::Thredded::Post.pending_moderation.where(user_id: user_id).where.not(id: id).exists? + + NotifyDiscordOfPendingPostJob.perform_later(id) end end end -end \ No newline at end of file +end diff --git a/lib/extensions/thredded/topic.rb b/lib/extensions/thredded/topic.rb index 630e6bc79..488ad9aba 100644 --- a/lib/extensions/thredded/topic.rb +++ b/lib/extensions/thredded/topic.rb @@ -9,6 +9,7 @@ module Topic included do after_create :create_content_page_share after_create :notify_discord + after_commit :notify_discord_of_approval, on: :update has_many :content_page_shares, as: :content acts_as_paranoid @@ -30,6 +31,18 @@ def notify_discord NotifyDiscordOfThreadJob.set(wait: 1.minute).perform_later(self.id) if Rails.env.production? end + # Threads held for moderation aren't announced on Discord when they're + # created (see NotifyDiscordOfThreadJob); announce them once a moderator + # approves them instead. + def notify_discord_of_approval + return unless saved_change_to_moderation_state? + + previous_state, new_state = saved_change_to_moderation_state + return unless previous_state == 'pending_moderation' && new_state == 'approved' + + NotifyDiscordOfThreadJob.perform_later(self.id, true) if Rails.env.production? + end + def create_content_page_share ContentPageShare.create( user_id: self.user_id, diff --git a/test/jobs/notify_discord_of_thread_job_test.rb b/test/jobs/notify_discord_of_thread_job_test.rb new file mode 100644 index 000000000..426cd5842 --- /dev/null +++ b/test/jobs/notify_discord_of_thread_job_test.rb @@ -0,0 +1,67 @@ +require 'test_helper' +require 'webmock' + +class NotifyDiscordOfThreadJobTest < ActiveSupport::TestCase + WEBHOOK_URL = 'https://discord.test/api/webhooks/123/abc'.freeze + + def setup + @user = users(:one) + @moderator = users(:two) + @moderator.update!(forum_moderator: true) + @messageboard = Thredded::Messageboard.create!(name: "Job test board") + + ENV['DISCORD_FORUMS_WEBHOOK'] = WEBHOOK_URL + WebMock.enable! + WebMock.stub_request(:post, WEBHOOK_URL).to_return(status: 204) + end + + def teardown + WebMock.reset! + WebMock.disable! + ENV.delete('DISCORD_FORUMS_WEBHOOK') + end + + def create_topic(content) + form = Thredded::TopicForm.new( + title: "A topic", + content: content, + user: @user, + messageboard: @messageboard + ) + assert form.save, "expected topic form to save" + form.topic + end + + test "announces approved threads" do + topic = create_topic("Hello, no links here") + + NotifyDiscordOfThreadJob.perform_now(topic.id) + + WebMock.assert_requested(:post, WEBHOOK_URL) + end + + test "does not announce threads held for moderation" do + topic = create_topic("Spam: https://spam.example.com") + assert topic.reload.pending_moderation? + + NotifyDiscordOfThreadJob.perform_now(topic.id) + + WebMock.assert_not_requested(:post, WEBHOOK_URL) + end + + test "announces a held thread once when it is approved" do + topic = create_topic("Link: https://example.com") + Thredded::ModeratePost.run!( + post: topic.reload.first_post, + moderation_state: :approved, + moderator: @moderator + ) + + # The approval-time enqueue announces it... + NotifyDiscordOfThreadJob.perform_now(topic.id, true) + # ...and the creation-time enqueue (fires 1 minute after creation) skips it. + NotifyDiscordOfThreadJob.perform_now(topic.id) + + WebMock.assert_requested(:post, WEBHOOK_URL, times: 1) + end +end diff --git a/test/models/forum_first_post_moderation_test.rb b/test/models/forum_first_post_moderation_test.rb new file mode 100644 index 000000000..c80e9e8d2 --- /dev/null +++ b/test/models/forum_first_post_moderation_test.rb @@ -0,0 +1,122 @@ +require 'test_helper' + +class ForumFirstPostModerationTest < ActiveSupport::TestCase + include ActiveJob::TestHelper + + def setup + @user = users(:one) + @other_user = users(:two) + @messageboard = Thredded::Messageboard.create!(name: "Test board") + end + + def create_topic_with_post(user, content, title: "A topic") + form = Thredded::TopicForm.new( + title: title, + content: content, + user: user, + messageboard: @messageboard + ) + assert form.save, "expected topic form to save" + [form.topic, form.post] + end + + test "first-ever post containing a link is held for moderation, along with its topic and author" do + topic, post = create_topic_with_post(@user, "Check out https://spam.example.com for cheap stuff") + + assert post.pending_moderation? + assert topic.reload.pending_moderation? + assert @user.reload.thredded_user_detail.pending_moderation? + end + + test "first-ever post without a link is approved as usual" do + topic, post = create_topic_with_post(@user, "Hello everyone, happy to be here!") + + assert post.approved? + assert topic.reload.approved? + assert @user.reload.thredded_user_detail.approved? + end + + test "posts with links from users with existing posts are approved as usual" do + create_topic_with_post(@user, "My first post, no links here") + topic, post = create_topic_with_post(@user, "Now a link: https://example.com", title: "Second topic") + + assert post.approved? + assert topic.reload.approved? + end + + test "held first post as a reply does not affect someone else's topic" do + topic, _post = create_topic_with_post(@other_user, "A perfectly normal thread") + + reply = Thredded::Post.create!( + content: "Buy now at www.spam.example", + user: @user, + postable: topic, + messageboard: @messageboard + ) + + assert reply.pending_moderation? + assert topic.reload.approved? + assert @user.reload.thredded_user_detail.pending_moderation? + end + + test "subsequent posts by a held user are also held" do + create_topic_with_post(@user, "First post with https://spam.example.com link") + topic, post = create_topic_with_post(@user, "A follow-up with no links at all", title: "Second topic") + + assert post.pending_moderation? + assert topic.reload.pending_moderation? + end + + test "forum staff are exempt from first-post link moderation" do + @user.update!(forum_moderator: true) + _topic, post = create_topic_with_post(@user, "Announcement: https://notebook.ai/some/page") + + assert post.approved? + end + + test "approving a held post from the moderation queue approves the topic and author" do + topic, post = create_topic_with_post(@user, "Check out https://spam.example.com") + moderator = @other_user + moderator.update!(forum_moderator: true) + + Thredded::ModeratePost.run!(post: post, moderation_state: :approved, moderator: moderator) + + assert post.reload.approved? + assert topic.reload.approved? + assert @user.reload.thredded_user_detail.approved? + end + + test "detects common link formats" do + ["https://example.com", "http://example.com", "visit www.example.com now", + "a [markdown link](https://example.com)"].each do |content| + assert Thredded::Post.new(content: content).contains_link?, "expected link in: #{content}" + end + + ["no links here", "just talking about wwwater", "parentheses (like these)"].each do |content| + assert_not Thredded::Post.new(content: content).contains_link?, "expected no link in: #{content}" + end + end + + test "moderators are pinged on Discord when a post is held" do + assert_enqueued_with(job: NotifyDiscordOfPendingPostJob) do + create_topic_with_post(@user, "Spam here: https://spam.example.com") + end + end + + test "moderators are only pinged once while a user has posts in the queue" do + create_topic_with_post(@user, "Spam here: https://spam.example.com") + + assert_no_enqueued_jobs(only: NotifyDiscordOfPendingPostJob) do + create_topic_with_post(@user, "More posts while pending", title: "Second topic") + end + end + + test "moderators are pinged when an approved post is reported" do + _topic, post = create_topic_with_post(@user, "A perfectly normal first post") + assert post.approved? + + assert_enqueued_with(job: NotifyDiscordOfPendingPostJob) do + post.update!(moderation_state: :pending_moderation) + end + end +end From 53770cc556d3438c0590720e86b005d119602eb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 18:04:30 +0000 Subject: [PATCH 2/2] Ignore locally-installed bundler gems in vendor/bundle Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UxYdrUQipvTQzr9v2ENbhv --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9ed66fe2d..0f57e8d1a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,9 @@ # or operating system, you probably want to add a global ignore instead: # git config --global core.excludesfile ~/.gitignore_global -# Ignore bundler config +# Ignore bundler config and locally-installed gems /.bundle +/vendor/bundle # Ingore Yardoc temp files /.yardoc