mirror of
https://github.com/discourse/discourse.git
synced 2025-02-07 11:58:27 +00:00
A bit of a mixed bag, this addresses several edge areas of bookmarks and makes them compatible with polymorphic bookmarks (hidden behind the `use_polymorphic_bookmarks` site setting). The main ones are: * ExportUserArchive compatibility * SyncTopicUserBookmarked job compatibility * Sending different notifications for the bookmark reminders based on the bookmarkable type * Import scripts compatibility * BookmarkReminderNotificationHandler compatibility This PR also refactors the `register_bookmarkable` API so it accepts a class descended from a `BaseBookmarkable` class instead. This was done because we kept having to add more and more lambdas/properties inline and it was very messy, so a factory pattern is cleaner. The classes can be tested independently as well. Some later PRs will address some other areas like the discourse narrative bot, advanced search, reports, and the .ics endpoint for bookmarks.
622 lines
16 KiB
Ruby
622 lines
16 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'distributed_mutex'
|
|
|
|
module DiscourseNarrativeBot
|
|
class NewUserNarrative < Base
|
|
I18N_KEY = "discourse_narrative_bot.new_user_narrative".freeze
|
|
BADGE_NAME = 'Certified'.freeze
|
|
|
|
TRANSITION_TABLE = {
|
|
begin: {
|
|
init: {
|
|
next_state: :tutorial_bookmark,
|
|
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.bookmark.instructions", base_uri: Discourse.base_path) },
|
|
action: :say_hello
|
|
}
|
|
},
|
|
|
|
tutorial_bookmark: {
|
|
next_state: :tutorial_onebox,
|
|
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.onebox.instructions", base_uri: Discourse.base_path) },
|
|
|
|
bookmark: {
|
|
action: :reply_to_bookmark
|
|
},
|
|
|
|
reply: {
|
|
next_state: :tutorial_bookmark,
|
|
action: :missing_bookmark
|
|
}
|
|
},
|
|
|
|
tutorial_onebox: {
|
|
next_state: :tutorial_emoji,
|
|
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.emoji.instructions", base_uri: Discourse.base_path) },
|
|
|
|
reply: {
|
|
action: :reply_to_onebox
|
|
}
|
|
},
|
|
|
|
tutorial_emoji: {
|
|
prerequisite: Proc.new { SiteSetting.enable_emoji },
|
|
next_state: :tutorial_mention,
|
|
next_instructions: Proc.new {
|
|
I18n.t("#{I18N_KEY}.mention.instructions",
|
|
discobot_username: self.discobot_username,
|
|
base_uri: Discourse.base_path)
|
|
},
|
|
reply: {
|
|
action: :reply_to_emoji
|
|
}
|
|
},
|
|
|
|
tutorial_mention: {
|
|
prerequisite: Proc.new { SiteSetting.enable_mentions },
|
|
next_state: :tutorial_formatting,
|
|
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.formatting.instructions", base_uri: Discourse.base_path) },
|
|
|
|
reply: {
|
|
action: :reply_to_mention
|
|
}
|
|
},
|
|
|
|
tutorial_formatting: {
|
|
next_state: :tutorial_quote,
|
|
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.quoting.instructions", base_uri: Discourse.base_path) },
|
|
|
|
reply: {
|
|
action: :reply_to_formatting
|
|
}
|
|
},
|
|
|
|
tutorial_quote: {
|
|
next_state: :tutorial_images,
|
|
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.images.instructions", base_uri: Discourse.base_path) },
|
|
|
|
reply: {
|
|
action: :reply_to_quote
|
|
}
|
|
},
|
|
|
|
# Note: tutorial_images and tutorial_likes are mutually exclusive.
|
|
# The prerequisites should ensure only one of them is called.
|
|
tutorial_images: {
|
|
prerequisite: Proc.new { @user.has_trust_level?(SiteSetting.min_trust_to_post_embedded_media) },
|
|
next_state: :tutorial_likes,
|
|
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.likes.instructions", base_uri: Discourse.base_path) },
|
|
reply: {
|
|
action: :reply_to_image
|
|
},
|
|
like: {
|
|
action: :track_images_like
|
|
}
|
|
},
|
|
|
|
tutorial_likes: {
|
|
prerequisite: Proc.new { !@user.has_trust_level?(SiteSetting.min_trust_to_post_embedded_media) },
|
|
next_state: :tutorial_flag,
|
|
next_instructions: Proc.new {
|
|
I18n.t("#{I18N_KEY}.flag.instructions",
|
|
guidelines_url: url_helpers(:guidelines_url),
|
|
about_url: url_helpers(:about_index_url),
|
|
base_uri: Discourse.base_path)
|
|
},
|
|
like: {
|
|
action: :reply_to_likes
|
|
},
|
|
reply: {
|
|
next_state: :tutorial_likes,
|
|
action: :missing_likes_like
|
|
}
|
|
},
|
|
|
|
tutorial_flag: {
|
|
prerequisite: Proc.new { SiteSetting.allow_flagging_staff },
|
|
next_state: :tutorial_search,
|
|
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.search.instructions", base_uri: Discourse.base_path) },
|
|
flag: {
|
|
action: :reply_to_flag
|
|
},
|
|
reply: {
|
|
next_state: :tutorial_flag,
|
|
action: :missing_flag
|
|
}
|
|
},
|
|
|
|
tutorial_search: {
|
|
next_state: :end,
|
|
reply: {
|
|
action: :reply_to_search
|
|
}
|
|
}
|
|
}
|
|
|
|
def self.badge_name
|
|
BADGE_NAME
|
|
end
|
|
|
|
def self.search_answer
|
|
':herb:'
|
|
end
|
|
|
|
def self.search_answer_emoji
|
|
"\u{1F33F}"
|
|
end
|
|
|
|
def self.reset_trigger
|
|
I18n.t('discourse_narrative_bot.new_user_narrative.reset_trigger')
|
|
end
|
|
|
|
def reset_bot(user, post)
|
|
if pm_to_bot?(post)
|
|
reset_data(user, topic_id: post.topic_id)
|
|
else
|
|
reset_data(user)
|
|
end
|
|
|
|
Jobs.enqueue_in(2.seconds, :narrative_init, user_id: user.id, klass: self.class.to_s)
|
|
end
|
|
|
|
private
|
|
|
|
def synchronize(user)
|
|
if Rails.env.test?
|
|
yield
|
|
else
|
|
DistributedMutex.synchronize("new_user_narrative_#{user.id}") { yield }
|
|
end
|
|
end
|
|
|
|
def init_tutorial_search
|
|
topic = @post.topic
|
|
post = topic.first_post
|
|
|
|
MessageBus.publish('/new_user_narrative/tutorial_search', {}, user_ids: [@user.id])
|
|
|
|
raw = <<~MD
|
|
#{post.raw}
|
|
|
|
#{I18n.t("#{I18N_KEY}.search.hidden_message", i18n_post_args.merge(search_answer: NewUserNarrative.search_answer))}
|
|
MD
|
|
|
|
PostRevisor.new(post, topic).revise!(
|
|
self.discobot_user,
|
|
{ raw: raw },
|
|
skip_validations: true, force_new_version: true
|
|
)
|
|
|
|
set_state_data(:post_version, post.reload.version || 0)
|
|
end
|
|
|
|
def clean_up_tutorial_search
|
|
first_post = @post.topic.first_post
|
|
first_post.revert_to(get_state_data(:post_version) - 1)
|
|
first_post.save!
|
|
first_post.publish_change_to_clients!(:revised)
|
|
end
|
|
|
|
def say_hello
|
|
raw = I18n.t(
|
|
"#{I18N_KEY}.hello.message",
|
|
i18n_post_args(
|
|
username: @user.username,
|
|
title: SiteSetting.title
|
|
)
|
|
)
|
|
|
|
raw = <<~MD
|
|
#{raw}
|
|
|
|
#{instance_eval(&@next_instructions)}
|
|
MD
|
|
|
|
title = I18n.t("#{I18N_KEY}.hello.title", title: SiteSetting.title)
|
|
if SiteSetting.max_emojis_in_title == 0
|
|
title = title.gsub(/:([\w\-+]+(?::t\d)?):/, '').strip
|
|
end
|
|
|
|
opts = {
|
|
title: title,
|
|
target_usernames: @user.username,
|
|
archetype: Archetype.private_message,
|
|
subtype: TopicSubtype.system_message,
|
|
}
|
|
|
|
if @post &&
|
|
@post.topic.private_message? &&
|
|
@post.topic.topic_allowed_users.pluck(:user_id).include?(@user.id)
|
|
|
|
opts = opts.merge(topic_id: @post.topic_id)
|
|
end
|
|
|
|
if @data[:topic_id]
|
|
opts = opts
|
|
.merge(topic_id: @data[:topic_id])
|
|
.except(:title, :target_usernames, :archetype)
|
|
end
|
|
|
|
post = reply_to(@post, raw, opts)
|
|
@data[:topic_id] = post.topic.id
|
|
@data[:track] = self.class.to_s
|
|
post
|
|
end
|
|
|
|
# TODO (martin) [POLYBOOK] Fix up narrative bot bookmark interactions in a separate PR.
|
|
def missing_bookmark
|
|
return unless valid_topic?(@post.topic_id)
|
|
return if @post.user_id == self.discobot_user.id
|
|
|
|
fake_delay
|
|
enqueue_timeout_job(@user)
|
|
reply_to(@post, I18n.t("#{I18N_KEY}.bookmark.not_found", i18n_post_args)) unless @data[:attempted]
|
|
false
|
|
end
|
|
|
|
# TODO (martin) [POLYBOOK] Fix up narrative bot bookmark interactions in a separate PR.
|
|
def reply_to_bookmark
|
|
return unless valid_topic?(@post.topic_id)
|
|
return unless @post.user_id == self.discobot_user.id
|
|
|
|
profile_page_url = url_helpers(:user_url, username: @user.username)
|
|
bookmark_url = "#{profile_page_url}/activity/bookmarks"
|
|
raw = <<~MD
|
|
#{I18n.t("#{I18N_KEY}.bookmark.reply", i18n_post_args(bookmark_url: bookmark_url))}
|
|
|
|
#{instance_eval(&@next_instructions)}
|
|
MD
|
|
|
|
fake_delay
|
|
|
|
reply = reply_to(@post, raw)
|
|
enqueue_timeout_job(@user)
|
|
reply
|
|
end
|
|
|
|
def reply_to_onebox
|
|
post_topic_id = @post.topic_id
|
|
return unless valid_topic?(post_topic_id)
|
|
|
|
@post.post_analyzer.cook(@post.raw, {})
|
|
|
|
if @post.post_analyzer.found_oneboxes?
|
|
raw = <<~MD
|
|
#{I18n.t("#{I18N_KEY}.onebox.reply", i18n_post_args)}
|
|
|
|
#{instance_eval(&@next_instructions)}
|
|
MD
|
|
|
|
fake_delay
|
|
|
|
reply = reply_to(@post, raw)
|
|
enqueue_timeout_job(@user)
|
|
reply
|
|
else
|
|
fake_delay
|
|
reply_to(@post, I18n.t("#{I18N_KEY}.onebox.not_found", i18n_post_args)) unless @data[:attempted]
|
|
enqueue_timeout_job(@user)
|
|
false
|
|
end
|
|
end
|
|
|
|
def track_images_like
|
|
post_topic_id = @post.topic_id
|
|
return unless valid_topic?(post_topic_id)
|
|
|
|
post_liked = PostAction.exists?(
|
|
post_action_type_id: PostActionType.types[:like],
|
|
post_id: @data[:last_post_id],
|
|
user_id: @user.id
|
|
)
|
|
|
|
if post_liked
|
|
set_state_data(:liked, true)
|
|
|
|
if (post_id = get_state_data(:post_id)) && (post = Post.find_by(id: post_id))
|
|
fake_delay
|
|
like_post(post)
|
|
|
|
raw = <<~MD
|
|
#{I18n.t("#{I18N_KEY}.images.reply", i18n_post_args)}
|
|
|
|
#{instance_eval(&@next_instructions)}
|
|
MD
|
|
|
|
reply = reply_to(@post, raw)
|
|
enqueue_timeout_job(@user)
|
|
return reply
|
|
end
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
def reply_to_image
|
|
post_topic_id = @post.topic_id
|
|
return unless valid_topic?(post_topic_id)
|
|
|
|
transition = true
|
|
attempted_count = get_state_data(:attempted) || 0
|
|
|
|
if attempted_count < 2
|
|
@data[:skip_attempted] = true
|
|
@data[:attempted] = false
|
|
else
|
|
@data[:skip_attempted] = false
|
|
end
|
|
|
|
cooked = @post.post_analyzer.cook(@post.raw, {})
|
|
|
|
if Nokogiri::HTML5.fragment(cooked).css("img").size > 0
|
|
set_state_data(:post_id, @post.id)
|
|
|
|
if get_state_data(:liked)
|
|
raw = <<~MD
|
|
#{I18n.t("#{I18N_KEY}.images.reply", i18n_post_args)}
|
|
|
|
#{instance_eval(&@next_instructions)}
|
|
MD
|
|
|
|
like_post(@post)
|
|
else
|
|
raw = I18n.t(
|
|
"#{I18N_KEY}.images.like_not_found",
|
|
i18n_post_args(url: Post.find_by(id: @data[:last_post_id]).url)
|
|
)
|
|
|
|
transition = false
|
|
end
|
|
else
|
|
raw = I18n.t(
|
|
"#{I18N_KEY}.images.not_found",
|
|
i18n_post_args(image_url: "#{Discourse.base_url}/plugins/discourse-narrative-bot/images/dog-walk.gif")
|
|
)
|
|
|
|
transition = false
|
|
end
|
|
|
|
fake_delay
|
|
|
|
set_state_data(:attempted, attempted_count + 1) if !transition
|
|
reply = reply_to(@post, raw) unless @data[:attempted] && !transition
|
|
enqueue_timeout_job(@user)
|
|
transition ? reply : false
|
|
end
|
|
|
|
def missing_likes_like
|
|
return unless valid_topic?(@post.topic_id)
|
|
return if @post.user_id == self.discobot_user.id
|
|
|
|
fake_delay
|
|
enqueue_timeout_job(@user)
|
|
|
|
last_post = Post.find_by(id: @data[:last_post_id])
|
|
reply_to(@post, I18n.t("#{I18N_KEY}.likes.not_found", i18n_post_args(url: last_post.url)))
|
|
false
|
|
end
|
|
|
|
def reply_to_likes
|
|
post_topic_id = @post.topic_id
|
|
return unless valid_topic?(post_topic_id)
|
|
|
|
post_liked = PostAction.exists?(
|
|
post_action_type_id: PostActionType.types[:like],
|
|
post_id: @data[:last_post_id],
|
|
user_id: @user.id
|
|
)
|
|
|
|
if post_liked
|
|
raw = <<~MD
|
|
#{I18n.t("#{I18N_KEY}.likes.reply", i18n_post_args)}
|
|
|
|
#{instance_eval(&@next_instructions)}
|
|
MD
|
|
|
|
fake_delay
|
|
|
|
reply = reply_to(@post, raw)
|
|
enqueue_timeout_job(@user)
|
|
return reply
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
def reply_to_formatting
|
|
post_topic_id = @post.topic_id
|
|
return unless valid_topic?(post_topic_id)
|
|
|
|
if Nokogiri::HTML5.fragment(@post.cooked).css("b", "strong", "em", "i", ".bbcode-i", ".bbcode-b").size > 0
|
|
raw = <<~MD
|
|
#{I18n.t("#{I18N_KEY}.formatting.reply", i18n_post_args)}
|
|
|
|
#{instance_eval(&@next_instructions)}
|
|
MD
|
|
|
|
fake_delay
|
|
|
|
reply = reply_to(@post, raw)
|
|
enqueue_timeout_job(@user)
|
|
reply
|
|
else
|
|
fake_delay
|
|
reply_to(@post, I18n.t("#{I18N_KEY}.formatting.not_found", i18n_post_args)) unless @data[:attempted]
|
|
enqueue_timeout_job(@user)
|
|
false
|
|
end
|
|
end
|
|
|
|
def reply_to_quote
|
|
post_topic_id = @post.topic_id
|
|
return unless valid_topic?(post_topic_id)
|
|
|
|
doc = Nokogiri::HTML5.fragment(@post.cooked)
|
|
|
|
if doc.css(".quote").size > 0
|
|
raw = <<~MD
|
|
#{I18n.t("#{I18N_KEY}.quoting.reply", i18n_post_args)}
|
|
|
|
#{instance_eval(&@next_instructions)}
|
|
MD
|
|
|
|
fake_delay
|
|
|
|
reply = reply_to(@post, raw)
|
|
enqueue_timeout_job(@user)
|
|
reply
|
|
else
|
|
fake_delay
|
|
reply_to(@post, I18n.t("#{I18N_KEY}.quoting.not_found", i18n_post_args)) unless @data[:attempted]
|
|
enqueue_timeout_job(@user)
|
|
false
|
|
end
|
|
end
|
|
|
|
def reply_to_emoji
|
|
post_topic_id = @post.topic_id
|
|
return unless valid_topic?(post_topic_id)
|
|
|
|
doc = Nokogiri::HTML5.fragment(@post.cooked)
|
|
|
|
if doc.css(".emoji").size > 0
|
|
raw = <<~MD
|
|
#{I18n.t("#{I18N_KEY}.emoji.reply", i18n_post_args)}
|
|
|
|
#{instance_eval(&@next_instructions)}
|
|
MD
|
|
|
|
fake_delay
|
|
|
|
reply = reply_to(@post, raw)
|
|
enqueue_timeout_job(@user)
|
|
reply
|
|
else
|
|
fake_delay
|
|
reply_to(@post, I18n.t("#{I18N_KEY}.emoji.not_found", i18n_post_args)) unless @data[:attempted]
|
|
enqueue_timeout_job(@user)
|
|
false
|
|
end
|
|
end
|
|
|
|
def reply_to_mention
|
|
post_topic_id = @post.topic_id
|
|
return unless valid_topic?(post_topic_id)
|
|
|
|
if bot_mentioned?(@post)
|
|
raw = <<~MD
|
|
#{I18n.t("#{I18N_KEY}.mention.reply", i18n_post_args)}
|
|
|
|
#{instance_eval(&@next_instructions)}
|
|
MD
|
|
|
|
fake_delay
|
|
|
|
reply = reply_to(@post, raw)
|
|
enqueue_timeout_job(@user)
|
|
reply
|
|
else
|
|
fake_delay
|
|
|
|
unless @data[:attempted]
|
|
reply_to(@post, I18n.t(
|
|
"#{I18N_KEY}.mention.not_found",
|
|
i18n_post_args(
|
|
username: @user.username,
|
|
discobot_username: self.discobot_username
|
|
)
|
|
))
|
|
end
|
|
|
|
enqueue_timeout_job(@user)
|
|
false
|
|
end
|
|
end
|
|
|
|
def missing_flag
|
|
return unless valid_topic?(@post.topic_id)
|
|
|
|
# Remove any incorrect flags so that they can try again
|
|
if @post.user_id == -2
|
|
@post.post_actions
|
|
.where(user_id: @user.id)
|
|
.where("post_action_type_id IN (?)", (PostActionType.flag_types.values - [PostActionType.types[:inappropriate]]))
|
|
.destroy_all
|
|
end
|
|
|
|
fake_delay
|
|
reply_to(@post, I18n.t("#{I18N_KEY}.flag.not_found", i18n_post_args)) unless @data[:attempted]
|
|
false
|
|
end
|
|
|
|
def reply_to_flag
|
|
post_topic_id = @post.topic_id
|
|
return unless valid_topic?(post_topic_id)
|
|
return unless @post.user.id == -2
|
|
|
|
raw = <<~MD
|
|
#{I18n.t("#{I18N_KEY}.flag.reply", i18n_post_args)}
|
|
|
|
#{instance_eval(&@next_instructions)}
|
|
MD
|
|
|
|
fake_delay
|
|
|
|
reply = reply_to(@post, raw)
|
|
@post.post_actions.where(user_id: @user.id).destroy_all
|
|
|
|
enqueue_timeout_job(@user)
|
|
reply
|
|
end
|
|
|
|
def reply_to_search
|
|
post_topic_id = @post.topic_id
|
|
return unless valid_topic?(post_topic_id)
|
|
|
|
if @post.raw.include?(NewUserNarrative.search_answer) || @post.raw.include?(NewUserNarrative.search_answer_emoji)
|
|
fake_delay
|
|
reply_to(@post, I18n.t("#{I18N_KEY}.search.reply", i18n_post_args(search_url: url_helpers(:search_url))))
|
|
else
|
|
fake_delay
|
|
reply_to(@post, I18n.t("#{I18N_KEY}.search.not_found", i18n_post_args)) unless @data[:attempted]
|
|
enqueue_timeout_job(@user)
|
|
false
|
|
end
|
|
end
|
|
|
|
def end_reply
|
|
fake_delay
|
|
|
|
reply_to(
|
|
@post,
|
|
I18n.t("#{I18N_KEY}.end.message",
|
|
i18n_post_args(
|
|
username: @user.username,
|
|
base_url: Discourse.base_url,
|
|
certificate: certificate,
|
|
discobot_username: self.discobot_username,
|
|
advanced_trigger: AdvancedUserNarrative.reset_trigger
|
|
)
|
|
),
|
|
topic_id: @data[:topic_id]
|
|
)
|
|
end
|
|
|
|
def like_post(post)
|
|
PostActionCreator.like(self.discobot_user, post)
|
|
end
|
|
|
|
def welcome_topic
|
|
Topic.find_by(slug: 'welcome-to-discourse', archetype: Archetype.default) ||
|
|
Topic.recent(1).first
|
|
end
|
|
|
|
def url_helpers(url, opts = {})
|
|
Rails.application.routes.url_helpers.public_send(
|
|
url,
|
|
opts.merge(host: Discourse.base_url)
|
|
)
|
|
end
|
|
end
|
|
end
|