Merge discourse-narrative-bot into core plugins.

This commit is contained in:
Guo Xiang Tan
2017-05-24 13:50:20 +08:00
parent 796a2967af
commit 7f0561b621
52 changed files with 7298 additions and 0 deletions

View File

@ -0,0 +1,85 @@
module DiscourseNarrativeBot
module Actions
extend ActiveSupport::Concern
included do
def self.discobot_user
@discobot ||= User.find(-2)
end
end
private
def reply_to(post, raw, opts = {})
if post
default_opts = {
raw: raw,
topic_id: post.topic_id,
reply_to_post_number: post.post_number
}
new_post = PostCreator.create!(self.class.discobot_user, default_opts.merge(opts))
reset_rate_limits(post) if new_post
new_post
else
PostCreator.create!(self.class.discobot_user, { raw: raw }.merge(opts))
end
end
def reset_rate_limits(post)
user = post.user
data = DiscourseNarrativeBot::Store.get(user.id.to_s)
return unless data
key = "#{DiscourseNarrativeBot::PLUGIN_NAME}:reset-rate-limit:#{post.topic_id}:#{data['state']}"
if !(count = $redis.get(key))
count = 0
duration =
if user && user.new_user?
SiteSetting.rate_limit_new_user_create_post
else
SiteSetting.rate_limit_create_post
end
$redis.setex(key, duration, count)
end
if count.to_i < 2
post.default_rate_limiter.rollback!
post.limit_posts_per_day&.rollback!
$redis.incr(key)
end
end
def fake_delay
sleep(rand(2..3)) if Rails.env.production?
end
def bot_mentioned?(post)
doc = Nokogiri::HTML.fragment(post.cooked)
valid = false
doc.css(".mention").each do |mention|
valid = true if mention.text == "@#{self.class.discobot_user.username}"
end
valid
end
def reply_to_bot_post?(post)
post&.reply_to_post && post.reply_to_post.user_id == -2
end
def pm_to_bot?(post)
topic = post.topic
return false if !topic
topic.pm_with_non_human_user? &&
topic.topic_allowed_users.where(user_id: -2).exists?
end
end
end

View File

