Martin Brennan 14983c5b8e
FIX: New hashtag support for narrative bot advanced narrative (#19875)
The discobot advanced tutorial was failing when the new hashtags
were enabled with enable_experimental_hashtag_autocomplete set
to true, since the CSS selector is different. This commit fixes
the issue and also changes the instructions if this is enabled since
we no longer require the hashtag to not be at the start of the line.

c.f. https://meta.discourse.org/t/it-is-impossible-to-complete-the-hashtag-section-of-the-discobot-advanced-tutorial/251494
2023-01-17 10:16:28 +10:00

436 lines
12 KiB
Ruby

# frozen_string_literal: true
module DiscourseNarrativeBot
class AdvancedUserNarrative < Base
I18N_KEY = "discourse_narrative_bot.advanced_user_narrative".freeze
BADGE_NAME = "Licensed".freeze
TRANSITION_TABLE = {
begin: {
next_state: :tutorial_edit,
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.edit.instructions", i18n_post_args) },
init: {
action: :start_advanced_track,
},
},
tutorial_edit: {
next_state: :tutorial_delete,
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.delete.instructions", i18n_post_args) },
edit: {
action: :reply_to_edit,
},
reply: {
next_state: :tutorial_edit,
action: :missing_edit,
},
},
tutorial_delete: {
next_state: :tutorial_recover,
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.recover.instructions", i18n_post_args) },
delete: {
action: :reply_to_delete,
},
reply: {
next_state: :tutorial_delete,
action: :missing_delete,
},
},
tutorial_recover: {
next_state: :tutorial_category_hashtag,
next_instructions:
Proc.new do
category = Category.secured(Guardian.new(@user)).last
slug = category.slug
if parent_category = category.parent_category
slug = "#{parent_category.slug}#{CategoryHashtag::SEPARATOR}#{slug}"
end
# TODO (martin) When enable_experimental_hashtag_autocomplete is the only option
# update the instructions and remove instructions_experimental, as well as the
# not_found translation
if SiteSetting.enable_experimental_hashtag_autocomplete
I18n.t(
"#{I18N_KEY}.category_hashtag.instructions_experimental",
i18n_post_args(category: "##{slug}"),
)
else
I18n.t(
"#{I18N_KEY}.category_hashtag.instructions",
i18n_post_args(category: "##{slug}"),
)
end
end,
recover: {
action: :reply_to_recover,
},
reply: {
next_state: :tutorial_recover,
action: :missing_recover,
},
},
tutorial_category_hashtag: {
next_state: :tutorial_change_topic_notification_level,
next_instructions:
Proc.new do
I18n.t("#{I18N_KEY}.change_topic_notification_level.instructions", i18n_post_args)
end,
reply: {
action: :reply_to_category_hashtag,
},
},
tutorial_change_topic_notification_level: {
next_state: :tutorial_poll,
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.poll.instructions", i18n_post_args) },
topic_notification_level_changed: {
action: :reply_to_topic_notification_level_changed,
},
reply: {
next_state: :tutorial_change_topic_notification_level,
action: :missing_topic_notification_level_change,
},
},
tutorial_poll: {
prerequisite:
Proc.new do
SiteSetting.poll_enabled &&
@user.has_trust_level?(SiteSetting.poll_minimum_trust_level_to_create)
end,
next_state: :tutorial_details,
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.details.instructions", i18n_post_args) },
reply: {
action: :reply_to_poll,
},
},
tutorial_details: {
next_state: :end,
reply: {
action: :reply_to_details,
},
},
}
def self.badge_name
BADGE_NAME
end
def self.reset_trigger
I18n.t("discourse_narrative_bot.advanced_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 init_tutorial_edit
data = get_data(@user)
fake_delay
post =
PostCreator.create!(
@user,
raw:
I18n.t(
"#{I18N_KEY}.edit.bot_created_post_raw",
i18n_post_args(discobot_username: self.discobot_username),
),
topic_id: data[:topic_id],
skip_bot: true,
skip_validations: true,
)
set_state_data(:post_id, post.id)
post
end
def init_tutorial_recover
data = get_data(@user)
post =
PostCreator.create!(
@user,
raw:
I18n.t(
"#{I18N_KEY}.recover.deleted_post_raw",
i18n_post_args(discobot_username: self.discobot_username),
),
topic_id: data[:topic_id],
skip_bot: true,
skip_validations: true,
)
set_state_data(:post_id, post.id)
opts = { skip_bot: true }
if SiteSetting.delete_removed_posts_after < 1
opts[:delete_removed_posts_after] = 1
result = PostActionCreator.notify_moderators(self.discobot_user, post)
result.reviewable.perform(self.discobot_user, :ignore)
end
PostDestroyer.new(@user, post, opts).destroy
end
def start_advanced_track
raw = I18n.t("#{I18N_KEY}.start_message", i18n_post_args(username: @user.username))
raw = <<~MD
#{raw}
#{instance_eval(&@next_instructions)}
MD
opts = {
title: I18n.t("#{I18N_KEY}.title"),
target_usernames: @user.username,
archetype: Archetype.private_message,
}
if @post && @post.topic.private_message? &&
@post.topic.topic_allowed_users.pluck(:user_id).include?(@user.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 reply_to_edit
return unless valid_topic?(@post.topic_id)
fake_delay
raw = <<~MD
#{I18n.t("#{I18N_KEY}.edit.reply", i18n_post_args)}
#{instance_eval(&@next_instructions)}
MD
reply_to(@post, raw)
end
def missing_edit
post_id = get_state_data(:post_id)
return unless valid_topic?(@post.topic_id) && post_id != @post.id
fake_delay
unless @data[:attempted]
reply_to(
@post,
I18n.t("#{I18N_KEY}.edit.not_found", i18n_post_args(url: Post.find_by(id: post_id).url)),
)
end
enqueue_timeout_job(@user)
false
end
def reply_to_delete
return unless valid_topic?(@topic_id)
fake_delay
raw = <<~MD
#{I18n.t("#{I18N_KEY}.delete.reply", i18n_post_args)}
#{instance_eval(&@next_instructions)}
MD
PostCreator.create!(self.discobot_user, raw: raw, topic_id: @topic_id)
end
def missing_delete
return unless valid_topic?(@post.topic_id)
fake_delay
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.delete.not_found", i18n_post_args))
end
enqueue_timeout_job(@user)
false
end
def reply_to_recover
return unless valid_topic?(@post.topic_id)
fake_delay
raw = <<~MD
#{I18n.t("#{I18N_KEY}.recover.reply", i18n_post_args(deletion_after: SiteSetting.delete_removed_posts_after))}
#{instance_eval(&@next_instructions)}
MD
PostCreator.create!(self.discobot_user, raw: raw, topic_id: @post.topic_id)
end
def missing_recover
unless valid_topic?(@post.topic_id) &&
post_id = get_state_data(:post_id) && @post.id != post_id
return
end
fake_delay
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.recover.not_found", i18n_post_args))
end
enqueue_timeout_job(@user)
false
end
def reply_to_category_hashtag
topic_id = @post.topic_id
return unless valid_topic?(topic_id)
hashtag_css_class =
SiteSetting.enable_experimental_hashtag_autocomplete ? ".hashtag-cooked" : ".hashtag"
if Nokogiri::HTML5.fragment(@post.cooked).css(hashtag_css_class).size > 0
raw = <<~MD
#{I18n.t("#{I18N_KEY}.category_hashtag.reply", i18n_post_args)}
#{instance_eval(&@next_instructions)}
MD
fake_delay
reply_to(@post, raw)
else
fake_delay
unless @data[:attempted]
if SiteSetting.enable_experimental_hashtag_autocomplete
reply_to(
@post,
I18n.t("#{I18N_KEY}.category_hashtag.not_found_experimental", i18n_post_args),
)
else
reply_to(@post, I18n.t("#{I18N_KEY}.category_hashtag.not_found", i18n_post_args))
end
end
enqueue_timeout_job(@user)
false
end
end
def missing_topic_notification_level_change
return unless valid_topic?(@post.topic_id)
fake_delay
unless @data[:attempted]
reply_to(
@post,
I18n.t("#{I18N_KEY}.change_topic_notification_level.not_found", i18n_post_args),
)
end
enqueue_timeout_job(@user)
false
end
def reply_to_topic_notification_level_changed
return unless valid_topic?(@topic_id)
fake_delay
raw = <<~MD
#{I18n.t("#{I18N_KEY}.change_topic_notification_level.reply", i18n_post_args)}
#{instance_eval(&@next_instructions)}
MD
fake_delay
post = PostCreator.create!(self.discobot_user, raw: raw, topic_id: @topic_id)
enqueue_timeout_job(@user)
post
end
def reply_to_poll
topic_id = @post.topic_id
return unless valid_topic?(topic_id)
if Nokogiri::HTML5.fragment(@post.cooked).css(".poll").size > 0
raw = <<~MD
#{I18n.t("#{I18N_KEY}.poll.reply", i18n_post_args)}
#{instance_eval(&@next_instructions)}
MD
fake_delay
reply_to(@post, raw)
else
fake_delay
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.poll.not_found", i18n_post_args))
end
enqueue_timeout_job(@user)
false
end
end
def reply_to_details
topic_id = @post.topic_id
return unless valid_topic?(topic_id)
fake_delay
if Nokogiri::HTML5.fragment(@post.cooked).css("details").size > 0
reply_to(@post, I18n.t("#{I18N_KEY}.details.reply", i18n_post_args))
else
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.details.not_found", i18n_post_args))
end
enqueue_timeout_job(@user)
false
end
end
def reply_to_wiki
topic_id = @post.topic_id
return unless valid_topic?(topic_id)
fake_delay
if @post.wiki
reply_to(@post, I18n.t("#{I18N_KEY}.wiki.reply", i18n_post_args))
else
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.wiki.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(certificate: certificate("advanced"))),
)
end
def synchronize(user)
if Rails.env.test?
yield
else
DistributedMutex.synchronize("advanced_user_narrative_#{user.id}") { yield }
end
end
end
end