# 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_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)) end end end