@ -0,0 +1,384 @@
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") },
init: {
action: :start_advanced_track
}
},
tutorial_edit: {
next_state: :tutorial_delete,
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.delete.instructions") },
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") },
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.last
slug = category.slug
if parent_category = category.parent_category
slug = "#{parent_category.slug}#{CategoryHashtag::SEPARATOR}#{slug}"
end
I18n.t("#{I18N_KEY}.category_hashtag.instructions",
category: "##{slug}"
)
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 { I18n.t("#{I18N_KEY}.change_topic_notification_level.instructions") },
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") },
topic_notification_level_changed: {
action: :reply_to_topic_notification_level_changed
},
reply: {
next_state: :tutorial_notification_level,
action: :missing_topic_notification_level_change
}
},
tutorial_poll: {
next_state: :tutorial_details,
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.details.instructions") },
reply: {
action: :reply_to_poll
}
},
tutorial_details: {
next_state: :end,
reply: {
action: :reply_to_details
}
}
}
def self.can_start?(user)
return true if user.staff?
user.badges.where(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME).exists?
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",
discobot_username: self.class.discobot_user.username
),
topic_id: data[:topic_id],
skip_bot: 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",
discobot_username: self.class.discobot_user.username
),
topic_id: data[:topic_id],
skip_bot: true
})
set_state_data(:post_id, post.id)
PostDestroyer.new(@user, post, skip_bot: true).destroy
end
def start_advanced_track
raw = I18n.t("#{I18N_KEY}.start_message", username: @user.username)
raw = <<~RAW
#{raw}
#{instance_eval(&@next_instructions)}
RAW
opts = {
title: I18n.t("#{I18N_KEY}.title"),
target_usernames: @user.username,
archetype: Archetype.private_message
}
if @post &&
@post.archetype == Archetype.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])
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 = <<~RAW
#{I18n.t("#{I18N_KEY}.edit.reply")}
#{instance_eval(&@next_instructions)}
RAW
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",
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 = <<~RAW
#{I18n.t("#{I18N_KEY}.delete.reply")}
#{instance_eval(&@next_instructions)}
RAW
PostCreator.create!(self.class.discobot_user,
raw: raw,
topic_id: @topic_id
)
end
def missing_delete
return unless valid_topic?(@post.topic_id)
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.delete.not_found")) unless @data[:attempted]
enqueue_timeout_job(@user)
false
end
def reply_to_recover
return unless valid_topic?(@post.topic_id)
fake_delay
raw = <<~RAW
#{I18n.t("#{I18N_KEY}.recover.reply")}
#{instance_eval(&@next_instructions)}
RAW
PostCreator.create!(self.class.discobot_user,
raw: raw,
topic_id: @post.topic_id
)
end
def missing_recover
return unless valid_topic?(@post.topic_id) &&
post_id = get_state_data(:post_id) && @post.id != post_id
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.recover.not_found")) unless @data[:attempted]
enqueue_timeout_job(@user)
false
end
def reply_to_category_hashtag
topic_id = @post.topic_id
return unless valid_topic?(topic_id)
if Nokogiri::HTML.fragment(@post.cooked).css('.hashtag').size > 0
raw = <<~RAW
#{I18n.t("#{I18N_KEY}.category_hashtag.reply")}
#{instance_eval(&@next_instructions)}
RAW
fake_delay
reply_to(@post, raw)
else
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.category_hashtag.not_found")) unless @data[:attempted]
enqueue_timeout_job(@user)
false
end
end
def missing_topic_notification_level_change
return unless valid_topic?(@post.topic_id)
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.change_topic_notification_level.not_found")) unless @data[:attempted]
enqueue_timeout_job(@user)
false
end
def reply_to_topic_notification_level_changed
return unless valid_topic?(@topic_id)
fake_delay
raw = <<~RAW
#{I18n.t("#{I18N_KEY}.change_topic_notification_level.reply")}
#{instance_eval(&@next_instructions)}
RAW
fake_delay
post = PostCreator.create!(self.class.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::HTML.fragment(@post.cooked).css(".poll").size > 0
raw = <<~RAW
#{I18n.t("#{I18N_KEY}.poll.reply")}
#{instance_eval(&@next_instructions)}
RAW
fake_delay
reply_to(@post, raw)
else
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.poll.not_found")) unless @data[:attempted]
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::HTML.fragment(@post.cooked).css("details").size > 0
reply_to(@post, I18n.t("#{I18N_KEY}.details.reply"))
else
reply_to(@post, I18n.t("#{I18N_KEY}.details.not_found")) unless @data[:attempted]
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"))
else
reply_to(@post, I18n.t("#{I18N_KEY}.wiki.not_found")) unless @data[:attempted]
enqueue_timeout_job(@user)
false
end
end
def end_reply
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.end.message",
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

View File

@ -0,0 +1,192 @@
module DiscourseNarrativeBot
class Base
include Actions
TIMEOUT_DURATION = 900 # 15 mins
class InvalidTransitionError < StandardError; end
def input(input, user, post: nil, topic_id: nil, skip: false)
new_post = nil
@post = post
@topic_id = topic_id
@skip = skip
synchronize(user) do
@user = user
@data = get_data(user) || {}
@state = (@data[:state] && @data[:state].to_sym) || :begin
@input = input
opts = {}
begin
opts = transition
rescue InvalidTransitionError
# For given input, no transition for current state
return
end
next_state = opts[:next_state]
action = opts[:action]
if next_instructions = opts[:next_instructions]
@next_instructions = next_instructions
end
begin
old_data = @data.dup
new_post = (@skip && @state != :end) ? skip_tutorial(next_state) : self.send(action)
if new_post
old_state = old_data[:state]
state_changed = (old_state.to_s != next_state.to_s)
clean_up_state(old_state) if state_changed
@state = @data[:state] = next_state
@data[:last_post_id] = new_post.id
set_data(@user, @data)
init_state(next_state) if state_changed
if next_state == :end
end_reply
cancel_timeout_job(user)
BadgeGranter.grant(
Badge.find_by(name: self.class::BADGE_NAME),
user
)
set_data(@user,
topic_id: new_post.topic_id,
state: :end,
track: self.class.to_s
)
end
end
rescue => e
@data = old_data
set_data(@user, @data)
raise e
end
end
new_post
end
def reset_bot
not_implemented
end
def set_data(user, value)
DiscourseNarrativeBot::Store.set(user.id, value)
end
def get_data(user)
DiscourseNarrativeBot::Store.get(user.id)
end
def notify_timeout(user)
@data = get_data(user) || {}
if post = Post.find_by(id: @data[:last_post_id])
reply_to(post, I18n.t("discourse_narrative_bot.timeout.message",
username: user.username,
skip_trigger: TrackSelector.skip_trigger,
reset_trigger: "#{TrackSelector.reset_trigger} #{self.class.reset_trigger}",
))
end
end
def certificate(type = nil)
options = {
user_id: @user.id,
date: Time.zone.now.strftime('%b %d %Y'),
host: Discourse.base_url,
format: :svg
}
options.merge!(type: type) if type
src = DiscourseNarrativeBot::Engine.routes.url_helpers.certificate_url(options)
"<img class='discobot-certificate' src='#{src}' width='650' height='464' alt='#{I18n.t("#{self.class::I18N_KEY}.certificate.alt")}'>"
end
protected
def set_state_data(key, value)
@data[@state] ||= {}
@data[@state][key] = value
set_data(@user, @data)
end
def get_state_data(key)
@data[@state] ||= {}
@data[@state][key]
end
def reset_data(user, additional_data = {})
old_data = get_data(user)
new_data = additional_data
set_data(user, new_data)
new_data
end
def transition
options = self.class::TRANSITION_TABLE.fetch(@state).dup
input_options = options.fetch(@input)
options.merge!(input_options) unless @skip
options
rescue KeyError
raise InvalidTransitionError.new
end
def skip_tutorial(next_state)
return unless valid_topic?(@post.topic_id)
fake_delay
if next_state != :end
reply = reply_to(@post, instance_eval(&@next_instructions))
enqueue_timeout_job(@user)
reply
else
@post
end
end
def valid_topic?(topic_id)
topic_id == @data[:topic_id]
end
def cancel_timeout_job(user)
Jobs.cancel_scheduled_job(:narrative_timeout, user_id: user.id, klass: self.class.to_s)
end
def enqueue_timeout_job(user)
return if Rails.env.test?
cancel_timeout_job(user)
Jobs.enqueue_in(TIMEOUT_DURATION, :narrative_timeout,
user_id: user.id,
klass: self.class.to_s
)
end
def not_implemented
raise 'Not implemented.'
end
private
def clean_up_state(state)
clean_up_method = "clean_up_#{state}"
self.send(clean_up_method) if self.class.private_method_defined?(clean_up_method)
end
def init_state(state)
init_method = "init_#{state}"
self.send(init_method) if self.class.private_method_defined?(init_method)
end
end
end

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,32 @@
module DiscourseNarrativeBot
class Dice
MAXIMUM_NUM_OF_DICE = 20
MAXIMUM_RANGE_OF_DICE = 120
def self.roll(num_of_dice, range_of_dice)
if num_of_dice == 0 || range_of_dice == 0
return I18n.t('discourse_narrative_bot.dice.invalid')
end
output = ''
if num_of_dice > MAXIMUM_NUM_OF_DICE
output << I18n.t('discourse_narrative_bot.dice.not_enough_dice',
num_of_dice: MAXIMUM_NUM_OF_DICE
)
output << "\n\n"
num_of_dice = MAXIMUM_NUM_OF_DICE
end
if range_of_dice > MAXIMUM_RANGE_OF_DICE
output << I18n.t('discourse_narrative_bot.dice.out_of_range')
output << "\n\n"
range_of_dice = MAXIMUM_RANGE_OF_DICE
end
output << I18n.t('discourse_narrative_bot.dice.results',
results: num_of_dice.times.map { rand(1..range_of_dice) }.join(", ")
)
end
end
end

View File

@ -0,0 +1,9 @@
module DiscourseNarrativeBot
class Magic8Ball
def self.generate_answer
I18n.t("discourse_narrative_bot.magic_8_ball.result", result: I18n.t(
"discourse_narrative_bot.magic_8_ball.answers.#{rand(1..20)}"
))
end
end
end

View File

@ -0,0 +1,519 @@
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") },
action: :say_hello
}
},
tutorial_bookmark: {
next_state: :tutorial_onebox,
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.onebox.instructions") },
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") },
reply: {
action: :reply_to_onebox
}
},
tutorial_emoji: {
next_state: :tutorial_mention,
next_instructions: Proc.new {
I18n.t("#{I18N_KEY}.mention.instructions", discobot_username: self.class.discobot_user.username)
},
reply: {
action: :reply_to_emoji
}
},
tutorial_mention: {
next_state: :tutorial_formatting,
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.formatting.instructions") },
reply: {
action: :reply_to_mention
}
},
tutorial_formatting: {
next_state: :tutorial_quote,
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.quoting.instructions") },
reply: {
action: :reply_to_formatting
}
},
tutorial_quote: {
next_state: :tutorial_images,
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.images.instructions") },
reply: {
action: :reply_to_quote
}
},
tutorial_images: {
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))
},
reply: {
action: :reply_to_image
},
like: {
action: :track_like
}
},
tutorial_flag: {
next_state: :tutorial_search,
next_instructions: Proc.new { I18n.t("#{I18N_KEY}.search.instructions") },
flag: {
action: :reply_to_flag
},
reply: {
next_state: :tutorial_flag,
action: :missing_flag
}
},
tutorial_search: {
next_state: :end,
reply: {
action: :reply_to_search
}
}
}
SEARCH_ANSWER = ':herb:'.freeze
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 = <<~RAW
#{post.raw}
#{I18n.t("#{I18N_KEY}.search.hidden_message")}
RAW
PostRevisor.new(post, topic).revise!(
self.class.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",
username: @user.username,
title: SiteSetting.title
)
raw = <<~RAW
#{raw}
#{instance_eval(&@next_instructions)}
RAW
opts = {
title: I18n.t("#{I18N_KEY}.hello.title", title: SiteSetting.title),
target_usernames: @user.username,
archetype: Archetype.private_message
}
if @post &&
@post.archetype == Archetype.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])
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.class.discobot_user.id
fake_delay
enqueue_timeout_job(@user)
reply_to(@post, I18n.t("#{I18N_KEY}.bookmark.not_found")) unless @data[:attempted]
false
end
def reply_to_bookmark
return unless valid_topic?(@post.topic_id)
return unless @post.user_id == self.class.discobot_user.id
raw = <<~RAW
#{I18n.t("#{I18N_KEY}.bookmark.reply", profile_page_url: url_helpers(:user_url, username: @user.username))}
#{instance_eval(&@next_instructions)}
RAW
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 = <<~RAW
#{I18n.t("#{I18N_KEY}.onebox.reply")}
#{instance_eval(&@next_instructions)}
RAW
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")) unless @data[:attempted]
enqueue_timeout_job(@user)
false
end
end
def track_like
post_topic_id = @post.topic_id
return unless valid_topic?(post_topic_id)
post_liked = PostAction.find_by(
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 = <<~RAW
#{I18n.t("#{I18N_KEY}.images.reply")}
#{instance_eval(&@next_instructions)}
RAW
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)
@post.post_analyzer.cook(@post.raw, {})
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
if @post.post_analyzer.image_count > 0
set_state_data(:post_id, @post.id)
if get_state_data(:liked)
raw = <<~RAW
#{I18n.t("#{I18N_KEY}.images.reply")}
#{instance_eval(&@next_instructions)}
RAW
like_post(@post)
else
raw = I18n.t(
"#{I18N_KEY}.images.like_not_found",
url: Post.find_by(id: @data[:last_post_id]).url
)
transition = false
end
else
raw = I18n.t(
"#{I18N_KEY}.images.not_found",
image_url: "#{Discourse.base_url}/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 reply_to_formatting
post_topic_id = @post.topic_id
return unless valid_topic?(post_topic_id)
if Nokogiri::HTML.fragment(@post.cooked).css("b", "strong", "em", "i", ".bbcode-i", ".bbcode-b").size > 0
raw = <<~RAW
#{I18n.t("#{I18N_KEY}.formatting.reply")}
#{instance_eval(&@next_instructions)}
RAW
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")) 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::HTML.fragment(@post.cooked)
if doc.css(".quote").size > 0
raw = <<~RAW
#{I18n.t("#{I18N_KEY}.quoting.reply")}
#{instance_eval(&@next_instructions)}
RAW
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")) 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::HTML.fragment(@post.cooked)
if doc.css(".emoji").size > 0
raw = <<~RAW
#{I18n.t("#{I18N_KEY}.emoji.reply")}
#{instance_eval(&@next_instructions)}
RAW
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")) 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 = <<~RAW
#{I18n.t("#{I18N_KEY}.mention.reply")}
#{instance_eval(&@next_instructions)}
RAW
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",
username: @user.username,
discobot_username: self.class.discobot_user.username
))
end
enqueue_timeout_job(@user)
false
end
end
def missing_flag
return unless valid_topic?(@post.topic_id)
return if @post.user_id == -2
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.flag.not_found")) 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 = <<~RAW
#{I18n.t("#{I18N_KEY}.flag.reply")}
#{instance_eval(&@next_instructions)}
RAW
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.match(/#{SEARCH_ANSWER}/)
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.search.reply", search_url: url_helpers(:search_url)))
else
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.search.not_found")) unless @data[:attempted]
enqueue_timeout_job(@user)
false
end
end
def end_reply
fake_delay
reply_to(
@post,
I18n.t("#{I18N_KEY}.end.message",
username: @user.username,
base_url: Discourse.base_url,
certificate: certificate,
discobot_username: self.class.discobot_user.username,
advanced_trigger: AdvancedUserNarrative.reset_trigger
),
topic_id: @data[:topic_id]
)
end
def like_post(post)
PostAction.act(self.class.discobot_user, post, PostActionType.types[:like])
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.send(url, opts.merge(host: Discourse.base_url))
end
end
end

View File

@ -0,0 +1,27 @@
require 'excon'
module DiscourseNarrativeBot
class QuoteGenerator
API_ENDPOINT = 'http://api.forismatic.com/api/1.0/'.freeze
def self.generate(user)
quote, author =
if user.effective_locale != 'en'
translation_key = "discourse_narrative_bot.quote.#{rand(1..10)}"
[
I18n.t("#{translation_key}.quote"),
I18n.t("#{translation_key}.author")
]
else
connection = Excon.new("#{API_ENDPOINT}?lang=en&format=json&method=getQuote")
response = connection.request(expects: [200, 201], method: :Get)
response_body = JSON.parse(response.body)
[response_body["quoteText"].strip, response_body["quoteAuthor"].strip]
end
I18n.t('discourse_narrative_bot.quote.results', quote: quote, author: author)
end
end
end

View File

@ -0,0 +1,266 @@
module DiscourseNarrativeBot
class TrackSelector
include Actions
GENERIC_REPLIES_COUNT_PREFIX = 'discourse-narrative-bot:track-selector-count:'.freeze
PUBLIC_DISPLAY_BOT_HELP_KEY = 'discourse-narrative-bot:track-selector:display-bot-help'.freeze
TRACKS = [
NewUserNarrative,
AdvancedUserNarrative
]
TOPIC_ACTIONS = [
:delete,
:topic_notification_level_changed
].each(&:freeze)
RESET_TRIGGER_EXACT_MATCH_LENGTH = 200
def initialize(input, user, post_id:, topic_id: nil)
@input = input
@user = user
@post_id = post_id
@topic_id = topic_id
@post = Post.find_by(id: post_id)
end
def select
data = Store.get(@user.id)
if @post && !is_topic_action?
is_reply = @input == :reply
return if is_reply && reset_track
topic_id = @post.topic_id
if (data && data[:topic_id] == topic_id)
state = data[:state]
klass = (data[:track] || NewUserNarrative.to_s).constantize
if is_reply && like_user_post
Store.set(@user.id, data.merge!(state: nil, topic_id: nil))
elsif state&.to_sym == :end && is_reply
bot_commands(bot_mentioned?) || generic_replies(klass.reset_trigger)
elsif is_reply
previous_status = data[:attempted]
current_status = klass.new.input(@input, @user, post: @post, skip: skip_track?)
data = Store.get(@user.id)
data[:attempted] = !current_status
if previous_status && data[:attempted] == previous_status && !data[:skip_attempted]
generic_replies(klass.reset_trigger, state)
else
$redis.del(generic_replies_key(@user))
end
Store.set(@user.id, data)
else
klass.new.input(@input, @user, post: @post, skip: skip_track?)
end
elsif is_reply && (pm_to_bot?(@post) || public_reply?)
like_user_post
bot_commands
end
elsif data && data.dig(:state)&.to_sym != :end && is_topic_action?
klass = (data[:track] || NewUserNarrative.to_s).constantize
klass.new.input(@input, @user, post: @post, topic_id: @topic_id)
end
end
def self.reset_trigger
I18n.t(i18n_key("reset_trigger"))
end
def self.skip_trigger
I18n.t(i18n_key("skip_trigger"))
end
def self.help_trigger
I18n.t(i18n_key("help_trigger"))
end
def self.quote_trigger
I18n.t("discourse_narrative_bot.quote.trigger")
end
def self.dice_trigger
I18n.t("discourse_narrative_bot.dice.trigger")
end
def self.magic_8_ball_trigger
I18n.t("discourse_narrative_bot.magic_8_ball.trigger")
end
private
def is_topic_action?
@is_topic_action ||= TOPIC_ACTIONS.include?(@input)
end
def reset_track
reset = false
TRACKS.each do |klass|
if selected_track(klass)
klass.new.reset_bot(@user, @post)
reset = true
break
end
end
reset
end
def selected_track(klass)
return if klass.respond_to?(:can_start?) && !klass.can_start?(@user)
post_raw = @post.raw
trigger = "#{self.class.reset_trigger} #{klass.reset_trigger}"
if post_raw.length < RESET_TRIGGER_EXACT_MATCH_LENGTH && pm_to_bot?(@post)
post_raw.match(Regexp.new("\\b\\W\?#{trigger}\\W\?\\b", 'i'))
else
match_trigger?(trigger)
end
end
def bot_commands(hint = true)
raw =
if match_data = match_trigger?("#{self.class.dice_trigger} (\\d+)d(\\d+)")
DiscourseNarrativeBot::Dice.roll(match_data[1].to_i, match_data[2].to_i)
elsif match_trigger?(self.class.quote_trigger)
DiscourseNarrativeBot::QuoteGenerator.generate(@user)
elsif match_trigger?(self.class.magic_8_ball_trigger)
DiscourseNarrativeBot::Magic8Ball.generate_answer
elsif match_trigger?(self.class.help_trigger)
help_message
elsif hint
message = I18n.t(self.class.i18n_key('random_mention.reply'),
discobot_username: self.class.discobot_user.username,
help_trigger: self.class.help_trigger
)
if public_reply?
key = "#{PUBLIC_DISPLAY_BOT_HELP_KEY}:#{@post.topic_id}"
last_bot_help_post_number = $redis.get(key)
if !last_bot_help_post_number ||
(last_bot_help_post_number &&
@post.post_number - 10 > last_bot_help_post_number.to_i &&
(1.day.to_i - $redis.ttl(key)) > 6.hours.to_i)
$redis.setex(key, 1.day.to_i, @post.post_number)
message
end
else
message
end
end
if raw
fake_delay
reply_to(@post, raw, skip_validations: true)
end
end
def help_message
tracks = [NewUserNarrative.reset_trigger]
if @user.staff? ||
@user.badges.where(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME).exists?
tracks << AdvancedUserNarrative.reset_trigger
end
discobot_username = self.class.discobot_user.username
message = I18n.t(
self.class.i18n_key('random_mention.tracks'),
discobot_username: discobot_username,
reset_trigger: self.class.reset_trigger,
default_track: NewUserNarrative.reset_trigger,
tracks: tracks.join(', ')
)
message << "\n\n#{I18n.t(self.class.i18n_key('random_mention.bot_actions'),
discobot_username: discobot_username,
dice_trigger: self.class.dice_trigger,
quote_trigger: self.class.quote_trigger,
magic_8_ball_trigger: self.class.magic_8_ball_trigger
)}"
end
def generic_replies_key(user)
"#{GENERIC_REPLIES_COUNT_PREFIX}#{user.id}"
end
def generic_replies(track_reset_trigger, state = nil)
reset_trigger = "#{self.class.reset_trigger} #{track_reset_trigger}"
key = generic_replies_key(@user)
count = ($redis.get(key) || $redis.setex(key, 900, 0)).to_i
case count
when 0
raw = I18n.t(self.class.i18n_key('do_not_understand.first_response'))
if state && state.to_sym != :end
raw = "#{raw}\n\n#{I18n.t(self.class.i18n_key('do_not_understand.track_response'), reset_trigger: reset_trigger, skip_trigger: self.class.skip_trigger)}"
end
reply_to(@post, raw)
when 1
reply_to(@post, I18n.t(self.class.i18n_key('do_not_understand.second_response'),
reset_trigger: self.class.reset_trigger
))
else
# Stay out of the user's way
end
$redis.incr(key)
end
def self.i18n_key(key)
"discourse_narrative_bot.track_selector.#{key}"
end
def skip_track?
if pm_to_bot?(@post)
post_raw = @post.raw
post_raw.match(/^@#{self.class.discobot_user.username} #{self.class.skip_trigger}/i) ||
post_raw.strip == self.class.skip_trigger
else
false
end
end
def match_trigger?(trigger)
discobot_username = self.class.discobot_user.username
regexp = Regexp.new("<a class=\"mention\".*>@#{discobot_username}</a> #{trigger}", 'i')
match = @post.cooked.match(regexp)
if pm_to_bot?(@post)
match || @post.raw.strip.match(Regexp.new("^#{trigger}$", 'i'))
else
match
end
end
def like_user_post
if @post.raw.match(/thank/i)
PostAction.act(self.class.discobot_user, @post, PostActionType.types[:like])
end
end
def bot_mentioned?
@bot_mentioned ||= PostAnalyzer.new(@post.raw, @post.topic_id).raw_mentions.include?(
self.class.discobot_user.username
)
end
def public_reply?
!SiteSetting.discourse_narrative_bot_disable_public_replies &&
(bot_mentioned? || reply_to_bot_post?(@post))
end
end
end

View File

@ -0,0 +1,18 @@
module DiscourseNarrativeBot
class WelcomePostTypeSiteSetting
def self.valid_value?(val)
values.any? { |v| v[:value] == val.to_s }
end
def self.values
@values ||= [
{ name: 'discourse_narrative_bot.welcome_post_type.new_user_track', value: 'new_user_track' },
{ name: 'discourse_narrative_bot.welcome_post_type.welcome_message', value: 'welcome_message' }
]
end
def self.translate_names?
true
end
end
end