discourse/plugins/discourse-narrative-bot/lib/discourse_narrative_bot/new_user_narrative.rb

648 lines
17 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 do
I18n.t(
"#{I18N_KEY}.mention.instructions",
discobot_username: self.discobot_username,
base_uri: Discourse.base_path,
)
end,
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 do
I18n.t(
"#{I18N_KEY}.flag.instructions",
guidelines_url: url_helpers(:guidelines_url),
about_url: url_helpers(:about_index_url),
base_uri: Discourse.base_path,
)
end,
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.id}",
{},
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)
title = title.gsub(/:([\w\-+]+(?::t\d)?):/, "").strip if SiteSetting.max_emojis_in_title == 0
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
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)
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.bookmark.not_found", i18n_post_args))
end
false
end
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
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.onebox.not_found", i18n_post_args))
end
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
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.formatting.not_found", i18n_post_args))
end
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
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.quoting.not_found", i18n_post_args))
end
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
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.emoji.not_found", i18n_post_args))
end
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
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.search.not_found", i18n_post_args))
end
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_no_prefix),
)
end
end
end