DEV: Apply syntax_tree formatting to `plugins/*`

This commit is contained in:
David Taylor 2023-01-06 20:42:16 +00:00
parent 93e2dad656
commit 055310cea4
No known key found for this signature in database
GPG Key ID: 46904C18B1D3F434
110 changed files with 3712 additions and 3158 deletions

View File

@ -5,6 +5,5 @@
--ignore-files=config/*
--ignore-files=db/*
--ignore-files=lib/*
--ignore-files=plugins/*
--ignore-files=script/*
--ignore-files=spec/*

View File

@ -9,7 +9,10 @@ class Chat::Api::CategoryChatablesController < ApplicationController
Group
.joins(:category_groups)
.where(category_groups: { category_id: category.id })
.where("category_groups.permission_type IN (?)", [CategoryGroup.permission_types[:full], CategoryGroup.permission_types[:create_post]])
.where(
"category_groups.permission_type IN (?)",
[CategoryGroup.permission_types[:full], CategoryGroup.permission_types[:create_post]],
)
.joins("LEFT OUTER JOIN group_users ON groups.id = group_users.group_id")
.group("groups.id", "groups.name")
.pluck("groups.name", "COUNT(group_users.user_id)")

View File

@ -9,16 +9,17 @@ class Chat::Api::HintsController < ApplicationController
raise Discourse::InvalidParameters.new(:mentions) if group_names.blank?
visible_groups = Group
.where("LOWER(name) IN (?)", group_names)
.visible_groups(current_user)
.pluck(:name)
visible_groups =
Group.where("LOWER(name) IN (?)", group_names).visible_groups(current_user).pluck(:name)
mentionable_groups = filter_mentionable_groups(visible_groups)
result = {
unreachable: visible_groups - mentionable_groups.map(&:name),
over_members_limit: mentionable_groups.select { |g| g.user_count > SiteSetting.max_users_notified_per_group_mention }.map(&:name),
over_members_limit:
mentionable_groups
.select { |g| g.user_count > SiteSetting.max_users_notified_per_group_mention }
.map(&:name),
}
result[:invalid] = (group_names - result[:unreachable]) - result[:over_members_limit]

View File

@ -5,8 +5,9 @@ module Jobs
def execute(args)
return if args[:user_id].nil?
ChatMessageDestroyer.new
.destroy_in_batches(ChatMessage.with_deleted.where(user_id: args[:user_id]))
ChatMessageDestroyer.new.destroy_in_batches(
ChatMessage.with_deleted.where(user_id: args[:user_id]),
)
end
end
end

View File

@ -15,10 +15,9 @@ module Jobs
return unless valid_day_value?(:chat_channel_retention_days)
ChatMessageDestroyer.new.destroy_in_batches(
ChatMessage
.in_public_channel
.with_deleted
.created_before(SiteSetting.chat_channel_retention_days.days.ago)
ChatMessage.in_public_channel.with_deleted.created_before(
SiteSetting.chat_channel_retention_days.days.ago,
),
)
end
@ -26,10 +25,9 @@ module Jobs
return unless valid_day_value?(:chat_dm_retention_days)
ChatMessageDestroyer.new.destroy_in_batches(
ChatMessage
.in_dm_channel
.with_deleted
.created_before(SiteSetting.chat_dm_retention_days.days.ago)
ChatMessage.in_dm_channel.with_deleted.created_before(
SiteSetting.chat_dm_retention_days.days.ago,
),
)
end

View File

@ -89,7 +89,6 @@ class ChatChannel < ActiveRecord::Base
# TODO (martin) Move UpdateUserCountsForChatChannels into here
def self.update_counts
# NOTE: ChatChannel#messages_count is not updated every time
# a message is created or deleted in a channel, so it should not
# be displayed in the UI. It is updated eventually via Jobs::ChatPeriodicalUpdates

View File

@ -2,11 +2,13 @@
class ChatMessageDestroyer
def destroy_in_batches(chat_messages_query, batch_size: 200)
chat_messages_query.in_batches(of: batch_size).each do |relation|
destroyed_ids = relation.destroy_all.pluck(:id)
reset_last_read(destroyed_ids)
delete_flags(destroyed_ids)
end
chat_messages_query
.in_batches(of: batch_size)
.each do |relation|
destroyed_ids = relation.destroy_all.pluck(:id)
reset_last_read(destroyed_ids)
delete_flags(destroyed_ids)
end
end
private

View File

@ -2,10 +2,12 @@
class SaveChatAllowedGroupsSiteSetting < ActiveRecord::Migration[7.0]
def up
chat_enabled = DB.query_single("SELECT value FROM site_settings WHERE name = 'chat_enabled' AND value = 't'")
chat_enabled =
DB.query_single("SELECT value FROM site_settings WHERE name = 'chat_enabled' AND value = 't'")
return if chat_enabled.blank?
chat_allowed_groups = DB.query_single("SELECT value FROM site_settings WHERE name = 'chat_allowed_groups'")
chat_allowed_groups =
DB.query_single("SELECT value FROM site_settings WHERE name = 'chat_allowed_groups'")
return if chat_allowed_groups.present?
# The original default was auto group ID 3 (staff) so we are

View File

@ -30,10 +30,14 @@ module Chat::ChatChannelFetcher
end
def self.generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: false)
category_channel_sql = Category.post_create_allowed(guardian)
.joins("INNER JOIN chat_channels ON chat_channels.chatable_id = categories.id AND chat_channels.chatable_type = 'Category'")
.select("chat_channels.id")
.to_sql
category_channel_sql =
Category
.post_create_allowed(guardian)
.joins(
"INNER JOIN chat_channels ON chat_channels.chatable_id = categories.id AND chat_channels.chatable_type = 'Category'",
)
.select("chat_channels.id")
.to_sql
dm_channel_sql = ""
if !exclude_dm_channels
dm_channel_sql = <<~SQL
@ -75,8 +79,7 @@ module Chat::ChatChannelFetcher
end
def self.secured_public_channel_search(guardian, options = {})
allowed_channel_ids =
generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true)
allowed_channel_ids = generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true)
channels = ChatChannel.includes(chatable: [:topic_only_relative_url])
channels = channels.includes(:chat_channel_archive) if options[:include_archives]

View File

@ -32,10 +32,11 @@ class Chat::ChatMailer
when_away_frequency = UserOption.chat_email_frequencies[:when_away]
allowed_group_ids = Chat.allowed_group_ids
users = User
.joins(:user_option)
.where(user_options: { chat_enabled: true, chat_email_frequency: when_away_frequency })
.where("users.last_seen_at < ?", 15.minutes.ago)
users =
User
.joins(:user_option)
.where(user_options: { chat_enabled: true, chat_email_frequency: when_away_frequency })
.where("users.last_seen_at < ?", 15.minutes.ago)
if !allowed_group_ids.include?(Group::AUTO_GROUPS[:everyone])
users = users.joins(:groups).where(groups: { id: allowed_group_ids })

View File

@ -41,7 +41,7 @@ class Chat::ChatNotifier
:send_message_notifications,
chat_message_id: chat_message.id,
timestamp: timestamp.iso8601(6),
reason: :edit
reason: :edit,
)
end
@ -50,7 +50,7 @@ class Chat::ChatNotifier
:send_message_notifications,
chat_message_id: chat_message.id,
timestamp: timestamp.iso8601(6),
reason: :new
reason: :new,
)
end
end
@ -112,8 +112,7 @@ class Chat::ChatNotifier
group_mentions_count = group_name_mentions.length
skip_notifications =
(direct_mentions_count + group_mentions_count) >
SiteSetting.max_mentions_per_chat_message
(direct_mentions_count + group_mentions_count) > SiteSetting.max_mentions_per_chat_message
{}.tap do |to_notify|
# The order of these methods is the precedence
@ -248,24 +247,21 @@ class Chat::ChatNotifier
end
def visible_groups
@visible_groups ||=
Group
.where("LOWER(name) IN (?)", group_name_mentions)
.visible_groups(@user)
@visible_groups ||= Group.where("LOWER(name) IN (?)", group_name_mentions).visible_groups(@user)
end
def expand_group_mentions(to_notify, already_covered_ids, skip)
return [] if skip || visible_groups.empty?
mentionable_groups = Group
.mentionable(@user, include_public: false)
.where(id: visible_groups.map(&:id))
mentionable_groups =
Group.mentionable(@user, include_public: false).where(id: visible_groups.map(&:id))
mentions_disabled = visible_groups - mentionable_groups
too_many_members, mentionable = mentionable_groups.partition do |group|
group.user_count > SiteSetting.max_users_notified_per_group_mention
end
too_many_members, mentionable =
mentionable_groups.partition do |group|
group.user_count > SiteSetting.max_users_notified_per_group_mention
end
to_notify[:group_mentions_disabled] = mentions_disabled
to_notify[:too_many_members] = too_many_members
@ -275,7 +271,9 @@ class Chat::ChatNotifier
reached_by_group =
chat_users
.includes(:groups)
.joins(:groups).where(groups: mentionable).where.not(id: already_covered_ids)
.joins(:groups)
.where(groups: mentionable)
.where.not(id: already_covered_ids)
grouped = group_users_to_notify(reached_by_group)
@ -295,7 +293,13 @@ class Chat::ChatNotifier
end
def notify_creator_of_inaccessible_mentions(to_notify)
inaccessible = to_notify.extract!(:unreachable, :welcome_to_join, :too_many_members, :group_mentions_disabled)
inaccessible =
to_notify.extract!(
:unreachable,
:welcome_to_join,
:too_many_members,
:group_mentions_disabled,
)
return if inaccessible.values.all?(&:blank?)
ChatPublisher.publish_inaccessible_mentions(
@ -304,7 +308,7 @@ class Chat::ChatNotifier
inaccessible[:unreachable].to_a,
inaccessible[:welcome_to_join].to_a,
inaccessible[:too_many_members].to_a,
inaccessible[:group_mentions_disabled].to_a
inaccessible[:group_mentions_disabled].to_a,
)
end
@ -321,9 +325,7 @@ class Chat::ChatNotifier
to_notify
.except(:unreachable, :welcome_to_join)
.each do |key, user_ids|
to_notify[key] = user_ids.reject do |user_id|
screener.ignoring_or_muting_actor?(user_id)
end
to_notify[key] = user_ids.reject { |user_id| screener.ignoring_or_muting_actor?(user_id) }
end
# :welcome_to_join contains users because it's serialized by MB.
@ -351,11 +353,7 @@ class Chat::ChatNotifier
def notify_watching_users(except: [])
Jobs.enqueue(
:chat_notify_watching,
{
chat_message_id: @chat_message.id,
except_user_ids: except,
timestamp: @timestamp,
},
{ chat_message_id: @chat_message.id, except_user_ids: except, timestamp: @timestamp },
)
end
end

View File

@ -22,11 +22,11 @@ class Chat::DuplicateMessageValidator
# Check if the same duplicate message has been posted in the last N seconds by any user
if !chat_message
.chat_channel
.chat_messages
.where("created_at > ?", matrix[:min_past_seconds].seconds.ago)
.where(message: chat_message.message)
.exists?
.chat_channel
.chat_messages
.where("created_at > ?", matrix[:min_past_seconds].seconds.ago)
.where(message: chat_message.message)
.exists?
return
end

View File

@ -85,27 +85,27 @@ module Chat::UserNotificationsExtension
"user_notifications.chat_summary.subject.chat_channel_more",
email_prefix: @email_prefix,
channel: channels.first.title,
count: total_count - 1
count: total_count - 1,
)
elsif channels.size == 1 && dm_users.size == 0
I18n.t(
"user_notifications.chat_summary.subject.chat_channel_1",
email_prefix: @email_prefix,
channel: channels.first.title
channel: channels.first.title,
)
elsif channels.size == 1 && dm_users.size == 1
I18n.t(
"user_notifications.chat_summary.subject.chat_channel_and_direct_message",
email_prefix: @email_prefix,
channel: channels.first.title,
username: dm_users.first.username
username: dm_users.first.username,
)
elsif channels.size == 2
I18n.t(
"user_notifications.chat_summary.subject.chat_channel_2",
email_prefix: @email_prefix,
channel1: channels.first.title,
channel2: channels.second.title
channel2: channels.second.title,
)
end
end
@ -116,21 +116,21 @@ module Chat::UserNotificationsExtension
I18n.t(
"user_notifications.chat_summary.subject.direct_message_from_1",
email_prefix: @email_prefix,
username: dm_users.first.username
username: dm_users.first.username,
)
when 2
I18n.t(
"user_notifications.chat_summary.subject.direct_message_from_2",
email_prefix: @email_prefix,
username1: dm_users.first.username,
username2: dm_users.second.username
username2: dm_users.second.username,
)
else
I18n.t(
"user_notifications.chat_summary.subject.direct_message_from_more",
email_prefix: @email_prefix,
username: dm_users.first.username,
count: dm_users.size - 1
count: dm_users.size - 1,
)
end
end

View File

@ -32,10 +32,7 @@ describe Chat::ChatMessageCreator do
)
end
let(:direct_message_channel) do
Chat::DirectMessageChannelCreator.create!(
acting_user: user1,
target_users: [user1, user2],
)
Chat::DirectMessageChannelCreator.create!(acting_user: user1, target_users: [user1, user2])
end
before do
@ -135,13 +132,14 @@ describe Chat::ChatMessageCreator do
end
it "publishes a DiscourseEvent for new messages" do
events = DiscourseEvent.track_events {
Chat::ChatMessageCreator.create(
chat_channel: public_chat_channel,
user: user1,
content: "this is a message",
)
}
events =
DiscourseEvent.track_events do
Chat::ChatMessageCreator.create(
chat_channel: public_chat_channel,
user: user1,
content: "this is a message",
)
end
expect(events.map { _1[:event_name] }).to include(:chat_message_created)
end
@ -368,8 +366,8 @@ describe Chat::ChatMessageCreator do
content: "hello @#{admin_group.name}",
)
}.to change { admin1.chat_mentions.count }.by(1).and change {
admin2.chat_mentions.count
}.by(1)
admin2.chat_mentions.count
}.by(1)
end
it "doesn't mention users twice if they are direct mentioned and group mentioned" do
@ -380,8 +378,8 @@ describe Chat::ChatMessageCreator do
content: "hello @#{admin_group.name} @#{admin1.username} and @#{admin2.username}",
)
}.to change { admin1.chat_mentions.count }.by(1).and change {
admin2.chat_mentions.count
}.by(1)
admin2.chat_mentions.count
}.by(1)
end
it "creates chat mentions for group mentions and direct mentions" do
@ -392,8 +390,8 @@ describe Chat::ChatMessageCreator do
content: "hello @#{admin_group.name} @#{user2.username}",
)
}.to change { admin1.chat_mentions.count }.by(1).and change {
admin2.chat_mentions.count
}.by(1).and change { user2.chat_mentions.count }.by(1)
admin2.chat_mentions.count
}.by(1).and change { user2.chat_mentions.count }.by(1)
end
it "creates chat mentions for group mentions and direct mentions" do
@ -404,10 +402,10 @@ describe Chat::ChatMessageCreator do
content: "hello @#{admin_group.name} @#{user_group.name}",
)
}.to change { admin1.chat_mentions.count }.by(1).and change {
admin2.chat_mentions.count
}.by(1).and change { user2.chat_mentions.count }.by(1).and change {
user3.chat_mentions.count
}.by(1)
admin2.chat_mentions.count
}.by(1).and change { user2.chat_mentions.count }.by(1).and change {
user3.chat_mentions.count
}.by(1)
end
it "doesn't create chat mentions for group mentions where the group is un-mentionable" do
@ -475,8 +473,8 @@ describe Chat::ChatMessageCreator do
upload_ids: [upload1.id, upload2.id],
)
}.to change { ChatUpload.where(upload_id: upload1.id).count }.by(1).and change {
ChatUpload.where(upload_id: upload2.id).count
}.by(1)
ChatUpload.where(upload_id: upload2.id).count
}.by(1)
end
it "filters out uploads that weren't uploaded by the user" do

View File

@ -64,11 +64,11 @@ describe Chat::ChatMessageRateLimiter do
limiter.run!
expect { limiter.run! }.to raise_error(RateLimiter::LimitExceeded).and change {
UserHistory.where(
target_user: user,
acting_user: Discourse.system_user,
action: UserHistory.actions[:silence_user],
).count
}.by(1)
UserHistory.where(
target_user: user,
acting_user: Discourse.system_user,
action: UserHistory.actions[:silence_user],
).count
}.by(1)
end
end

View File

@ -76,9 +76,7 @@ end
Fabricator(:chat_upload) do
transient :user
user do
Fabricate(:user)
end
user { Fabricate(:user) }
chat_message { |attrs| Fabricate(:chat_message, user: attrs[:user]) }
upload { |attrs| Fabricate(:upload, user: attrs[:user]) }

View File

@ -219,9 +219,19 @@ martin</div>
channel = Fabricate(:chat_channel)
message1 = Fabricate(:chat_message, chat_channel: channel, user: post.user)
message2 = Fabricate(:chat_message, chat_channel: channel, user: post.user)
md = ChatTranscriptService.new(channel, message2.user, messages_or_ids: [message2.id]).generate_markdown
md =
ChatTranscriptService.new(
channel,
message2.user,
messages_or_ids: [message2.id],
).generate_markdown
message1.update!(message: md)
md_for_post = ChatTranscriptService.new(channel, message1.user, messages_or_ids: [message1.id]).generate_markdown
md_for_post =
ChatTranscriptService.new(
channel,
message1.user,
messages_or_ids: [message1.id],
).generate_markdown
post.update!(raw: md_for_post)
expect(post.cooked.chomp).to eq(<<~COOKED.chomp)
<div class="chat-transcript" data-message-id="#{message1.id}" data-username="#{message1.user.username}" data-datetime="#{message1.created_at.iso8601}" data-channel-name="#{channel.name}" data-channel-id="#{channel.id}">

View File

@ -56,23 +56,23 @@ describe Jobs::ChatChannelDelete do
expect { described_class.new.execute(chat_channel_id: chat_channel.id) }.to change {
IncomingChatWebhook.where(chat_channel_id: chat_channel.id).count
}.by(-1).and change {
ChatWebhookEvent.where(incoming_chat_webhook_id: @incoming_chat_webhook_id).count
}.by(-1).and change { ChatDraft.where(chat_channel: chat_channel).count }.by(
ChatWebhookEvent.where(incoming_chat_webhook_id: @incoming_chat_webhook_id).count
}.by(-1).and change { ChatDraft.where(chat_channel: chat_channel).count }.by(
-1,
).and change {
UserChatChannelMembership.where(chat_channel: chat_channel).count
}.by(-3).and change {
ChatMessageRevision.where(chat_message_id: @message_ids).count
}.by(-1).and change {
ChatMention.where(chat_message_id: @message_ids).count
}.by(-1).and change {
ChatUpload.where(chat_message_id: @message_ids).count
}.by(-10).and change {
ChatMessage.where(id: @message_ids).count
}.by(-20).and change {
ChatMessageReaction.where(
chat_message_id: @message_ids,
).count
}.by(-10)
ChatMessageRevision.where(chat_message_id: @message_ids).count
}.by(-1).and change {
ChatMention.where(chat_message_id: @message_ids).count
}.by(-1).and change {
ChatUpload.where(chat_message_id: @message_ids).count
}.by(-10).and change {
ChatMessage.where(id: @message_ids).count
}.by(-20).and change {
ChatMessageReaction.where(
chat_message_id: @message_ids,
).count
}.by(-10)
end
end

View File

@ -21,7 +21,7 @@ RSpec.describe Jobs::SendMessageNotifications do
subject.execute(
chat_message_id: chat_message.id,
reason: "invalid",
timestamp: 1.minute.ago
timestamp: 1.minute.ago,
)
end
@ -29,32 +29,21 @@ RSpec.describe Jobs::SendMessageNotifications do
Chat::ChatNotifier.any_instance.expects(:notify_new).never
Chat::ChatNotifier.any_instance.expects(:notify_edit).never
subject.execute(
chat_message_id: chat_message.id,
reason: "new"
)
subject.execute(chat_message_id: chat_message.id, reason: "new")
end
it "calls notify_new when the reason is 'new'" do
Chat::ChatNotifier.any_instance.expects(:notify_new).once
Chat::ChatNotifier.any_instance.expects(:notify_edit).never
subject.execute(
chat_message_id: chat_message.id,
reason: "new",
timestamp: 1.minute.ago
)
subject.execute(chat_message_id: chat_message.id, reason: "new", timestamp: 1.minute.ago)
end
it "calls notify_edit when the reason is 'edit'" do
Chat::ChatNotifier.any_instance.expects(:notify_new).never
Chat::ChatNotifier.any_instance.expects(:notify_edit).once
subject.execute(
chat_message_id: chat_message.id,
reason: "edit",
timestamp: 1.minute.ago
)
subject.execute(chat_message_id: chat_message.id, reason: "edit", timestamp: 1.minute.ago)
end
end
end

View File

@ -142,17 +142,38 @@ describe Chat::ChatChannelFetcher do
fab!(:group_user) { Fabricate(:group_user, group: group, user: user1) }
it "does not include the category channel for member of group with readonly access" do
category_channel.update!(chatable: Fabricate(:private_category, group: group, permission_type: CategoryGroup.permission_types[:readonly]))
category_channel.update!(
chatable:
Fabricate(
:private_category,
group: group,
permission_type: CategoryGroup.permission_types[:readonly],
),
)
expect(subject.all_secured_channel_ids(guardian)).to be_empty
end
it "includes the category channel for member of group with create_post access" do
category_channel.update!(chatable: Fabricate(:private_category, group: group, permission_type: CategoryGroup.permission_types[:create_post]))
category_channel.update!(
chatable:
Fabricate(
:private_category,
group: group,
permission_type: CategoryGroup.permission_types[:create_post],
),
)
expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id])
end
it "includes the category channel for member of group with full access" do
category_channel.update!(chatable: Fabricate(:private_category, group: group, permission_type: CategoryGroup.permission_types[:full]))
category_channel.update!(
chatable:
Fabricate(
:private_category,
group: group,
permission_type: CategoryGroup.permission_types[:full],
),
)
expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id])
end
end

View File

@ -9,7 +9,7 @@ describe Chat::ChatMessageReactor do
fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel, user: reacting_user) }
let(:subject) { described_class.new(reacting_user, channel) }
it 'calls guardian ensure_can_join_chat_channel!' do
it "calls guardian ensure_can_join_chat_channel!" do
Guardian.any_instance.expects(:ensure_can_join_chat_channel!).once
subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:")
end

View File

@ -275,7 +275,7 @@ describe Chat::ChatNotifier do
include_examples "ensure only channel members are notified"
it 'calls guardian can_join_chat_channel?' do
it "calls guardian can_join_chat_channel?" do
Guardian.any_instance.expects(:can_join_chat_channel?).at_least_once
msg = build_cooked_msg("Hello @#{group.name} and @#{user_2.username}", user_1)
to_notify = described_class.new(msg, msg.created_at).notify_new
@ -463,7 +463,8 @@ describe Chat::ChatNotifier do
expect(not_participating_msg).to be_present
expect(not_participating_msg.data[:cannot_see]).to be_empty
not_participating_users = not_participating_msg.data[:without_membership].map { |u| u["id"] }
not_participating_users =
not_participating_msg.data[:without_membership].map { |u| u["id"] }
expect(not_participating_users).to contain_exactly(user_3.id)
end
@ -515,7 +516,8 @@ describe Chat::ChatNotifier do
expect(not_participating_msg).to be_present
expect(not_participating_msg.data[:cannot_see]).to be_empty
not_participating_users = not_participating_msg.data[:without_membership].map { |u| u["id"] }
not_participating_users =
not_participating_msg.data[:without_membership].map { |u| u["id"] }
expect(not_participating_users).to contain_exactly(user_3.id)
end
@ -539,7 +541,8 @@ describe Chat::ChatNotifier do
expect(not_participating_msg).to be_present
expect(not_participating_msg.data[:cannot_see]).to be_empty
not_participating_users = not_participating_msg.data[:without_membership].map { |u| u["id"] }
not_participating_users =
not_participating_msg.data[:without_membership].map { |u| u["id"] }
expect(not_participating_users).to contain_exactly(user_3.id)
end
@ -598,11 +601,12 @@ describe Chat::ChatNotifier do
SiteSetting.max_users_notified_per_group_mention = (group.user_count - 1)
msg = build_cooked_msg("Hello @#{group.name}", user_1)
messages = MessageBus.track_publish("/chat/#{channel.id}") do
to_notify = described_class.new(msg, msg.created_at).notify_new
messages =
MessageBus.track_publish("/chat/#{channel.id}") do
to_notify = described_class.new(msg, msg.created_at).notify_new
expect(to_notify[group.name]).to be_nil
end
expect(to_notify[group.name]).to be_nil
end
too_many_members_msg = messages.first
expect(too_many_members_msg).to be_present
@ -614,11 +618,12 @@ describe Chat::ChatNotifier do
group.update!(mentionable_level: Group::ALIAS_LEVELS[:only_admins])
msg = build_cooked_msg("Hello @#{group.name}", user_1)
messages = MessageBus.track_publish("/chat/#{channel.id}") do
to_notify = described_class.new(msg, msg.created_at).notify_new
messages =
MessageBus.track_publish("/chat/#{channel.id}") do
to_notify = described_class.new(msg, msg.created_at).notify_new
expect(to_notify[group.name]).to be_nil
end
expect(to_notify[group.name]).to be_nil
end
mentions_disabled_msg = messages.first
expect(mentions_disabled_msg).to be_present

View File

@ -46,7 +46,7 @@ describe Chat::ChatReviewQueue do
it "returns an error" do
expect(second_flag_result).to include success: false,
errors: [I18n.t("chat.reviewables.message_already_handled")]
errors: [I18n.t("chat.reviewables.message_already_handled")]
end
it "returns an error when trying to use notify_moderators and the previous flag is still pending" do
@ -59,7 +59,7 @@ describe Chat::ChatReviewQueue do
)
expect(notify_moderators_result).to include success: false,
errors: [I18n.t("chat.reviewables.message_already_handled")]
errors: [I18n.t("chat.reviewables.message_already_handled")]
end
end
@ -87,7 +87,7 @@ describe Chat::ChatReviewQueue do
queue.flag_message(message, admin_guardian, ReviewableScore.types[:spam])
expect(second_flag_result).to include success: false,
errors: [I18n.t("chat.reviewables.message_already_handled")]
errors: [I18n.t("chat.reviewables.message_already_handled")]
end
end
@ -105,7 +105,7 @@ describe Chat::ChatReviewQueue do
it "raises an error when we are inside the cooldown window" do
expect(second_flag_result).to include success: false,
errors: [I18n.t("chat.reviewables.message_already_handled")]
errors: [I18n.t("chat.reviewables.message_already_handled")]
end
it "allows the user to re-flag after the cooldown period" do

View File

@ -92,17 +92,32 @@ RSpec.describe Chat::GuardianExtensions do
fab!(:group_user) { Fabricate(:group_user, group: group, user: user) }
it "returns true if the user can join the category" do
category = Fabricate(:private_category, group: group, permission_type: CategoryGroup.permission_types[:readonly])
category =
Fabricate(
:private_category,
group: group,
permission_type: CategoryGroup.permission_types[:readonly],
)
channel.update(chatable: category)
guardian = Guardian.new(user)
expect(guardian.can_join_chat_channel?(channel)).to eq(false)
category = Fabricate(:private_category, group: group, permission_type: CategoryGroup.permission_types[:create_post])
category =
Fabricate(
:private_category,
group: group,
permission_type: CategoryGroup.permission_types[:create_post],
)
channel.update(chatable: category)
guardian = Guardian.new(user)
expect(guardian.can_join_chat_channel?(channel)).to eq(true)
category = Fabricate(:private_category, group: group, permission_type: CategoryGroup.permission_types[:full])
category =
Fabricate(
:private_category,
group: group,
permission_type: CategoryGroup.permission_types[:full],
)
channel.update(chatable: category)
guardian = Guardian.new(user)
expect(guardian.can_join_chat_channel?(channel)).to eq(true)

View File

@ -25,7 +25,7 @@ describe UserNotifications do
Chat::DirectMessageChannelCreator.create!(acting_user: sender, target_users: [sender, user])
end
it 'calls guardian can_join_chat_channel?' do
it "calls guardian can_join_chat_channel?" do
Fabricate(:chat_message, user: sender, chat_channel: channel)
Guardian.any_instance.expects(:can_join_chat_channel?).once
email = described_class.chat_summary(user, {})
@ -34,11 +34,12 @@ describe UserNotifications do
describe "email subject" do
it "includes the sender username in the subject" do
expected_subject = I18n.t(
"user_notifications.chat_summary.subject.direct_message_from_1",
email_prefix: SiteSetting.title,
username: sender.username
)
expected_subject =
I18n.t(
"user_notifications.chat_summary.subject.direct_message_from_1",
email_prefix: SiteSetting.title,
username: sender.username,
)
Fabricate(:chat_message, user: sender, chat_channel: channel)
email = described_class.chat_summary(user, {})
@ -54,11 +55,12 @@ describe UserNotifications do
chat_channel: channel,
)
DirectMessageUser.create!(direct_message: channel.chatable, user: another_participant)
expected_subject = I18n.t(
"user_notifications.chat_summary.subject.direct_message_from_1",
email_prefix: SiteSetting.title,
username: sender.username
)
expected_subject =
I18n.t(
"user_notifications.chat_summary.subject.direct_message_from_1",
email_prefix: SiteSetting.title,
username: sender.username,
)
Fabricate(:chat_message, user: sender, chat_channel: channel)
email = described_class.chat_summary(user, {})
@ -80,12 +82,13 @@ describe UserNotifications do
Fabricate(:chat_message, user: sender, chat_channel: channel)
email = described_class.chat_summary(user, {})
expected_subject = I18n.t(
"user_notifications.chat_summary.subject.direct_message_from_2",
email_prefix: SiteSetting.title,
username1: another_dm_user.username,
username2: sender.username
)
expected_subject =
I18n.t(
"user_notifications.chat_summary.subject.direct_message_from_2",
email_prefix: SiteSetting.title,
username1: another_dm_user.username,
username2: sender.username,
)
expect(email.subject).to eq(expected_subject)
expect(email.subject).to include(sender.username)
@ -116,12 +119,13 @@ describe UserNotifications do
email = described_class.chat_summary(user, {})
expected_subject = I18n.t(
"user_notifications.chat_summary.subject.direct_message_from_more",
email_prefix: SiteSetting.title,
username: senders.first.username,
count: 2
)
expected_subject =
I18n.t(
"user_notifications.chat_summary.subject.direct_message_from_more",
email_prefix: SiteSetting.title,
username: senders.first.username,
count: 2,
)
expect(email.subject).to eq(expected_subject)
end
@ -162,11 +166,12 @@ describe UserNotifications do
before { Fabricate(:chat_mention, user: user, chat_message: chat_message) }
it "includes the sender username in the subject" do
expected_subject = I18n.t(
"user_notifications.chat_summary.subject.chat_channel_1",
email_prefix: SiteSetting.title,
channel: channel.title(user)
)
expected_subject =
I18n.t(
"user_notifications.chat_summary.subject.chat_channel_1",
email_prefix: SiteSetting.title,
channel: channel.title(user),
)
email = described_class.chat_summary(user, {})
@ -193,12 +198,13 @@ describe UserNotifications do
email = described_class.chat_summary(user, {})
expected_subject = I18n.t(
"user_notifications.chat_summary.subject.chat_channel_2",
email_prefix: SiteSetting.title,
channel1: channel.title(user),
channel2: another_chat_channel.title(user)
)
expected_subject =
I18n.t(
"user_notifications.chat_summary.subject.chat_channel_2",
email_prefix: SiteSetting.title,
channel1: channel.title(user),
channel2: another_chat_channel.title(user),
)
expect(email.subject).to eq(expected_subject)
expect(email.subject).to include(channel.title(user))
@ -224,12 +230,13 @@ describe UserNotifications do
Fabricate(:chat_mention, user: user, chat_message: another_chat_message)
end
expected_subject = I18n.t(
"user_notifications.chat_summary.subject.chat_channel_more",
email_prefix: SiteSetting.title,
channel: channel.title(user),
count: 2
)
expected_subject =
I18n.t(
"user_notifications.chat_summary.subject.chat_channel_more",
email_prefix: SiteSetting.title,
channel: channel.title(user),
count: 2,
)
email = described_class.chat_summary(user, {})
@ -250,12 +257,13 @@ describe UserNotifications do
end
it "always includes the DM second" do
expected_subject = I18n.t(
"user_notifications.chat_summary.subject.chat_channel_and_direct_message",
email_prefix: SiteSetting.title,
channel: channel.title(user),
username: sender.username
)
expected_subject =
I18n.t(
"user_notifications.chat_summary.subject.chat_channel_and_direct_message",
email_prefix: SiteSetting.title,
channel: channel.title(user),
username: sender.username,
)
email = described_class.chat_summary(user, {})

View File

@ -517,7 +517,8 @@ describe ChatMessage do
it "keeps the same hashtags the user has permission to after rebake" do
group.add(chat_message.user)
chat_message.update!(
message: "this is the message ##{category.slug} ##{secure_category.slug} ##{chat_message.chat_channel.slug}",
message:
"this is the message ##{category.slug} ##{secure_category.slug} ##{chat_message.chat_channel.slug}",
)
chat_message.cook
chat_message.save!

View File

@ -11,9 +11,7 @@ describe DeletedChatUser do
describe "#avatar_template" do
it "returns a default path" do
expect(subject.avatar_template).to eq(
"/plugins/chat/images/deleted-chat-user-avatar.png",
)
expect(subject.avatar_template).to eq("/plugins/chat/images/deleted-chat-user-avatar.png")
end
end
end

View File

@ -12,11 +12,7 @@ RSpec.describe Chat::Api::ChatChannelsCurrentUserNotificationsSettingsController
include_examples "channel access example",
:put,
"/notifications-settings/me",
{
notifications_settings: {
muted: true,
},
}
{ notifications_settings: { muted: true } }
context "when category channel has invalid params" do
fab!(:channel_1) { Fabricate(:category_channel) }

View File

@ -61,9 +61,9 @@ RSpec.describe Chat::Api::ChatChannelsStatusController do
context "when changing from open to closed" do
it "changes the status" do
expect { put "/chat/api/channels/#{channel_1.id}/status", params: status("closed") }.to change {
channel_1.reload.status
}.to("closed").from("open")
expect {
put "/chat/api/channels/#{channel_1.id}/status", params: status("closed")
}.to change { channel_1.reload.status }.to("closed").from("open")
expect(response.status).to eq(200)
channel = response.parsed_body["channel"]
@ -75,9 +75,9 @@ RSpec.describe Chat::Api::ChatChannelsStatusController do
before { channel_1.update!(status: "closed") }
it "changes the status" do
expect { put "/chat/api/channels/#{channel_1.id}/status", params: status("open") }.to change {
channel_1.reload.status
}.to("open").from("closed")
expect {
put "/chat/api/channels/#{channel_1.id}/status", params: status("open")
}.to change { channel_1.reload.status }.to("open").from("closed")
expect(response.status).to eq(200)
channel = response.parsed_body["channel"]

View File

@ -1114,7 +1114,11 @@ RSpec.describe Chat::ChatController do
it "returns a 403 if the user can't see the channel" do
category.update!(read_restricted: true)
group = Fabricate(:group)
CategoryGroup.create(group: group, category: category, permission_type: CategoryGroup.permission_types[:create_post])
CategoryGroup.create(
group: group,
category: category,
permission_type: CategoryGroup.permission_types[:create_post],
)
sign_in(user)
post "/chat/#{channel.id}/quote.json",
params: {

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.describe CategoriesController do
describe '#destroy' do
describe "#destroy" do
subject(:destroy_category) { delete "/categories/#{category.slug}.json" }
fab!(:admin) { Fabricate(:admin) }

View File

@ -23,11 +23,7 @@ RSpec.describe ChatMessageDestroyer do
it "deletes flags associated to deleted chat messages" do
guardian = Guardian.new(Discourse.system_user)
Chat::ChatReviewQueue.new.flag_message(
message_1,
guardian,
ReviewableScore.types[:off_topic],
)
Chat::ChatReviewQueue.new.flag_message(message_1, guardian, ReviewableScore.types[:off_topic])
reviewable = ReviewableChatMessage.last
expect(reviewable).to be_present

View File

@ -6,8 +6,8 @@ RSpec.shared_examples "a chatable model" do
it "returns a new chat channel model" do
expect(chat_channel).to have_attributes persisted?: false,
class: channel_class,
chatable: chatable
class: channel_class,
chatable: chatable
end
end

View File

@ -32,7 +32,9 @@ describe "Using #hashtag autocompletion to search for and lookup channels",
count: 3,
)
hashtag_results = page.all(".hashtag-autocomplete__link", count: 3)
expect(hashtag_results.map(&:text).map { |r| r.gsub("\n", " ") }).to eq(["Random", "Raspberry", "razed (x0)"])
expect(hashtag_results.map(&:text).map { |r| r.gsub("\n", " ") }).to eq(
["Random", "Raspberry", "razed (x0)"],
)
end
it "searches for channels as well with # in a topic composer and deprioritises them" do
@ -44,18 +46,26 @@ describe "Using #hashtag autocompletion to search for and lookup channels",
count: 3,
)
hashtag_results = page.all(".hashtag-autocomplete__link", count: 3)
expect(hashtag_results.map(&:text).map { |r| r.gsub("\n", " ") }).to eq(["Raspberry", "razed (x0)", "Random"])
expect(hashtag_results.map(&:text).map { |r| r.gsub("\n", " ") }).to eq(
["Raspberry", "razed (x0)", "Random"],
)
end
it "cooks the hashtags for channels, categories, and tags serverside when the chat message is saved to the database" do
chat_page.visit_channel(channel1)
expect(chat_channel_page).to have_no_loading_skeleton
chat_channel_page.type_in_composer("this is #random and this is #raspberry-beret and this is #razed which is cool")
chat_channel_page.type_in_composer(
"this is #random and this is #raspberry-beret and this is #razed which is cool",
)
chat_channel_page.click_send_message
message = nil
try_until_success do
message = ChatMessage.find_by(user: user, message: "this is #random and this is #raspberry-beret and this is #razed which is cool")
message =
ChatMessage.find_by(
user: user,
message: "this is #random and this is #raspberry-beret and this is #razed which is cool",
)
expect(message).not_to eq(nil)
end
expect(chat_channel_page).to have_message(id: message.id)

View File

@ -43,8 +43,12 @@ RSpec.describe "List channels | mobile", type: :system, js: true, mobile: true d
it "sorts them alphabetically" do
visit("/chat")
expect(page.find("#public-channels a:nth-child(1)")["data-chat-channel-id"]).to eq(channel_2.id.to_s)
expect(page.find("#public-channels a:nth-child(2)")["data-chat-channel-id"]).to eq(channel_1.id.to_s)
expect(page.find("#public-channels a:nth-child(1)")["data-chat-channel-id"]).to eq(
channel_2.id.to_s,
)
expect(page.find("#public-channels a:nth-child(2)")["data-chat-channel-id"]).to eq(
channel_1.id.to_s,
)
end
end

View File

@ -44,8 +44,12 @@ RSpec.describe "List channels | no sidebar", type: :system, js: true do
it "sorts them alphabetically" do
visit("/chat")
expect(page.find("#public-channels a:nth-child(1)")["data-chat-channel-id"]).to eq(channel_2.id.to_s)
expect(page.find("#public-channels a:nth-child(2)")["data-chat-channel-id"]).to eq(channel_1.id.to_s)
expect(page.find("#public-channels a:nth-child(1)")["data-chat-channel-id"]).to eq(
channel_2.id.to_s,
)
expect(page.find("#public-channels a:nth-child(2)")["data-chat-channel-id"]).to eq(
channel_1.id.to_s,
)
end
end

View File

@ -53,8 +53,12 @@ RSpec.describe "List channels | sidebar", type: :system, js: true do
it "sorts them alphabetically" do
visit("/")
expect(page.find("#sidebar-section-content-chat-channels li:nth-child(1)")).to have_css(".channel-#{channel_2.id}")
expect(page.find("#sidebar-section-content-chat-channels li:nth-child(2)")).to have_css(".channel-#{channel_1.id}")
expect(page.find("#sidebar-section-content-chat-channels li:nth-child(1)")).to have_css(
".channel-#{channel_2.id}",
)
expect(page.find("#sidebar-section-content-chat-channels li:nth-child(2)")).to have_css(
".channel-#{channel_1.id}",
)
end
end

View File

@ -43,14 +43,20 @@ RSpec.describe "Navigating to message", type: :system, js: true do
context "when clicking a link to a message from the current channel" do
before do
Fabricate(:chat_message, chat_channel: channel_1, message: "[#{link}](/chat/channel/#{channel_1.id}/-?messageId=#{first_message.id})")
Fabricate(
:chat_message,
chat_channel: channel_1,
message: "[#{link}](/chat/channel/#{channel_1.id}/-?messageId=#{first_message.id})",
)
end
it "highglights the correct message" do
chat_page.visit_channel(channel_1)
click_link(link)
expect(page).to have_css(".chat-message-container.highlighted[data-id='#{first_message.id}']")
expect(page).to have_css(
".chat-message-container.highlighted[data-id='#{first_message.id}']",
)
end
it "highlights the correct message after using the bottom arrow" do
@ -59,7 +65,9 @@ RSpec.describe "Navigating to message", type: :system, js: true do
click_link(I18n.t("js.chat.scroll_to_bottom"))
click_link(link)
expect(page).to have_css(".chat-message-container.highlighted[data-id='#{first_message.id}']")
expect(page).to have_css(
".chat-message-container.highlighted[data-id='#{first_message.id}']",
)
end
end
@ -67,7 +75,11 @@ RSpec.describe "Navigating to message", type: :system, js: true do
fab!(:channel_2) { Fabricate(:category_channel) }
before do
Fabricate(:chat_message, chat_channel: channel_2, message: "[#{link}](/chat/channel/#{channel_1.id}/-?messageId=#{first_message.id})")
Fabricate(
:chat_message,
chat_channel: channel_2,
message: "[#{link}](/chat/channel/#{channel_1.id}/-?messageId=#{first_message.id})",
)
channel_2.add(current_user)
end
@ -75,7 +87,9 @@ RSpec.describe "Navigating to message", type: :system, js: true do
chat_page.visit_channel(channel_2)
click_link(link)
expect(page).to have_css(".chat-message-container.highlighted[data-id='#{first_message.id}']")
expect(page).to have_css(
".chat-message-container.highlighted[data-id='#{first_message.id}']",
)
end
end
@ -83,7 +97,9 @@ RSpec.describe "Navigating to message", type: :system, js: true do
it "highglights the correct message" do
visit("/chat/channel/#{channel_1.id}/-?messageId=#{first_message.id}")
expect(page).to have_css(".chat-message-container.highlighted[data-id='#{first_message.id}']")
expect(page).to have_css(
".chat-message-container.highlighted[data-id='#{first_message.id}']",
)
end
end
end
@ -113,7 +129,11 @@ RSpec.describe "Navigating to message", type: :system, js: true do
context "when clicking a link to a message from the current channel" do
before do
Fabricate(:chat_message, chat_channel: channel_1, message: "[#{link}](/chat/channel/#{channel_1.id}/-?messageId=#{first_message.id})")
Fabricate(
:chat_message,
chat_channel: channel_1,
message: "[#{link}](/chat/channel/#{channel_1.id}/-?messageId=#{first_message.id})",
)
end
it "highglights the correct message" do
@ -122,7 +142,9 @@ RSpec.describe "Navigating to message", type: :system, js: true do
chat_drawer_page.open_channel(channel_1)
click_link(link)
expect(page).to have_css(".chat-message-container.highlighted[data-id='#{first_message.id}']")
expect(page).to have_css(
".chat-message-container.highlighted[data-id='#{first_message.id}']",
)
end
it "highlights the correct message after using the bottom arrow" do
@ -133,7 +155,9 @@ RSpec.describe "Navigating to message", type: :system, js: true do
click_link(I18n.t("js.chat.scroll_to_bottom"))
click_link(link)
expect(page).to have_css(".chat-message-container.highlighted[data-id='#{first_message.id}']")
expect(page).to have_css(
".chat-message-container.highlighted[data-id='#{first_message.id}']",
)
end
end
end

View File

@ -4,7 +4,9 @@ module PageObjects
module Pages
class Chat < PageObjects::Pages::Base
def prefers_full_page
page.execute_script("window.localStorage.setItem('discourse_chat_preferred_mode', '\"FULL_PAGE_CHAT\"');")
page.execute_script(
"window.localStorage.setItem('discourse_chat_preferred_mode', '\"FULL_PAGE_CHAT\"');",
)
end
def open_from_header

View File

@ -12,32 +12,34 @@ hide_plugin if self.respond_to?(:hide_plugin)
register_asset "stylesheets/details.scss"
after_initialize do
Email::Styles.register_plugin_style do |fragment|
# remove all elided content
fragment.css("details.elided").each(&:remove)
# replace all details with their summary in emails
fragment.css("details").each do |details|
summary = details.css("summary")
if summary && summary[0]
summary = summary[0]
if summary && summary.respond_to?(:name)
summary.name = "p"
details.replace(summary)
fragment
.css("details")
.each do |details|
summary = details.css("summary")
if summary && summary[0]
summary = summary[0]
if summary && summary.respond_to?(:name)
summary.name = "p"
details.replace(summary)
end
end
end
end
end
on(:reduce_cooked) do |fragment, post|
fragment.css("details").each do |el|
text = el.css("summary").text
link = fragment.document.create_element("a")
link["href"] = post.url if post
link.content = I18n.t("details.excerpt_details")
el.replace CGI.escapeHTML(text) + " " + link.to_html
end
fragment
.css("details")
.each do |el|
text = el.css("summary").text
link = fragment.document.create_element("a")
link["href"] = post.url if post
link.content = I18n.t("details.excerpt_details")
el.replace CGI.escapeHTML(text) + " " + link.to_html
end
end
end

View File

@ -1,10 +1,9 @@
# frozen_string_literal: true
require 'rails_helper'
require 'pretty_text'
require "rails_helper"
require "pretty_text"
RSpec.describe PrettyText do
let(:post) { Fabricate(:post) }
it "supports details tag" do
@ -17,17 +16,19 @@ RSpec.describe PrettyText do
HTML
expect(cooked_html).to match_html(cooked_html)
expect(PrettyText.cook("[details=foo]\nbar\n[/details]").gsub("\n", "")).to match_html(cooked_html)
expect(PrettyText.cook("[details=foo]\nbar\n[/details]").gsub("\n", "")).to match_html(
cooked_html,
)
end
it "deletes elided content" do
cooked_html = PrettyText.cook("Hello World\n\n<details class='elided'>42</details>")
mail_html = "<p>Hello World</p>\n<a href=\"http://test.localhost\">(click for more details)</a>"
mail_html = "<p>Hello World</p>\n<a href=\"http://test.localhost\">(click for more details)</a>"
expect(PrettyText.format_for_email(cooked_html)).to match_html(mail_html)
end
it 'can replace spoilers in emails' do
it "can replace spoilers in emails" do
md = PrettyText.cook(<<~MD)
hello
@ -41,7 +42,7 @@ RSpec.describe PrettyText do
expect(md).to eq(html)
end
it 'properly handles multiple spoiler blocks in a post' do
it "properly handles multiple spoiler blocks in a post" do
md = PrettyText.cook(<<~MD)
[details="First"]
body secret stuff very long
@ -58,13 +59,13 @@ RSpec.describe PrettyText do
MD
md = PrettyText.format_for_email(md, post)
expect(md).not_to include('secret stuff')
expect(md).not_to include("secret stuff")
expect(md.scan(/First/).size).to eq(1)
expect(md.scan(/Third/).size).to eq(1)
expect(md.scan(I18n.t('details.excerpt_details')).size).to eq(3)
expect(md.scan(I18n.t("details.excerpt_details")).size).to eq(3)
end
it 'escapes summary text' do
it "escapes summary text" do
md = PrettyText.cook(<<~MD)
<script>alert('hello')</script>
[details="<script>alert('hello')</script>"]
@ -73,7 +74,6 @@ RSpec.describe PrettyText do
MD
md = PrettyText.format_for_email(md, post)
expect(md).not_to include('<script>')
expect(md).not_to include("<script>")
end
end

View File

@ -7,40 +7,40 @@
hide_plugin if self.respond_to?(:hide_plugin)
register_asset 'stylesheets/common/discourse-local-dates.scss'
register_asset 'moment.js', :vendored_core_pretty_text
register_asset 'moment-timezone.js', :vendored_core_pretty_text
register_asset "stylesheets/common/discourse-local-dates.scss"
register_asset "moment.js", :vendored_core_pretty_text
register_asset "moment-timezone.js", :vendored_core_pretty_text
enabled_site_setting :discourse_local_dates_enabled
after_initialize do
module ::DiscourseLocalDates
PLUGIN_NAME ||= 'discourse-local-dates'.freeze
POST_CUSTOM_FIELD ||= 'local_dates'.freeze
PLUGIN_NAME ||= "discourse-local-dates".freeze
POST_CUSTOM_FIELD ||= "local_dates".freeze
end
%w[../lib/discourse_local_dates/engine.rb].each do |path|
load File.expand_path(path, __FILE__)
end
%w[../lib/discourse_local_dates/engine.rb].each { |path| load File.expand_path(path, __FILE__) }
register_post_custom_field_type(DiscourseLocalDates::POST_CUSTOM_FIELD, :json)
on(:before_post_process_cooked) do |doc, post|
dates = []
doc.css('span.discourse-local-date').map do |cooked_date|
next if cooked_date.ancestors("aside").length > 0
date = {}
cooked_date.attributes.values.each do |attribute|
data_name = attribute.name&.gsub('data-', '')
if data_name && %w[date time timezone recurring].include?(data_name)
unless attribute.value == 'undefined'
date[data_name] = CGI.escapeHTML(attribute.value || '')
doc
.css("span.discourse-local-date")
.map do |cooked_date|
next if cooked_date.ancestors("aside").length > 0
date = {}
cooked_date.attributes.values.each do |attribute|
data_name = attribute.name&.gsub("data-", "")
if data_name && %w[date time timezone recurring].include?(data_name)
unless attribute.value == "undefined"
date[data_name] = CGI.escapeHTML(attribute.value || "")
end
end
end
dates << date
end
dates << date
end
if dates.present?
post.custom_fields[DiscourseLocalDates::POST_CUSTOM_FIELD] = dates
@ -51,22 +51,22 @@ after_initialize do
end
end
add_to_class(:post, :local_dates) do
custom_fields[DiscourseLocalDates::POST_CUSTOM_FIELD] || []
end
add_to_class(:post, :local_dates) { custom_fields[DiscourseLocalDates::POST_CUSTOM_FIELD] || [] }
on(:reduce_excerpt) do |fragment, post|
fragment.css('.discourse-local-date').each do |container|
container.content = "#{container.content} (UTC)"
end
fragment
.css(".discourse-local-date")
.each { |container| container.content = "#{container.content} (UTC)" }
end
on(:reduce_cooked) do |fragment|
fragment.css('.discourse-local-date').each do |container|
if container.attributes['data-email-preview']
preview = container.attributes['data-email-preview'].value
container.content = preview
fragment
.css(".discourse-local-date")
.each do |container|
if container.attributes["data-email-preview"]
preview = container.attributes["data-email-preview"].value
container.content = preview
end
end
end
end
end

View File

@ -1,9 +1,7 @@
# frozen_string_literal: true
RSpec.describe "Local Dates" do
before do
freeze_time DateTime.parse('2018-11-10 12:00')
end
before { freeze_time DateTime.parse("2018-11-10 12:00") }
it "should work without timezone" do
post = Fabricate(:post, raw: <<~MD)
@ -15,14 +13,12 @@ RSpec.describe "Local Dates" do
expect(cooked).to include('class="discourse-local-date"')
expect(cooked).to include('data-date="2018-05-08"')
expect(cooked).to include('data-format="L LTS"')
expect(cooked).not_to include('data-timezone=')
expect(cooked).not_to include("data-timezone=")
expect(cooked).to include(
'data-timezones="Europe/Paris|America/Los_Angeles"'
)
expect(cooked).to include('data-timezones="Europe/Paris|America/Los_Angeles"')
expect(cooked).to include('data-email-preview="2018-05-08T22:00:00Z UTC"')
expect(cooked).to include('05/08/2018 10:00:00 PM')
expect(cooked).to include("05/08/2018 10:00:00 PM")
end
it "should work with timezone" do
@ -33,10 +29,10 @@ RSpec.describe "Local Dates" do
cooked = post.cooked
expect(cooked).to include('data-timezone="Asia/Calcutta"')
expect(cooked).to include('05/08/2018 4:30:00 PM')
expect(cooked).to include("05/08/2018 4:30:00 PM")
end
it 'requires the right attributes to convert to a local date' do
it "requires the right attributes to convert to a local date" do
post = Fabricate(:post, raw: <<~MD)
[date]
MD
@ -44,10 +40,10 @@ RSpec.describe "Local Dates" do
cooked = post.cooked
expect(post.cooked).to include("<p>[date]</p>")
expect(cooked).to_not include('data-date=')
expect(cooked).to_not include("data-date=")
end
it 'requires the right attributes to convert to a local date' do
it "requires the right attributes to convert to a local date" do
post = Fabricate(:post, raw: <<~MD)
[date]
MD
@ -55,48 +51,48 @@ RSpec.describe "Local Dates" do
cooked = post.cooked
expect(post.cooked).to include("<p>[date]</p>")
expect(cooked).to_not include('data-date=')
expect(cooked).to_not include("data-date=")
end
it 'it works with only a date and time' do
it "it works with only a date and time" do
raw = "[date=2018-11-01 time=12:00]"
cooked = Fabricate(:post, raw: raw).cooked
expect(cooked).to include('data-date="2018-11-01"')
expect(cooked).to include('data-time="12:00"')
end
it 'doesnt include format by default' do
it "doesnt include format by default" do
raw = "[date=2018-11-01 time=12:00]"
cooked = Fabricate(:post, raw: raw).cooked
expect(cooked).not_to include('data-format=')
expect(cooked).not_to include("data-format=")
end
it 'doesnt include timezone by default' do
it "doesnt include timezone by default" do
raw = "[date=2018-11-01 time=12:00]"
cooked = Fabricate(:post, raw: raw).cooked
expect(cooked).not_to include("data-timezone=")
end
it 'supports countdowns' do
it "supports countdowns" do
raw = "[date=2018-11-01 time=12:00 countdown=true]"
cooked = Fabricate(:post, raw: raw).cooked
expect(cooked).to include("data-countdown=")
end
describe 'ranges' do
it 'generates ranges without time' do
describe "ranges" do
it "generates ranges without time" do
raw = "[date-range from=2022-01-06 to=2022-01-08]"
cooked = Fabricate(:post, raw: raw).cooked
expect(cooked).to include('data-date="2022-01-06')
expect(cooked).to include('data-range="from"')
expect(cooked).to include('data-range="to"')
expect(cooked).not_to include('data-time=')
expect(cooked).not_to include("data-time=")
end
it 'supports time and timezone' do
it "supports time and timezone" do
raw = "[date-range from=2022-01-06T13:00 to=2022-01-08 timezone=Australia/Sydney]"
cooked = Fabricate(:post, raw: raw).cooked
@ -107,7 +103,7 @@ RSpec.describe "Local Dates" do
expect(cooked).to include('data-timezone="Australia/Sydney"')
end
it 'generates single date when range without end date' do
it "generates single date when range without end date" do
raw = "[date-range from=2022-01-06T13:00]"
cooked = Fabricate(:post, raw: raw).cooked

View File

@ -15,94 +15,118 @@ def generate_html(text, opts = {})
end
RSpec.describe PrettyText do
before do
freeze_time
end
before { freeze_time }
describe 'emails simplified rendering' do
it 'works with default markup' do
describe "emails simplified rendering" do
it "works with default markup" do
cooked = PrettyText.cook("[date=2018-05-08]")
cooked_mail = generate_html("2018-05-08T00:00:00Z UTC",
date: "2018-05-08",
email_preview: "2018-05-08T00:00:00Z UTC"
)
expect(PrettyText.format_for_email(cooked)).to match_html(cooked_mail)
end
it 'works with time' do
cooked = PrettyText.cook("[date=2018-05-08 time=20:00:00]")
cooked_mail = generate_html("2018-05-08T20:00:00Z UTC",
date: "2018-05-08",
email_preview: "2018-05-08T20:00:00Z UTC",
time: "20:00:00"
)
expect(PrettyText.format_for_email(cooked)).to match_html(cooked_mail)
end
it 'works with multiple timezones' do
cooked = PrettyText.cook('[date=2018-05-08 timezone="Europe/Paris" timezones="America/Los_Angeles|Pacific/Auckland"]')
cooked_mail = generate_html("2018-05-07T22:00:00Z UTC",
date: "2018-05-08",
email_preview: "2018-05-07T22:00:00Z UTC",
timezone: "Europe/Paris",
timezones: "America/Los_Angeles|Pacific/Auckland"
)
expect(PrettyText.format_for_email(cooked)).to match_html(cooked_mail)
end
describe 'discourse_local_dates_email_format' do
before do
SiteSetting.discourse_local_dates_email_format = "DD/MM"
end
it 'uses the site setting' do
cooked = PrettyText.cook("[date=2018-05-08]")
cooked_mail = generate_html("08/05 UTC",
cooked_mail =
generate_html(
"2018-05-08T00:00:00Z UTC",
date: "2018-05-08",
email_preview: "08/05 UTC"
email_preview: "2018-05-08T00:00:00Z UTC",
)
expect(PrettyText.format_for_email(cooked)).to match_html(cooked_mail)
end
it "works with time" do
cooked = PrettyText.cook("[date=2018-05-08 time=20:00:00]")
cooked_mail =
generate_html(
"2018-05-08T20:00:00Z UTC",
date: "2018-05-08",
email_preview: "2018-05-08T20:00:00Z UTC",
time: "20:00:00",
)
expect(PrettyText.format_for_email(cooked)).to match_html(cooked_mail)
end
it "works with multiple timezones" do
cooked =
PrettyText.cook(
'[date=2018-05-08 timezone="Europe/Paris" timezones="America/Los_Angeles|Pacific/Auckland"]',
)
cooked_mail =
generate_html(
"2018-05-07T22:00:00Z UTC",
date: "2018-05-08",
email_preview: "2018-05-07T22:00:00Z UTC",
timezone: "Europe/Paris",
timezones: "America/Los_Angeles|Pacific/Auckland",
)
expect(PrettyText.format_for_email(cooked)).to match_html(cooked_mail)
end
describe "discourse_local_dates_email_format" do
before { SiteSetting.discourse_local_dates_email_format = "DD/MM" }
it "uses the site setting" do
cooked = PrettyText.cook("[date=2018-05-08]")
cooked_mail = generate_html("08/05 UTC", date: "2018-05-08", email_preview: "08/05 UTC")
expect(PrettyText.format_for_email(cooked)).to match_html(cooked_mail)
end
end
end
describe 'excerpt simplified rendering' do
let(:post) { Fabricate(:post, raw: '[date=2019-10-16 time=14:00:00 format="LLLL" timezone="America/New_York"]') }
describe "excerpt simplified rendering" do
let(:post) do
Fabricate(
:post,
raw: '[date=2019-10-16 time=14:00:00 format="LLLL" timezone="America/New_York"]',
)
end
it 'adds UTC' do
it "adds UTC" do
excerpt = PrettyText.excerpt(post.cooked, 200)
expect(excerpt).to eq("Wednesday, October 16, 2019 6:00 PM (UTC)")
end
end
describe 'special quotes' do
it 'converts special quotes to regular quotes' do
describe "special quotes" do
it "converts special quotes to regular quotes" do
# german
post = Fabricate(:post, raw: '[date=2019-10-16 time=14:00:00 format="LLLL" timezone=„America/New_York“]')
post =
Fabricate(
:post,
raw: '[date=2019-10-16 time=14:00:00 format="LLLL" timezone=„America/New_York“]',
)
excerpt = PrettyText.excerpt(post.cooked, 200)
expect(excerpt).to eq('Wednesday, October 16, 2019 6:00 PM (UTC)')
expect(excerpt).to eq("Wednesday, October 16, 2019 6:00 PM (UTC)")
# french
post = Fabricate(:post, raw: '[date=2019-10-16 time=14:00:00 format="LLLL" timezone=«America/New_York»]')
post =
Fabricate(
:post,
raw: '[date=2019-10-16 time=14:00:00 format="LLLL" timezone=«America/New_York»]',
)
excerpt = PrettyText.excerpt(post.cooked, 200)
expect(excerpt).to eq('Wednesday, October 16, 2019 6:00 PM (UTC)')
expect(excerpt).to eq("Wednesday, October 16, 2019 6:00 PM (UTC)")
post = Fabricate(:post, raw: '[date=2019-10-16 time=14:00:00 format="LLLL" timezone=“America/New_York”]')
post =
Fabricate(
:post,
raw: '[date=2019-10-16 time=14:00:00 format="LLLL" timezone=“America/New_York”]',
)
excerpt = PrettyText.excerpt(post.cooked, 200)
expect(excerpt).to eq('Wednesday, October 16, 2019 6:00 PM (UTC)')
expect(excerpt).to eq("Wednesday, October 16, 2019 6:00 PM (UTC)")
end
end
describe 'french quotes' do
let(:post) { Fabricate(:post, raw: '[date=2019-10-16 time=14:00:00 format="LLLL" timezone=«America/New_York»]') }
describe "french quotes" do
let(:post) do
Fabricate(
:post,
raw: '[date=2019-10-16 time=14:00:00 format="LLLL" timezone=«America/New_York»]',
)
end
it 'converts french quotes to regular quotes' do
it "converts french quotes to regular quotes" do
excerpt = PrettyText.excerpt(post.cooked, 200)
expect(excerpt).to eq('Wednesday, October 16, 2019 6:00 PM (UTC)')
expect(excerpt).to eq("Wednesday, October 16, 2019 6:00 PM (UTC)")
end
end
end

View File

@ -1,12 +1,9 @@
# frozen_string_literal: true
RSpec.describe Post do
before { Jobs.run_immediately! }
before do
Jobs.run_immediately!
end
describe '#local_dates' do
describe "#local_dates" do
it "should have correct custom fields" do
post = Fabricate(:post, raw: <<~SQL)
[date=2018-09-17 time=01:39:00 format="LLL" timezone="Europe/Paris" timezones="Europe/Paris|America/Los_Angeles"]
@ -37,7 +34,7 @@ RSpec.describe Post do
end
it "should not contain dates from examples" do
Oneboxer.stubs(:cached_onebox).with('https://example.com').returns(<<-HTML)
Oneboxer.stubs(:cached_onebox).with("https://example.com").returns(<<-HTML)
<aside class="onebox githubcommit">
<span class="discourse-local-date" data-format="ll" data-date="2020-01-20" data-time="15:06:58" data-timezone="UTC">03:06PM - 20 Jan 20 UTC</span>
</aside>
@ -48,5 +45,4 @@ RSpec.describe Post do
expect(post.local_dates.count).to eq(0)
end
end
end

View File

@ -4,18 +4,11 @@ describe "Local dates", type: :system, js: true do
fab!(:topic) { Fabricate(:topic) }
fab!(:user) { Fabricate(:user) }
before do
create_post(
user: user,
topic: topic,
title: "Date range test post",
raw: <<~RAW
before { create_post(user: user, topic: topic, title: "Date range test post", raw: <<~RAW) }
First option: [date=2022-12-15 time=14:19:00 timezone="Asia/Singapore"]
Second option: [date=2022-12-15 time=01:20:00 timezone="Asia/Singapore"], or [date=2022-12-15 time=02:40:00 timezone="Asia/Singapore"]
Third option: [date-range from=2022-12-15T11:25:00 to=2022-12-16T00:26:00 timezone="Asia/Singapore"] or [date-range from=2022-12-22T11:57:00 to=2022-12-23T11:58:00 timezone="Asia/Singapore"]
RAW
)
end
let(:topic_page) { PageObjects::Pages::Topic.new }
@ -53,12 +46,18 @@ describe "Local dates", type: :system, js: true do
post_dates[3].click
tippy_date = topic_page.find(".tippy-content .current .date-time")
expect(tippy_date).to have_text("Thursday, December 15, 2022\n11:25 AM → 12:26 AM", exact: true)
expect(tippy_date).to have_text(
"Thursday, December 15, 2022\n11:25 AM → 12:26 AM",
exact: true,
)
post_dates[5].click
tippy_date = topic_page.find(".tippy-content .current .date-time")
expect(tippy_date).to have_text("Thursday, December 22, 2022 11:57 AM → Friday, December 23, 2022 11:58 AM", exact: true)
expect(tippy_date).to have_text(
"Thursday, December 22, 2022 11:57 AM → Friday, December 23, 2022 11:58 AM",
exact: true,
)
end
end
end

View File

@ -4,33 +4,29 @@ module Jobs
module DiscourseNarrativeBot
class GrantBadges < ::Jobs::Onceoff
def execute_onceoff(args)
new_user_track_badge = Badge.find_by(
name: ::DiscourseNarrativeBot::NewUserNarrative.badge_name
)
new_user_track_badge =
Badge.find_by(name: ::DiscourseNarrativeBot::NewUserNarrative.badge_name)
advanced_user_track_badge = Badge.find_by(
name: ::DiscourseNarrativeBot::AdvancedUserNarrative.badge_name
)
advanced_user_track_badge =
Badge.find_by(name: ::DiscourseNarrativeBot::AdvancedUserNarrative.badge_name)
PluginStoreRow.where(
plugin_name: ::DiscourseNarrativeBot::PLUGIN_NAME,
type_name: 'JSON'
).find_each do |row|
PluginStoreRow
.where(plugin_name: ::DiscourseNarrativeBot::PLUGIN_NAME, type_name: "JSON")
.find_each do |row|
value = JSON.parse(row.value)
completed = value["completed"]
user = User.find_by(id: row.key)
value = JSON.parse(row.value)
completed = value["completed"]
user = User.find_by(id: row.key)
if user && completed
if completed.include?(::DiscourseNarrativeBot::NewUserNarrative.to_s)
BadgeGranter.grant(new_user_track_badge, user)
end
if user && completed
if completed.include?(::DiscourseNarrativeBot::NewUserNarrative.to_s)
BadgeGranter.grant(new_user_track_badge, user)
end
if completed.include?(::DiscourseNarrativeBot::AdvancedUserNarrative.to_s)
BadgeGranter.grant(advanced_user_track_badge, user)
if completed.include?(::DiscourseNarrativeBot::AdvancedUserNarrative.to_s)
BadgeGranter.grant(advanced_user_track_badge, user)
end
end
end
end
end
end
end

View File

@ -4,34 +4,40 @@ module Jobs
module DiscourseNarrativeBot
class RemapOldBotImages < ::Jobs::Onceoff
def execute_onceoff(args)
paths = [
"/images/font-awesome-link.png",
"/images/unicorn.png",
"/images/font-awesome-ellipsis.png",
"/images/font-awesome-bookmark.png",
"/images/font-awesome-smile.png",
"/images/font-awesome-flag.png",
"/images/font-awesome-search.png",
"/images/capybara-eating.gif",
"/images/font-awesome-pencil.png",
"/images/font-awesome-trash.png",
"/images/font-awesome-rotate-left.png",
"/images/font-awesome-gear.png",
paths = %w[
/images/font-awesome-link.png
/images/unicorn.png
/images/font-awesome-ellipsis.png
/images/font-awesome-bookmark.png
/images/font-awesome-smile.png
/images/font-awesome-flag.png
/images/font-awesome-search.png
/images/capybara-eating.gif
/images/font-awesome-pencil.png
/images/font-awesome-trash.png
/images/font-awesome-rotate-left.png
/images/font-awesome-gear.png
]
Post.raw_match("/images/").where(user_id: -2).find_each do |post|
if (matches = post.raw.scan(/(?<!\/plugins\/discourse-narrative-bot)(#{paths.join("|")})/)).present?
new_raw = post.raw
Post
.raw_match("/images/")
.where(user_id: -2)
.find_each do |post|
if (
matches =
post.raw.scan(%r{(?<!/plugins/discourse-narrative-bot)(#{paths.join("|")})})
).present?
new_raw = post.raw
matches.each do |match|
path = match.first
new_raw = new_raw.gsub(path, "/plugins/discourse-narrative-bot#{path}")
matches.each do |match|
path = match.first
new_raw = new_raw.gsub(path, "/plugins/discourse-narrative-bot#{path}")
end
post.update_columns(raw: new_raw)
post.rebake!
end
post.update_columns(raw: new_raw)
post.rebake!
end
end
end
end
end

View File

@ -2,16 +2,17 @@
module Jobs
class BotInput < ::Jobs::Base
sidekiq_options queue: 'critical', retry: false
sidekiq_options queue: "critical", retry: false
def execute(args)
return unless user = User.find_by(id: args[:user_id])
I18n.with_locale(user.effective_locale) do
::DiscourseNarrativeBot::TrackSelector.new(args[:input].to_sym, user,
::DiscourseNarrativeBot::TrackSelector.new(
args[:input].to_sym,
user,
post_id: args[:post_id],
topic_id: args[:topic_id]
topic_id: args[:topic_id],
).select
end
end

View File

@ -2,13 +2,11 @@
module Jobs
class NarrativeInit < ::Jobs::Base
sidekiq_options queue: 'critical'
sidekiq_options queue: "critical"
def execute(args)
if user = User.find_by(id: args[:user_id])
I18n.with_locale(user.effective_locale) do
args[:klass].constantize.new.input(:init, user)
end
I18n.with_locale(user.effective_locale) { args[:klass].constantize.new.input(:init, user) }
end
end
end

View File

@ -4,23 +4,24 @@ module Jobs
class SendDefaultWelcomeMessage < ::Jobs::Base
def execute(args)
if user = User.find_by(id: args[:user_id])
type = user.invited_by ? 'welcome_invite' : 'welcome_user'
type = user.invited_by ? "welcome_invite" : "welcome_user"
params = SystemMessage.new(user).defaults
title = I18n.t("system_messages.#{type}.subject_template", params)
raw = I18n.t("system_messages.#{type}.text_body_template", params)
discobot_user = ::DiscourseNarrativeBot::Base.new.discobot_user
post = PostCreator.create!(
discobot_user,
title: title,
raw: raw,
archetype: Archetype.private_message,
target_usernames: user.username,
skip_validations: true
)
post =
PostCreator.create!(
discobot_user,
title: title,
raw: raw,
archetype: Archetype.private_message,
target_usernames: user.username,
skip_validations: true,
)
post.topic.update_status('closed', true, discobot_user)
post.topic.update_status("closed", true, discobot_user)
end
end
end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
discobot_username = 'discobot'
discobot_username = "discobot"
def seed_primary_email
UserEmail.seed do |ue|
@ -42,15 +42,13 @@ bot.create_user_option! if !bot.user_option
bot.user_option.update!(
email_messages_level: UserOption.email_level_types[:never],
email_level: UserOption.email_level_types[:never]
email_level: UserOption.email_level_types[:never],
)
bot.create_user_profile! if !bot.user_profile
if !bot.user_profile.bio_raw
bot.user_profile.update!(
bio_raw: I18n.t('discourse_narrative_bot.bio')
)
bot.user_profile.update!(bio_raw: I18n.t("discourse_narrative_bot.bio"))
end
Group.user_trust_level_change!(DiscourseNarrativeBot::BOT_USER_ID, TrustLevel[4])

View File

@ -1,41 +1,33 @@
# frozen_string_literal: true
Badge
.where(name: 'Complete New User Track')
.update_all(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME)
Badge.where(name: "Complete New User Track").update_all(
name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME,
)
Badge
.where(name: 'Complete Discobot Advanced User Track')
.update_all(name: DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME)
Badge.where(name: "Complete Discobot Advanced User Track").update_all(
name: DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME,
)
new_user_narrative_badge = Badge.find_by(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME)
unless new_user_narrative_badge
new_user_narrative_badge = Badge.create!(
name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME,
badge_type_id: 3
)
new_user_narrative_badge =
Badge.create!(name: DiscourseNarrativeBot::NewUserNarrative::BADGE_NAME, badge_type_id: 3)
end
advanced_user_narrative_badge = Badge.find_by(name: DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME)
advanced_user_narrative_badge =
Badge.find_by(name: DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME)
unless advanced_user_narrative_badge
advanced_user_narrative_badge = Badge.create!(
name: DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME,
badge_type_id: 2
)
advanced_user_narrative_badge =
Badge.create!(name: DiscourseNarrativeBot::AdvancedUserNarrative::BADGE_NAME, badge_type_id: 2)
end
badge_grouping = BadgeGrouping.find(1)
[
[new_user_narrative_badge, I18n.t('badges.certified.description')],
[advanced_user_narrative_badge, I18n.t('badges.licensed.description')]
[new_user_narrative_badge, I18n.t("badges.certified.description")],
[advanced_user_narrative_badge, I18n.t("badges.licensed.description")],
].each do |badge, description|
badge.update!(
badge_grouping: badge_grouping,
description: description,
system: true
)
badge.update!(badge_grouping: badge_grouping, description: description, system: true)
end

View File

@ -23,18 +23,19 @@ module DiscourseNarrativeBot
topic_id: post.topic_id,
reply_to_post_number: post.post_number,
post_alert_options: defaut_post_alert_opts,
skip_validations: true
skip_validations: true,
}
new_post = PostCreator.create!(self.discobot_user, default_opts.merge(opts))
reset_rate_limits(post) if new_post
new_post
else
PostCreator.create!(self.discobot_user, {
post_alert_options: defaut_post_alert_opts,
raw: raw,
skip_validations: true
}.merge(opts))
PostCreator.create!(
self.discobot_user,
{ post_alert_options: defaut_post_alert_opts, raw: raw, skip_validations: true }.merge(
opts,
),
)
end
end
@ -53,7 +54,8 @@ module DiscourseNarrativeBot
data = DiscourseNarrativeBot::Store.get(user.id.to_s)
return unless data
key = "#{DiscourseNarrativeBot::PLUGIN_NAME}:reset-rate-limit:#{post.topic_id}:#{data['state']}"
key =
"#{DiscourseNarrativeBot::PLUGIN_NAME}:reset-rate-limit:#{post.topic_id}:#{data["state"]}"
if !(count = Discourse.redis.get(key))
count = 0
@ -76,12 +78,14 @@ module DiscourseNarrativeBot
valid = false
doc.css(".mention").each do |mention|
if User.normalize_username(mention.text) == "@#{self.discobot_username}"
valid = true
break
doc
.css(".mention")
.each do |mention|
if User.normalize_username(mention.text) == "@#{self.discobot_username}"
valid = true
break
end
end
end
valid
end
@ -94,8 +98,7 @@ module DiscourseNarrativeBot
topic = post.topic
return false if !topic
topic.pm_with_non_human_user? &&
topic.topic_allowed_users.where(user_id: -2).exists?
topic.pm_with_non_human_user? && topic.topic_allowed_users.where(user_id: -2).exists?
end
def cancel_timeout_job(user)
@ -107,9 +110,11 @@ module DiscourseNarrativeBot
cancel_timeout_job(user)
Jobs.enqueue_in(TIMEOUT_DURATION, :narrative_timeout,
Jobs.enqueue_in(
TIMEOUT_DURATION,
:narrative_timeout,
user_id: user.id,
klass: self.class.to_s
klass: self.class.to_s,
)
end
end

View File

@ -3,99 +3,101 @@
module DiscourseNarrativeBot
class AdvancedUserNarrative < Base
I18N_KEY = "discourse_narrative_bot.advanced_user_narrative".freeze
BADGE_NAME = 'Licensed'.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
}
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
action: :reply_to_edit,
},
reply: {
next_state: :tutorial_edit,
action: :missing_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
action: :reply_to_delete,
},
reply: {
next_state: :tutorial_delete,
action: :missing_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
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
if parent_category = category.parent_category
slug = "#{parent_category.slug}#{CategoryHashtag::SEPARATOR}#{slug}"
end
I18n.t("#{I18N_KEY}.category_hashtag.instructions",
i18n_post_args(category: "##{slug}")
)
end,
I18n.t(
"#{I18N_KEY}.category_hashtag.instructions",
i18n_post_args(category: "##{slug}"),
)
end,
recover: {
action: :reply_to_recover
action: :reply_to_recover,
},
reply: {
next_state: :tutorial_recover,
action: :missing_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", i18n_post_args) },
next_instructions:
Proc.new do
I18n.t("#{I18N_KEY}.change_topic_notification_level.instructions", i18n_post_args)
end,
reply: {
action: :reply_to_category_hashtag
}
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
action: :reply_to_topic_notification_level_changed,
},
reply: {
next_state: :tutorial_change_topic_notification_level,
action: :missing_topic_notification_level_change
}
action: :missing_topic_notification_level_change,
},
},
tutorial_poll: {
prerequisite: Proc.new { SiteSetting.poll_enabled && @user.has_trust_level?(SiteSetting.poll_minimum_trust_level_to_create) },
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
}
action: :reply_to_poll,
},
},
tutorial_details: {
next_state: :end,
reply: {
action: :reply_to_details
}
}
action: :reply_to_details,
},
},
}
def self.badge_name
@ -103,7 +105,7 @@ module DiscourseNarrativeBot
end
def self.reset_trigger
I18n.t('discourse_narrative_bot.advanced_user_narrative.reset_trigger')
I18n.t("discourse_narrative_bot.advanced_user_narrative.reset_trigger")
end
def reset_bot(user, post)
@ -123,13 +125,18 @@ module DiscourseNarrativeBot
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
)
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
@ -138,13 +145,18 @@ module DiscourseNarrativeBot
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
)
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)
@ -172,18 +184,15 @@ module DiscourseNarrativeBot
opts = {
title: I18n.t("#{I18N_KEY}.title"),
target_usernames: @user.username,
archetype: Archetype.private_message
archetype: Archetype.private_message,
}
if @post &&
@post.topic.private_message? &&
@post.topic.topic_allowed_users.pluck(:user_id).include?(@user.id)
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)
opts = opts.merge(topic_id: @data[:topic_id]).except(:title, :target_usernames, :archetype)
end
post = reply_to(@post, raw, opts)
@ -213,9 +222,10 @@ module DiscourseNarrativeBot
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)
))
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)
@ -233,16 +243,15 @@ module DiscourseNarrativeBot
#{instance_eval(&@next_instructions)}
MD
PostCreator.create!(self.discobot_user,
raw: raw,
topic_id: @topic_id
)
PostCreator.create!(self.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", i18n_post_args)) unless @data[:attempted]
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.delete.not_found", i18n_post_args))
end
enqueue_timeout_job(@user)
false
end
@ -258,18 +267,19 @@ module DiscourseNarrativeBot
#{instance_eval(&@next_instructions)}
MD
PostCreator.create!(self.discobot_user,
raw: raw,
topic_id: @post.topic_id
)
PostCreator.create!(self.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
unless valid_topic?(@post.topic_id) &&
post_id = get_state_data(:post_id) && @post.id != post_id
return
end
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.recover.not_found", i18n_post_args)) unless @data[:attempted]
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.recover.not_found", i18n_post_args))
end
enqueue_timeout_job(@user)
false
end
@ -278,7 +288,7 @@ module DiscourseNarrativeBot
topic_id = @post.topic_id
return unless valid_topic?(topic_id)
if Nokogiri::HTML5.fragment(@post.cooked).css('.hashtag').size > 0
if Nokogiri::HTML5.fragment(@post.cooked).css(".hashtag").size > 0
raw = <<~MD
#{I18n.t("#{I18N_KEY}.category_hashtag.reply", i18n_post_args)}
@ -289,7 +299,9 @@ module DiscourseNarrativeBot
reply_to(@post, raw)
else
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.category_hashtag.not_found", i18n_post_args)) unless @data[:attempted]
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.category_hashtag.not_found", i18n_post_args))
end
enqueue_timeout_job(@user)
false
end
@ -299,7 +311,12 @@ module DiscourseNarrativeBot
return unless valid_topic?(@post.topic_id)
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.change_topic_notification_level.not_found", i18n_post_args)) unless @data[:attempted]
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
@ -316,10 +333,7 @@ module DiscourseNarrativeBot
fake_delay
post = PostCreator.create!(self.discobot_user,
raw: raw,
topic_id: @topic_id
)
post = PostCreator.create!(self.discobot_user, raw: raw, topic_id: @topic_id)
enqueue_timeout_job(@user)
post
@ -340,7 +354,9 @@ module DiscourseNarrativeBot
reply_to(@post, raw)
else
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.poll.not_found", i18n_post_args)) unless @data[:attempted]
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.poll.not_found", i18n_post_args))
end
enqueue_timeout_job(@user)
false
end
@ -355,7 +371,9 @@ module DiscourseNarrativeBot
if Nokogiri::HTML5.fragment(@post.cooked).css("details").size > 0
reply_to(@post, I18n.t("#{I18N_KEY}.details.reply", i18n_post_args))
else
reply_to(@post, I18n.t("#{I18N_KEY}.details.not_found", i18n_post_args)) unless @data[:attempted]
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.details.not_found", i18n_post_args))
end
enqueue_timeout_job(@user)
false
end
@ -370,7 +388,9 @@ module DiscourseNarrativeBot
if @post.wiki
reply_to(@post, I18n.t("#{I18N_KEY}.wiki.reply", i18n_post_args))
else
reply_to(@post, I18n.t("#{I18N_KEY}.wiki.not_found", i18n_post_args)) unless @data[:attempted]
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.wiki.not_found", i18n_post_args))
end
enqueue_timeout_job(@user)
false
end
@ -379,9 +399,10 @@ module DiscourseNarrativeBot
def end_reply
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.end.message",
i18n_post_args(certificate: certificate('advanced'))
))
reply_to(
@post,
I18n.t("#{I18N_KEY}.end.message", i18n_post_args(certificate: certificate("advanced"))),
)
end
def synchronize(user)

View File

@ -4,7 +4,8 @@ module DiscourseNarrativeBot
class Base
include Actions
class InvalidTransitionError < StandardError; end
class InvalidTransitionError < StandardError
end
def input(input, user, post: nil, topic_id: nil, skip: false)
new_post = nil
@ -30,16 +31,18 @@ module DiscourseNarrativeBot
next_opts = self.class::TRANSITION_TABLE.fetch(next_state)
prerequisite = next_opts[:prerequisite]
if (!prerequisite || instance_eval(&prerequisite)) && !(
SiteSetting.discourse_narrative_bot_skip_tutorials.present? &&
SiteSetting.discourse_narrative_bot_skip_tutorials.split("|").include?(next_state.to_s))
if (!prerequisite || instance_eval(&prerequisite)) &&
!(
SiteSetting.discourse_narrative_bot_skip_tutorials.present? &&
SiteSetting
.discourse_narrative_bot_skip_tutorials
.split("|")
.include?(next_state.to_s)
)
break
end
[:next_state, :next_instructions].each do |key|
opts[key] = next_opts[key]
end
%i[next_state next_instructions].each { |key| opts[key] = next_opts[key] }
end
rescue InvalidTransitionError
# For given input, no transition for current state
@ -78,16 +81,9 @@ module DiscourseNarrativeBot
end_reply
cancel_timeout_job(user)
BadgeGranter.grant(
Badge.find_by(name: self.class.badge_name),
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
)
set_data(@user, topic_id: new_post.topic_id, state: :end, track: self.class.to_s)
end
end
rescue => e
@ -116,25 +112,29 @@ module DiscourseNarrativeBot
@data = get_data(user) || {}
if post = Post.find_by(id: @data[:last_post_id])
reply_to(post, I18n.t("discourse_narrative_bot.timeout.message",
i18n_post_args(
username: user.username,
skip_trigger: TrackSelector.skip_trigger,
reset_trigger: "#{TrackSelector.reset_trigger} #{self.class.reset_trigger}"
)
), {}, skip_send_email: false)
reply_to(
post,
I18n.t(
"discourse_narrative_bot.timeout.message",
i18n_post_args(
username: user.username,
skip_trigger: TrackSelector.skip_trigger,
reset_trigger: "#{TrackSelector.reset_trigger} #{self.class.reset_trigger}",
),
),
{},
skip_send_email: false,
)
end
end
def certificate(type = nil)
options = {
user_id: @user.id,
date: Time.zone.now.strftime('%b %d %Y'),
format: :svg
}
options = { user_id: @user.id, date: Time.zone.now.strftime("%b %d %Y"), format: :svg }
options.merge!(type: type) if type
src = Discourse.base_url + DiscourseNarrativeBot::Engine.routes.url_helpers.certificate_path(options)
src =
Discourse.base_url +
DiscourseNarrativeBot::Engine.routes.url_helpers.certificate_path(options)
alt = CGI.escapeHTML(I18n.t("#{self.class::I18N_KEY}.certificate.alt"))
"<iframe class='discobot-certificate' src='#{src}' width='650' height='464' alt='#{alt}'></iframe>"
@ -192,7 +192,7 @@ module DiscourseNarrativeBot
end
def not_implemented
raise 'Not implemented.'
raise "Not implemented."
end
private

View File

@ -10,7 +10,7 @@ module DiscourseNarrativeBot
begin
Date.parse(date)
rescue ArgumentError => e
if e.message == 'invalid date'
if e.message == "invalid date"
Date.parse(Date.today.to_s)
else
raise e
@ -25,14 +25,20 @@ module DiscourseNarrativeBot
svg_default_width = 538.583
logo_container = logo_group(55, svg_default_width, 280)
ApplicationController.render(inline: read_template('new_user'), assigns: assign_options(svg_default_width, logo_container))
ApplicationController.render(
inline: read_template("new_user"),
assigns: assign_options(svg_default_width, logo_container),
)
end
def advanced_user_track
svg_default_width = 722.8
logo_container = logo_group(40, svg_default_width, 350)
ApplicationController.render(inline: read_template('advanced_user'), assigns: assign_options(svg_default_width, logo_container))
ApplicationController.render(
inline: read_template("advanced_user"),
assigns: assign_options(svg_default_width, logo_container),
)
end
private
@ -48,7 +54,7 @@ module DiscourseNarrativeBot
date: @date,
avatar_url: @avatar_url,
logo_group: logo_group,
name: name
name: name,
}
end

View File

@ -7,25 +7,26 @@ module DiscourseNarrativeBot
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')
return I18n.t("discourse_narrative_bot.dice.invalid")
end
output = +''
output = +""
if num_of_dice > MAXIMUM_NUM_OF_DICE
output << I18n.t('discourse_narrative_bot.dice.not_enough_dice', count: MAXIMUM_NUM_OF_DICE)
output << I18n.t("discourse_narrative_bot.dice.not_enough_dice", count: 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 << 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(", ")
output << I18n.t(
"discourse_narrative_bot.dice.results",
results: num_of_dice.times.map { rand(1..range_of_dice) }.join(", "),
)
end
end

View File

@ -3,9 +3,10 @@
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)}"
))
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

@ -1,136 +1,136 @@
# frozen_string_literal: true
require 'distributed_mutex'
require "distributed_mutex"
module DiscourseNarrativeBot
class NewUserNarrative < Base
I18N_KEY = "discourse_narrative_bot.new_user_narrative".freeze
BADGE_NAME = 'Certified'.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
}
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) },
next_instructions:
Proc.new { I18n.t("#{I18N_KEY}.onebox.instructions", base_uri: Discourse.base_path) },
bookmark: {
action: :reply_to_bookmark
action: :reply_to_bookmark,
},
reply: {
next_state: :tutorial_bookmark,
action: :missing_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) },
next_instructions:
Proc.new { I18n.t("#{I18N_KEY}.emoji.instructions", base_uri: Discourse.base_path) },
reply: {
action: :reply_to_onebox
}
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)
},
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
}
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) },
next_instructions:
Proc.new { I18n.t("#{I18N_KEY}.formatting.instructions", base_uri: Discourse.base_path) },
reply: {
action: :reply_to_mention
}
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) },
next_instructions:
Proc.new { I18n.t("#{I18N_KEY}.quoting.instructions", base_uri: Discourse.base_path) },
reply: {
action: :reply_to_formatting
}
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) },
next_instructions:
Proc.new { I18n.t("#{I18N_KEY}.images.instructions", base_uri: Discourse.base_path) },
reply: {
action: :reply_to_quote
}
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) },
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) },
next_instructions:
Proc.new { I18n.t("#{I18N_KEY}.likes.instructions", base_uri: Discourse.base_path) },
reply: {
action: :reply_to_image
action: :reply_to_image,
},
like: {
action: :track_images_like
}
action: :track_images_like,
},
},
tutorial_likes: {
prerequisite: Proc.new { !@user.has_trust_level?(SiteSetting.min_trust_to_post_embedded_media) },
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)
},
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
action: :reply_to_likes,
},
reply: {
next_state: :tutorial_likes,
action: :missing_likes_like
}
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) },
next_instructions:
Proc.new { I18n.t("#{I18N_KEY}.search.instructions", base_uri: Discourse.base_path) },
flag: {
action: :reply_to_flag
action: :reply_to_flag,
},
reply: {
next_state: :tutorial_flag,
action: :missing_flag
}
action: :missing_flag,
},
},
tutorial_search: {
next_state: :end,
reply: {
action: :reply_to_search
}
}
action: :reply_to_search,
},
},
}
def self.badge_name
@ -138,7 +138,7 @@ module DiscourseNarrativeBot
end
def self.search_answer
':herb:'
":herb:"
end
def self.search_answer_emoji
@ -146,7 +146,7 @@ module DiscourseNarrativeBot
end
def self.reset_trigger
I18n.t('discourse_narrative_bot.new_user_narrative.reset_trigger')
I18n.t("discourse_narrative_bot.new_user_narrative.reset_trigger")
end
def reset_bot(user, post)
@ -173,7 +173,7 @@ module DiscourseNarrativeBot
topic = @post.topic
post = topic.first_post
MessageBus.publish('/new_user_narrative/tutorial_search', {}, user_ids: [@user.id])
MessageBus.publish("/new_user_narrative/tutorial_search", {}, user_ids: [@user.id])
raw = <<~MD
#{post.raw}
@ -184,7 +184,8 @@ module DiscourseNarrativeBot
PostRevisor.new(post, topic).revise!(
self.discobot_user,
{ raw: raw },
skip_validations: true, force_new_version: true
skip_validations: true,
force_new_version: true,
)
set_state_data(:post_version, post.reload.version || 0)
@ -198,13 +199,11 @@ module DiscourseNarrativeBot
end
def say_hello
raw = I18n.t(
"#{I18N_KEY}.hello.message",
i18n_post_args(
username: @user.username,
title: SiteSetting.title
raw =
I18n.t(
"#{I18N_KEY}.hello.message",
i18n_post_args(username: @user.username, title: SiteSetting.title),
)
)
raw = <<~MD
#{raw}
@ -213,9 +212,7 @@ module DiscourseNarrativeBot
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
title = title.gsub(/:([\w\-+]+(?::t\d)?):/, "").strip if SiteSetting.max_emojis_in_title == 0
opts = {
title: title,
@ -224,17 +221,13 @@ module DiscourseNarrativeBot
subtype: TopicSubtype.system_message,
}
if @post &&
@post.topic.private_message? &&
@post.topic.topic_allowed_users.pluck(:user_id).include?(@user.id)
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)
opts = opts.merge(topic_id: @data[:topic_id]).except(:title, :target_usernames, :archetype)
end
post = reply_to(@post, raw, opts)
@ -249,7 +242,9 @@ module DiscourseNarrativeBot
fake_delay
enqueue_timeout_job(@user)
reply_to(@post, I18n.t("#{I18N_KEY}.bookmark.not_found", i18n_post_args)) unless @data[:attempted]
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.bookmark.not_found", i18n_post_args))
end
false
end
@ -292,7 +287,9 @@ module DiscourseNarrativeBot
reply
else
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.onebox.not_found", i18n_post_args)) unless @data[:attempted]
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.onebox.not_found", i18n_post_args))
end
enqueue_timeout_job(@user)
false
end
@ -302,11 +299,12 @@ module DiscourseNarrativeBot
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
)
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)
@ -358,18 +356,23 @@ module DiscourseNarrativeBot
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)
)
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")
)
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
@ -398,11 +401,12 @@ module DiscourseNarrativeBot
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
)
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
@ -425,7 +429,10 @@ module DiscourseNarrativeBot
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
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)}
@ -439,7 +446,9 @@ module DiscourseNarrativeBot
reply
else
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.formatting.not_found", i18n_post_args)) unless @data[:attempted]
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.formatting.not_found", i18n_post_args))
end
enqueue_timeout_job(@user)
false
end
@ -465,7 +474,9 @@ module DiscourseNarrativeBot
reply
else
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.quoting.not_found", i18n_post_args)) unless @data[:attempted]
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.quoting.not_found", i18n_post_args))
end
enqueue_timeout_job(@user)
false
end
@ -491,7 +502,9 @@ module DiscourseNarrativeBot
reply
else
fake_delay
reply_to(@post, I18n.t("#{I18N_KEY}.emoji.not_found", i18n_post_args)) unless @data[:attempted]
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.emoji.not_found", i18n_post_args))
end
enqueue_timeout_job(@user)
false
end
@ -517,13 +530,13 @@ module DiscourseNarrativeBot
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
)
))
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)
@ -536,9 +549,13 @@ module DiscourseNarrativeBot
# Remove any incorrect flags so that they can try again
if @post.user_id == -2
@post.post_actions
@post
.post_actions
.where(user_id: @user.id)
.where("post_action_type_id IN (?)", (PostActionType.flag_types.values - [PostActionType.types[:inappropriate]]))
.where(
"post_action_type_id IN (?)",
(PostActionType.flag_types.values - [PostActionType.types[:inappropriate]]),
)
.destroy_all
end
@ -571,12 +588,18 @@ module DiscourseNarrativeBot
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)
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))))
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]
unless @data[:attempted]
reply_to(@post, I18n.t("#{I18N_KEY}.search.not_found", i18n_post_args))
end
enqueue_timeout_job(@user)
false
end
@ -587,16 +610,17 @@ module DiscourseNarrativeBot
reply_to(
@post,
I18n.t("#{I18N_KEY}.end.message",
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
)
advanced_trigger: AdvancedUserNarrative.reset_trigger,
),
),
topic_id: @data[:topic_id]
topic_id: @data[:topic_id],
)
end
@ -605,15 +629,12 @@ module DiscourseNarrativeBot
end
def welcome_topic
Topic.find_by(slug: 'welcome-to-discourse', archetype: Archetype.default) ||
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)
)
Rails.application.routes.url_helpers.public_send(url, opts.merge(host: Discourse.base_url))
end
end
end

View File

@ -1,24 +1,21 @@
# frozen_string_literal: true
require 'excon'
require "excon"
module DiscourseNarrativeBot
class QuoteGenerator
API_ENDPOINT = 'http://api.forismatic.com/api/1.0/'.freeze
API_ENDPOINT = "http://api.forismatic.com/api/1.0/".freeze
def self.format_quote(quote, author)
I18n.t('discourse_narrative_bot.quote.results', quote: quote, author: author)
I18n.t("discourse_narrative_bot.quote.results", quote: quote, author: author)
end
def self.generate(user)
quote, author =
if !user.effective_locale.start_with?('en')
if !user.effective_locale.start_with?("en")
translation_key = "discourse_narrative_bot.quote.#{rand(1..10)}"
[
I18n.t("#{translation_key}.quote"),
I18n.t("#{translation_key}.author")
]
[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)

View File

@ -4,18 +4,12 @@ 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
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 = [
AdvancedUserNarrative,
NewUserNarrative
]
TRACKS = [AdvancedUserNarrative, NewUserNarrative]
TOPIC_ACTIONS = [
:delete,
:topic_notification_level_changed
].each(&:freeze)
TOPIC_ACTIONS = %i[delete topic_notification_level_changed].each(&:freeze)
RESET_TRIGGER_EXACT_MATCH_LENGTH = 200
@ -118,7 +112,7 @@ module DiscourseNarrativeBot
trigger = "#{self.class.reset_trigger} #{klass.reset_trigger}"
if @post.raw.length < RESET_TRIGGER_EXACT_MATCH_LENGTH && @is_pm_to_bot
@post.raw.match(Regexp.new("\\b\\W\?#{trigger}\\W\?\\b", 'i'))
@post.raw.match(Regexp.new("\\b\\W\?#{trigger}\\W\?\\b", "i"))
else
match_trigger?(trigger)
end
@ -127,7 +121,7 @@ module DiscourseNarrativeBot
def bot_commands(hint = true)
raw =
if @user.manually_disabled_discobot?
I18n.t(self.class.i18n_key('random_mention.discobot_disabled'))
I18n.t(self.class.i18n_key("random_mention.discobot_disabled"))
elsif 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)
@ -137,20 +131,23 @@ module DiscourseNarrativeBot
elsif match_trigger?(self.class.help_trigger)
help_message
elsif hint
message = I18n.t(self.class.i18n_key('random_mention.reply'),
discobot_username: self.discobot_username,
help_trigger: self.class.help_trigger
)
message =
I18n.t(
self.class.i18n_key("random_mention.reply"),
discobot_username: self.discobot_username,
help_trigger: self.class.help_trigger,
)
if public_reply?
key = "#{PUBLIC_DISPLAY_BOT_HELP_KEY}:#{@post.topic_id}"
last_bot_help_post_number = Discourse.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 - Discourse.redis.ttl(key)) > 6.hours.to_i)
(
last_bot_help_post_number &&
@post.post_number - 10 > last_bot_help_post_number.to_i &&
(1.day.to_i - Discourse.redis.ttl(key)) > 6.hours.to_i
)
Discourse.redis.setex(key, 1.day.to_i, @post.post_number)
message
end
@ -166,20 +163,24 @@ module DiscourseNarrativeBot
end
def help_message
message = I18n.t(
self.class.i18n_key('random_mention.tracks'),
discobot_username: self.discobot_username,
reset_trigger: self.class.reset_trigger,
tracks: [NewUserNarrative.reset_trigger, AdvancedUserNarrative.reset_trigger].join(', ')
)
message =
I18n.t(
self.class.i18n_key("random_mention.tracks"),
discobot_username: self.discobot_username,
reset_trigger: self.class.reset_trigger,
tracks: [NewUserNarrative.reset_trigger, AdvancedUserNarrative.reset_trigger].join(", "),
)
message << "\n\n#{I18n.t(self.class.i18n_key('random_mention.bot_actions'),
discobot_username: self.discobot_username,
dice_trigger: self.class.dice_trigger,
quote_trigger: self.class.quote_trigger,
quote_sample: DiscourseNarrativeBot::QuoteGenerator.generate(@user),
magic_8_ball_trigger: self.class.magic_8_ball_trigger
)}"
message << "\n\n#{
I18n.t(
self.class.i18n_key("random_mention.bot_actions"),
discobot_username: self.discobot_username,
dice_trigger: self.class.dice_trigger,
quote_trigger: self.class.quote_trigger,
quote_sample: DiscourseNarrativeBot::QuoteGenerator.generate(@user),
magic_8_ball_trigger: self.class.magic_8_ball_trigger,
)
}"
end
def generic_replies_key(user)
@ -193,18 +194,23 @@ module DiscourseNarrativeBot
case count
when 0
raw = I18n.t(self.class.i18n_key('do_not_understand.first_response'))
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)}"
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'),
base_path: Discourse.base_path,
reset_trigger: self.class.reset_trigger
))
reply_to(
@post,
I18n.t(
self.class.i18n_key("do_not_understand.second_response"),
base_path: Discourse.base_path,
reset_trigger: self.class.reset_trigger,
),
)
else
# Stay out of the user's way
end
@ -218,7 +224,9 @@ module DiscourseNarrativeBot
def skip_track?
if @is_pm_to_bot
@post.raw.match(/((^@#{self.discobot_username} #{self.class.skip_trigger})|(^#{self.class.skip_trigger}$))/i)
@post.raw.match(
/((^@#{self.discobot_username} #{self.class.skip_trigger})|(^#{self.class.skip_trigger}$))/i,
)
else
false
end
@ -233,24 +241,23 @@ module DiscourseNarrativeBot
def match_trigger?(trigger)
# we remove the leading <p> to allow for trigger to be at the end of a paragraph
cooked_trigger = cook(trigger)[3..-1]
regexp = Regexp.new(cooked_trigger, 'i')
regexp = Regexp.new(cooked_trigger, "i")
match = @post.cooked.match(regexp)
if @is_pm_to_bot
match || @post.raw.strip.match(Regexp.new("^#{trigger}$", 'i'))
match || @post.raw.strip.match(Regexp.new("^#{trigger}$", "i"))
else
match
end
end
def like_user_post
if @post.raw.match(/thank/i)
PostActionCreator.like(self.discobot_user, @post)
end
PostActionCreator.like(self.discobot_user, @post) if @post.raw.match(/thank/i)
end
def bot_mentioned?
@bot_mentioned ||= PostAnalyzer.new(@post.raw, @post.topic_id).raw_mentions.include?(self.discobot_username)
@bot_mentioned ||=
PostAnalyzer.new(@post.raw, @post.topic_id).raw_mentions.include?(self.discobot_username)
end
def public_reply?

View File

@ -8,8 +8,14 @@ module DiscourseNarrativeBot
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' }
{
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

View File

@ -18,34 +18,37 @@ if Rails.env == "development"
# 3. we have a post_edited hook that queues a job for bot input
# 4. if you are not running sidekiq in dev every time you save a post it will trigger it
# 5. but the constant can not be autoloaded
Rails.configuration.autoload_paths << File.expand_path('../autoload/jobs', __FILE__)
Rails.configuration.autoload_paths << File.expand_path("../autoload/jobs", __FILE__)
end
require_relative 'lib/discourse_narrative_bot/welcome_post_type_site_setting.rb'
register_asset 'stylesheets/discourse-narrative-bot.scss'
require_relative "lib/discourse_narrative_bot/welcome_post_type_site_setting.rb"
register_asset "stylesheets/discourse-narrative-bot.scss"
after_initialize do
SeedFu.fixture_paths << Rails.root.join("plugins", "discourse-narrative-bot", "db", "fixtures").to_s
SeedFu.fixture_paths << Rails
.root
.join("plugins", "discourse-narrative-bot", "db", "fixtures")
.to_s
Mime::Type.register "image/svg+xml", :svg
[
'../autoload/jobs/regular/bot_input.rb',
'../autoload/jobs/regular/narrative_timeout.rb',
'../autoload/jobs/regular/narrative_init.rb',
'../autoload/jobs/regular/send_default_welcome_message.rb',
'../autoload/jobs/onceoff/discourse_narrative_bot/grant_badges.rb',
'../autoload/jobs/onceoff/discourse_narrative_bot/remap_old_bot_images.rb',
'../lib/discourse_narrative_bot/actions.rb',
'../lib/discourse_narrative_bot/base.rb',
'../lib/discourse_narrative_bot/new_user_narrative.rb',
'../lib/discourse_narrative_bot/advanced_user_narrative.rb',
'../lib/discourse_narrative_bot/track_selector.rb',
'../lib/discourse_narrative_bot/certificate_generator.rb',
'../lib/discourse_narrative_bot/dice.rb',
'../lib/discourse_narrative_bot/quote_generator.rb',
'../lib/discourse_narrative_bot/magic_8_ball.rb',
'../lib/discourse_narrative_bot/welcome_post_type_site_setting.rb'
%w[
../autoload/jobs/regular/bot_input.rb
../autoload/jobs/regular/narrative_timeout.rb
../autoload/jobs/regular/narrative_init.rb
../autoload/jobs/regular/send_default_welcome_message.rb
../autoload/jobs/onceoff/discourse_narrative_bot/grant_badges.rb
../autoload/jobs/onceoff/discourse_narrative_bot/remap_old_bot_images.rb
../lib/discourse_narrative_bot/actions.rb
../lib/discourse_narrative_bot/base.rb
../lib/discourse_narrative_bot/new_user_narrative.rb
../lib/discourse_narrative_bot/advanced_user_narrative.rb
../lib/discourse_narrative_bot/track_selector.rb
../lib/discourse_narrative_bot/certificate_generator.rb
../lib/discourse_narrative_bot/dice.rb
../lib/discourse_narrative_bot/quote_generator.rb
../lib/discourse_narrative_bot/magic_8_ball.rb
../lib/discourse_narrative_bot/welcome_post_type_site_setting.rb
].each { |path| load File.expand_path(path, __FILE__) }
RailsMultisite::ConnectionManagement.safe_each_connection do
@ -55,12 +58,13 @@ after_initialize do
certificate_path = "#{Discourse.base_url}/discobot/certificate.svg"
if !SiteSetting.allowed_iframes.include?(certificate_path)
SiteSetting.allowed_iframes = SiteSetting.allowed_iframes.split('|').append(certificate_path).join('|')
SiteSetting.allowed_iframes =
SiteSetting.allowed_iframes.split("|").append(certificate_path).join("|")
end
end
end
require_dependency 'plugin_store'
require_dependency "plugin_store"
module ::DiscourseNarrativeBot
PLUGIN_NAME = "discourse-narrative-bot".freeze
@ -94,13 +98,15 @@ after_initialize do
immutable_for(24.hours)
%i[date user_id].each do |key|
raise Discourse::InvalidParameters.new("#{key} must be present") unless params[key]&.present?
unless params[key]&.present?
raise Discourse::InvalidParameters.new("#{key} must be present")
end
end
if params[:user_id].to_i != current_user.id
rate_limiter = RateLimiter.new(current_user, 'svg_certificate', 3, 1.minute)
rate_limiter = RateLimiter.new(current_user, "svg_certificate", 3, 1.minute)
else
rate_limiter = RateLimiter.new(current_user, 'svg_certificate_self', 30, 10.minutes)
rate_limiter = RateLimiter.new(current_user, "svg_certificate_self", 30, 10.minutes)
end
rate_limiter.performed! unless current_user.staff?
@ -110,33 +116,28 @@ after_initialize do
hijack do
generator = CertificateGenerator.new(user, params[:date], avatar_url(user))
svg = params[:type] == 'advanced' ? generator.advanced_user_track : generator.new_user_track
svg =
params[:type] == "advanced" ? generator.advanced_user_track : generator.new_user_track
respond_to do |format|
format.svg { render inline: svg }
end
respond_to { |format| format.svg { render inline: svg } }
end
end
private
def avatar_url(user)
UrlHelper.absolute(Discourse.base_path + user.avatar_template.gsub('{size}', '250'))
UrlHelper.absolute(Discourse.base_path + user.avatar_template.gsub("{size}", "250"))
end
end
end
DiscourseNarrativeBot::Engine.routes.draw do
get "/certificate" => "certificates#generate", format: :svg
get "/certificate" => "certificates#generate", :format => :svg
end
Discourse::Application.routes.append do
mount ::DiscourseNarrativeBot::Engine, at: "/discobot"
end
Discourse::Application.routes.append { mount ::DiscourseNarrativeBot::Engine, at: "/discobot" }
self.add_model_callback(User, :after_destroy) do
DiscourseNarrativeBot::Store.remove(self.id)
end
self.add_model_callback(User, :after_destroy) { DiscourseNarrativeBot::Store.remove(self.id) }
self.on(:user_created) do |user|
if SiteSetting.discourse_narrative_bot_welcome_post_delay == 0 && !user.staged
@ -145,19 +146,13 @@ after_initialize do
end
self.on(:user_first_logged_in) do |user|
if SiteSetting.discourse_narrative_bot_welcome_post_delay > 0
user.enqueue_bot_welcome_post
end
user.enqueue_bot_welcome_post if SiteSetting.discourse_narrative_bot_welcome_post_delay > 0
end
self.on(:user_unstaged) do |user|
user.enqueue_bot_welcome_post
end
self.on(:user_unstaged) { |user| user.enqueue_bot_welcome_post }
self.add_model_callback(UserOption, :after_save) do
if saved_change_to_skip_new_user_tips? && self.skip_new_user_tips
user.delete_bot_welcome_post
end
user.delete_bot_welcome_post if saved_change_to_skip_new_user_tips? && self.skip_new_user_tips
end
self.add_to_class(:user, :enqueue_bot_welcome_post) do
@ -166,28 +161,29 @@ after_initialize do
delay = SiteSetting.discourse_narrative_bot_welcome_post_delay
case SiteSetting.discourse_narrative_bot_welcome_post_type
when 'new_user_track'
when "new_user_track"
if enqueue_narrative_bot_job? && !manually_disabled_discobot?
Jobs.enqueue_in(delay, :narrative_init,
Jobs.enqueue_in(
delay,
:narrative_init,
user_id: self.id,
klass: DiscourseNarrativeBot::NewUserNarrative.to_s
klass: DiscourseNarrativeBot::NewUserNarrative.to_s,
)
end
when 'welcome_message'
when "welcome_message"
Jobs.enqueue_in(delay, :send_default_welcome_message, user_id: self.id)
end
end
self.add_to_class(:user, :manually_disabled_discobot?) do
user_option&.skip_new_user_tips
end
self.add_to_class(:user, :manually_disabled_discobot?) { user_option&.skip_new_user_tips }
self.add_to_class(:user, :enqueue_narrative_bot_job?) do
SiteSetting.discourse_narrative_bot_enabled &&
self.human? &&
!self.anonymous? &&
SiteSetting.discourse_narrative_bot_enabled && self.human? && !self.anonymous? &&
!self.staged &&
!SiteSetting.discourse_narrative_bot_ignored_usernames.split('|'.freeze).include?(self.username)
!SiteSetting
.discourse_narrative_bot_ignored_usernames
.split("|".freeze)
.include?(self.username)
end
self.add_to_class(:user, :delete_bot_welcome_post) do
@ -219,42 +215,31 @@ after_initialize do
user = post.user
if user&.enqueue_narrative_bot_job? && !options[:skip_bot]
Jobs.enqueue(:bot_input,
user_id: user.id,
post_id: post.id,
input: "reply"
)
Jobs.enqueue(:bot_input, user_id: user.id, post_id: post.id, input: "reply")
end
end
self.on(:post_edited) do |post|
if post.user&.enqueue_narrative_bot_job?
Jobs.enqueue(:bot_input,
user_id: post.user.id,
post_id: post.id,
input: "edit"
)
Jobs.enqueue(:bot_input, user_id: post.user.id, post_id: post.id, input: "edit")
end
end
self.on(:post_destroyed) do |post, options, user|
if user&.enqueue_narrative_bot_job? && !options[:skip_bot]
Jobs.enqueue(:bot_input,
Jobs.enqueue(
:bot_input,
user_id: user.id,
post_id: post.id,
topic_id: post.topic_id,
input: "delete"
input: "delete",
)
end
end
self.on(:post_recovered) do |post, _, user|
if user&.enqueue_narrative_bot_job?
Jobs.enqueue(:bot_input,
user_id: user.id,
post_id: post.id,
input: "recover"
)
Jobs.enqueue(:bot_input, user_id: user.id, post_id: post.id, input: "recover")
end
end
@ -268,20 +253,19 @@ after_initialize do
"like"
end
if input
Jobs.enqueue(:bot_input,
user_id: self.user.id,
post_id: self.post.id,
input: input
)
end
Jobs.enqueue(:bot_input, user_id: self.user.id, post_id: self.post.id, input: input) if input
end
end
self.add_model_callback(Bookmark, :after_commit, on: :create) do
if self.user.enqueue_narrative_bot_job?
if self.bookmarkable_type == "Post"
Jobs.enqueue(:bot_input, user_id: self.user_id, post_id: self.bookmarkable_id, input: "bookmark")
Jobs.enqueue(
:bot_input,
user_id: self.user_id,
post_id: self.bookmarkable_id,
input: "bookmark",
)
end
end
end
@ -290,31 +274,36 @@ after_initialize do
user = User.find_by(id: user_id)
if user && user.enqueue_narrative_bot_job?
Jobs.enqueue(:bot_input,
Jobs.enqueue(
:bot_input,
user_id: user_id,
topic_id: topic_id,
input: "topic_notification_level_changed"
input: "topic_notification_level_changed",
)
end
end
UserAvatar.register_custom_user_gravatar_email_hash(
DiscourseNarrativeBot::BOT_USER_ID,
"discobot@discourse.org"
"discobot@discourse.org",
)
self.on(:system_message_sent) do |args|
next if !SiteSetting.discourse_narrative_bot_enabled
next if args[:message_type] != 'tl2_promotion_message'
next if args[:message_type] != "tl2_promotion_message"
recipient = args[:post].topic.topic_users.where.not(user_id: args[:post].user_id).last&.user
recipient ||= Discourse.site_contact_user if args[:post].user == Discourse.site_contact_user
next if recipient.nil?
I18n.with_locale(recipient.effective_locale) do
raw = I18n.t("discourse_narrative_bot.tl2_promotion_message.text_body_template",
discobot_username: ::DiscourseNarrativeBot::Base.new.discobot_username,
reset_trigger: "#{::DiscourseNarrativeBot::TrackSelector.reset_trigger} #{::DiscourseNarrativeBot::AdvancedUserNarrative.reset_trigger}")
raw =
I18n.t(
"discourse_narrative_bot.tl2_promotion_message.text_body_template",
discobot_username: ::DiscourseNarrativeBot::Base.new.discobot_username,
reset_trigger:
"#{::DiscourseNarrativeBot::TrackSelector.reset_trigger} #{::DiscourseNarrativeBot::AdvancedUserNarrative.reset_trigger}",
)
PostCreator.create!(
::DiscourseNarrativeBot::Base.new.discobot_user,
@ -322,7 +311,7 @@ after_initialize do
raw: raw,
skip_validations: true,
archetype: Archetype.private_message,
target_usernames: recipient.username
target_usernames: recipient.username,
)
end
end
@ -331,12 +320,12 @@ after_initialize do
alias_method :existing_can_create_post?, :can_create_post?
def can_create_post?(parent)
return true if SiteSetting.discourse_narrative_bot_enabled &&
parent.try(:subtype) == "system_message" &&
parent.try(:user) == ::DiscourseNarrativeBot::Base.new.discobot_user
if SiteSetting.discourse_narrative_bot_enabled && parent.try(:subtype) == "system_message" &&
parent.try(:user) == ::DiscourseNarrativeBot::Base.new.discobot_user
return true
end
existing_can_create_post?(parent)
end
end
end

View File

@ -1,28 +1,28 @@
# frozen_string_literal: true
RSpec.describe DiscourseNarrativeBot::Store do
describe '.set' do
it 'should set the right value in the plugin store' do
key = 'somekey'
described_class.set(key, 'yay')
describe ".set" do
it "should set the right value in the plugin store" do
key = "somekey"
described_class.set(key, "yay")
plugin_store_row = PluginStoreRow.last
expect(plugin_store_row.value).to eq('yay')
expect(plugin_store_row.value).to eq("yay")
expect(plugin_store_row.plugin_name).to eq(DiscourseNarrativeBot::PLUGIN_NAME)
expect(plugin_store_row.key).to eq(key)
end
end
describe '.get' do
it 'should get the right value from the plugin store' do
describe ".get" do
it "should get the right value from the plugin store" do
PluginStoreRow.create!(
plugin_name: DiscourseNarrativeBot::PLUGIN_NAME,
key: 'somekey',
value: 'yay',
type_name: 'string'
key: "somekey",
value: "yay",
type_name: "string",
)
expect(described_class.get('somekey')).to eq('yay')
expect(described_class.get("somekey")).to eq("yay")
end
end
end

View File

@ -5,13 +5,16 @@ RSpec.describe Jobs::DiscourseNarrativeBot::GrantBadges do
let(:other_user) { Fabricate(:user) }
before do
DiscourseNarrativeBot::Store.set(user.id, completed: [
DiscourseNarrativeBot::NewUserNarrative.to_s,
DiscourseNarrativeBot::AdvancedUserNarrative.to_s
])
DiscourseNarrativeBot::Store.set(
user.id,
completed: [
DiscourseNarrativeBot::NewUserNarrative.to_s,
DiscourseNarrativeBot::AdvancedUserNarrative.to_s,
],
)
end
it 'should grant the right badges' do
it "should grant the right badges" do
described_class.new.execute_onceoff({})
expect(user.badges.count).to eq(2)

View File

@ -3,14 +3,17 @@
RSpec.describe Jobs::DiscourseNarrativeBot::RemapOldBotImages do
context "when bot's post contains an old link" do
let!(:post) do
Fabricate(:post,
Fabricate(
:post,
user: ::DiscourseNarrativeBot::Base.new.discobot_user,
raw: 'If youd like to learn more, select <img src="/images/font-awesome-gear.png" width="16" height="16"> <img src="/images/font-awesome-ellipsis.png" width="16" height="16"> below and <img src="/images/font-awesome-bookmark.png" width="16" height="16"> **bookmark this private message**. If you do, there may be a :gift: in your future!'
raw:
'If youd like to learn more, select <img src="/images/font-awesome-gear.png" width="16" height="16"> <img src="/images/font-awesome-ellipsis.png" width="16" height="16"> below and <img src="/images/font-awesome-bookmark.png" width="16" height="16"> **bookmark this private message**. If you do, there may be a :gift: in your future!',
)
end
it 'should remap the links correctly' do
expected_raw = 'If youd like to learn more, select <img src="/plugins/discourse-narrative-bot/images/font-awesome-gear.png" width="16" height="16"> <img src="/plugins/discourse-narrative-bot/images/font-awesome-ellipsis.png" width="16" height="16"> below and <img src="/plugins/discourse-narrative-bot/images/font-awesome-bookmark.png" width="16" height="16"> **bookmark this private message**. If you do, there may be a :gift: in your future!'
it "should remap the links correctly" do
expected_raw =
'If youd like to learn more, select <img src="/plugins/discourse-narrative-bot/images/font-awesome-gear.png" width="16" height="16"> <img src="/plugins/discourse-narrative-bot/images/font-awesome-ellipsis.png" width="16" height="16"> below and <img src="/plugins/discourse-narrative-bot/images/font-awesome-bookmark.png" width="16" height="16"> **bookmark this private message**. If you do, there may be a :gift: in your future!'
2.times do
described_class.new.execute_onceoff({})
@ -19,19 +22,21 @@ RSpec.describe Jobs::DiscourseNarrativeBot::RemapOldBotImages do
end
end
context 'with subfolder' do
context "with subfolder" do
let!(:post) do
Fabricate(:post,
Fabricate(
:post,
user: ::DiscourseNarrativeBot::Base.new.discobot_user,
raw: 'If youd like to learn more, select <img src="/community/images/font-awesome-ellipsis.png" width="16" height="16"> below and <img src="/community/images/font-awesome-bookmark.png" width="16" height="16"> **bookmark this private message**. If you do, there may be a :gift: in your future!'
raw:
'If youd like to learn more, select <img src="/community/images/font-awesome-ellipsis.png" width="16" height="16"> below and <img src="/community/images/font-awesome-bookmark.png" width="16" height="16"> **bookmark this private message**. If you do, there may be a :gift: in your future!',
)
end
it 'should remap the links correctly' do
it "should remap the links correctly" do
described_class.new.execute_onceoff({})
expect(post.reload.raw).to eq(
'If youd like to learn more, select <img src="/community/plugins/discourse-narrative-bot/images/font-awesome-ellipsis.png" width="16" height="16"> below and <img src="/community/plugins/discourse-narrative-bot/images/font-awesome-bookmark.png" width="16" height="16"> **bookmark this private message**. If you do, there may be a :gift: in your future!'
'If youd like to learn more, select <img src="/community/plugins/discourse-narrative-bot/images/font-awesome-ellipsis.png" width="16" height="16"> below and <img src="/community/plugins/discourse-narrative-bot/images/font-awesome-bookmark.png" width="16" height="16"> **bookmark this private message**. If you do, there may be a :gift: in your future!',
)
end
end

View File

@ -3,42 +3,51 @@
RSpec.describe Jobs::SendDefaultWelcomeMessage do
let(:user) { Fabricate(:user) }
it 'should send the right welcome message' do
it "should send the right welcome message" do
described_class.new.execute(user_id: user.id)
topic = Topic.last
expect(topic.title).to eq(I18n.t(
"system_messages.welcome_user.subject_template",
site_name: SiteSetting.title
))
expect(topic.title).to eq(
I18n.t("system_messages.welcome_user.subject_template", site_name: SiteSetting.title),
)
expect(topic.first_post.raw).to eq(I18n.t(
"system_messages.welcome_user.text_body_template",
SystemMessage.new(user).defaults
).chomp)
expect(topic.first_post.raw).to eq(
I18n.t(
"system_messages.welcome_user.text_body_template",
SystemMessage.new(user).defaults,
).chomp,
)
expect(topic.closed).to eq(true)
end
describe 'for an invited user' do
let(:invite) { Fabricate(:invite, email: 'foo@bar.com') }
let(:invited_user) { Fabricate(:invited_user, invite: invite, user: Fabricate(:user, email: 'foo@bar.com'), redeemed_at: Time.zone.now) }
describe "for an invited user" do
let(:invite) { Fabricate(:invite, email: "foo@bar.com") }
let(:invited_user) do
Fabricate(
:invited_user,
invite: invite,
user: Fabricate(:user, email: "foo@bar.com"),
redeemed_at: Time.zone.now,
)
end
it 'should send the right welcome message' do
it "should send the right welcome message" do
described_class.new.execute(user_id: invited_user.user_id)
topic = Topic.last
expect(topic.title).to eq(I18n.t(
"system_messages.welcome_invite.subject_template",
site_name: SiteSetting.title
))
expect(topic.title).to eq(
I18n.t("system_messages.welcome_invite.subject_template", site_name: SiteSetting.title),
)
expect(topic.first_post.raw).to eq(I18n.t(
"system_messages.welcome_invite.text_body_template",
SystemMessage.new(invited_user.user).defaults
).chomp)
expect(topic.first_post.raw).to eq(
I18n.t(
"system_messages.welcome_invite.text_body_template",
SystemMessage.new(invited_user.user).defaults,
).chomp,
)
expect(topic.closed).to eq(true)
end

View File

@ -2,24 +2,21 @@
RSpec.describe DiscourseNarrativeBot::CertificateGenerator do
let(:user) { Fabricate(:user) }
let(:avatar_url) { 'http://test.localhost/cdn/avatar.png' }
let(:avatar_url) { "http://test.localhost/cdn/avatar.png" }
let(:date) { "2017-00-10" }
describe 'when an invalid date is given' do
it 'should default to the current date' do
describe "when an invalid date is given" do
it "should default to the current date" do
expect { described_class.new(user, date, avatar_url) }.to_not raise_error
end
end
describe '#logo_group' do
describe 'when SiteSetting.site_logo_small_url is blank' do
before do
SiteSetting.logo_small = ''
end
describe "#logo_group" do
describe "when SiteSetting.site_logo_small_url is blank" do
before { SiteSetting.logo_small = "" }
it 'should not try to fetch a image' do
expect(described_class.new(user, date, avatar_url).send(:logo_group, 1, 1, 1))
.to eq(nil)
it "should not try to fetch a image" do
expect(described_class.new(user, date, avatar_url).send(:logo_group, 1, 1, 1)).to eq(nil)
end
end
end

View File

@ -1,48 +1,43 @@
# frozen_string_literal: true
RSpec.describe "Discobot Certificate" do
let(:user) { Fabricate(:user, name: 'Jeff Atwood') }
let(:user) { Fabricate(:user, name: "Jeff Atwood") }
let(:params) {
{
date: Time.zone.now.strftime("%b %d %Y"),
user_id: user.id
}
}
let(:params) { { date: Time.zone.now.strftime("%b %d %Y"), user_id: user.id } }
describe 'when viewing the certificate' do
describe 'when no logged in' do
it 'should return the right response' do
get '/discobot/certificate.svg', params: params
describe "when viewing the certificate" do
describe "when no logged in" do
it "should return the right response" do
get "/discobot/certificate.svg", params: params
expect(response.status).to eq(404)
end
end
describe 'when logged in' do
before do
sign_in(user)
end
describe "when logged in" do
before { sign_in(user) }
it 'should return the right text' do
stub_request(:get, /letter_avatar_proxy/).to_return(status: 200, body: 'http://test.localhost/cdn/avatar.png')
it "should return the right text" do
stub_request(:get, /letter_avatar_proxy/).to_return(
status: 200,
body: "http://test.localhost/cdn/avatar.png",
)
stub_request(:get, /avatar.png/).to_return(status: 200)
stub_request(:get, SiteSetting.site_logo_small_url)
.to_return(status: 200)
stub_request(:get, SiteSetting.site_logo_small_url).to_return(status: 200)
get '/discobot/certificate.svg', params: params
get "/discobot/certificate.svg", params: params
expect(response.status).to eq(200)
expect(response.body).to include('<svg')
expect(response.body).to include(user.avatar_template.gsub('{size}', '250'))
expect(response.body).to include("<svg")
expect(response.body).to include(user.avatar_template.gsub("{size}", "250"))
expect(response.body).to include(SiteSetting.site_logo_small_url)
end
describe 'when params are missing' do
describe "when params are missing" do
it "should raise the right errors" do
params.each do |key, _|
get '/discobot/certificate.svg', params: params.except(key)
get "/discobot/certificate.svg", params: params.except(key)
expect(response.status).to eq(400)
end
end

View File

@ -3,40 +3,39 @@
RSpec.describe "Discobot welcome post" do
let(:user) { Fabricate(:user) }
before do
SiteSetting.discourse_narrative_bot_enabled = true
end
before { SiteSetting.discourse_narrative_bot_enabled = true }
context 'when discourse_narrative_bot_welcome_post_delay is 0' do
it 'should not delay the welcome post' do
context "when discourse_narrative_bot_welcome_post_delay is 0" do
it "should not delay the welcome post" do
user
expect { sign_in(user) }.to_not change { Jobs::NarrativeInit.jobs.count }
end
end
context 'when discourse_narrative_bot_welcome_post_delay is greater than 0' do
before do
SiteSetting.discourse_narrative_bot_welcome_post_delay = 5
end
context "when discourse_narrative_bot_welcome_post_delay is greater than 0" do
before { SiteSetting.discourse_narrative_bot_welcome_post_delay = 5 }
context 'when user logs in normally' do
it 'should delay the welcome post until user logs in' do
context "when user logs in normally" do
it "should delay the welcome post until user logs in" do
expect { sign_in(user) }.to change { Jobs::NarrativeInit.jobs.count }.by(1)
expect(Jobs::NarrativeInit.jobs.first["args"].first["user_id"]).to eq(user.id)
end
end
context 'when user redeems an invite' do
let!(:invite) { Fabricate(:invite, invited_by: Fabricate(:admin), email: 'testing@gmail.com') }
context "when user redeems an invite" do
let!(:invite) do
Fabricate(:invite, invited_by: Fabricate(:admin), email: "testing@gmail.com")
end
it 'should delay the welcome post until the user logs in' do
it "should delay the welcome post until the user logs in" do
expect do
put "/invites/show/#{invite.invite_key}.json", params: {
username: 'somename',
name: 'testing',
password: 'verystrongpassword',
email_token: invite.email_token
}
put "/invites/show/#{invite.invite_key}.json",
params: {
username: "somename",
name: "testing",
password: "verystrongpassword",
email_token: invite.email_token,
}
end.to change { User.count }.by(1)
expect(Jobs::NarrativeInit.jobs.first["args"].first["user_id"]).to eq(User.last.id)
@ -44,14 +43,12 @@ RSpec.describe "Discobot welcome post" do
end
end
context 'when user is staged' do
context "when user is staged" do
let(:staged_user) { Fabricate(:user, staged: true) }
before do
SiteSetting.discourse_narrative_bot_welcome_post_type = 'welcome_message'
end
before { SiteSetting.discourse_narrative_bot_welcome_post_type = "welcome_message" }
it 'should not send welcome message' do
it "should not send welcome message" do
expect { staged_user }.to_not change { Jobs::SendDefaultWelcomeMessage.jobs.count }
end
end

View File

@ -18,79 +18,76 @@ RSpec.describe User do
SiteSetting.discourse_narrative_bot_enabled = true
end
describe 'when a user is created' do
it 'should initiate the bot' do
describe "when a user is created" do
it "should initiate the bot" do
NotificationEmailer.expects(:process_notification).never
user
expected_raw = i18n_t('discourse_narrative_bot.new_user_narrative.hello.message',
username: user.username, title: SiteSetting.title
)
expected_raw =
i18n_t(
"discourse_narrative_bot.new_user_narrative.hello.message",
username: user.username,
title: SiteSetting.title,
)
expect(Post.last.raw).to include(expected_raw.chomp)
end
describe 'welcome post' do
context 'when disabled' do
before do
SiteSetting.disable_discourse_narrative_bot_welcome_post = true
end
describe "welcome post" do
context "when disabled" do
before { SiteSetting.disable_discourse_narrative_bot_welcome_post = true }
it 'should not initiate the bot' do
it "should not initiate the bot" do
expect { user }.to_not change { Post.count }
end
end
context 'with title emoji disabled' do
context "with title emoji disabled" do
before do
SiteSetting.disable_discourse_narrative_bot_welcome_post = false
SiteSetting.max_emojis_in_title = 0
end
it 'initiates the bot' do
it "initiates the bot" do
expect { user }.to change { Topic.count }.by(1)
expect(Topic.last.title).to eq(i18n_t(
'discourse_narrative_bot.new_user_narrative.hello.title'
).gsub(/:robot:/, '').strip)
expect(Topic.last.title).to eq(
i18n_t("discourse_narrative_bot.new_user_narrative.hello.title").gsub(
/:robot:/,
"",
).strip,
)
end
end
context 'when enabled' do
before do
SiteSetting.disable_discourse_narrative_bot_welcome_post = false
end
context "when enabled" do
before { SiteSetting.disable_discourse_narrative_bot_welcome_post = false }
it 'initiate the bot' do
it "initiate the bot" do
expect { user }.to change { Topic.count }.by(1)
expect(Topic.last.title).to eq(i18n_t(
'discourse_narrative_bot.new_user_narrative.hello.title'
))
expect(Topic.last.title).to eq(
i18n_t("discourse_narrative_bot.new_user_narrative.hello.title"),
)
end
describe "when send welcome message is selected" do
before do
SiteSetting.discourse_narrative_bot_welcome_post_type = 'welcome_message'
end
before { SiteSetting.discourse_narrative_bot_welcome_post_type = "welcome_message" }
it 'should send the right welcome message' do
it "should send the right welcome message" do
expect { user }.to change { Topic.count }.by(1)
expect(Topic.last.title).to eq(i18n_t(
"system_messages.welcome_user.subject_template",
site_name: SiteSetting.title
))
expect(Topic.last.title).to eq(
i18n_t("system_messages.welcome_user.subject_template", site_name: SiteSetting.title),
)
end
end
describe 'when welcome message is configured to be delayed' do
before do
SiteSetting.discourse_narrative_bot_welcome_post_delay = 100
end
describe "when welcome message is configured to be delayed" do
before { SiteSetting.discourse_narrative_bot_welcome_post_delay = 100 }
it 'should delay the welcome post until user logs in' do
it "should delay the welcome post until user logs in" do
user
expect(Jobs::NarrativeInit.jobs.count).to eq(0)
@ -99,41 +96,37 @@ RSpec.describe User do
end
end
context 'when user is staged' do
context "when user is staged" do
let(:user) { Fabricate(:user, staged: true) }
it 'should not initiate the bot' do
it "should not initiate the bot" do
expect { user }.to_not change { Post.count }
end
end
context 'when user skipped the new user tips' do
context "when user skipped the new user tips" do
let(:user) { Fabricate(:user) }
it 'should not initiate the bot' do
it "should not initiate the bot" do
SiteSetting.default_other_skip_new_user_tips = true
expect { user }.to_not change { Post.count }
end
it 'should delete the existing PM' do
it "should delete the existing PM" do
user.user_option.skip_new_user_tips = true
expect {
user.user_option.save!
}.to change { Topic.count }.by(-1)
.and not_change { UserHistory.count }
.and change { user.unread_high_priority_notifications }.by(-1)
.and change { user.notifications.count }.by(-1)
expect { user.user_option.save! }.to change { Topic.count }.by(-1).and not_change {
UserHistory.count
}.and change { user.unread_high_priority_notifications }.by(-1).and change {
user.notifications.count
}.by(-1)
end
end
context 'when user is anonymous?' do
before do
SiteSetting.allow_anonymous_posting = true
end
it 'should initiate bot for real user only' do
context "when user is anonymous?" do
before { SiteSetting.allow_anonymous_posting = true }
it "should initiate bot for real user only" do
user = Fabricate(:user, trust_level: 1)
shadow = AnonymousShadowCreator.get(user)
@ -145,21 +138,19 @@ RSpec.describe User do
context "when user's username should be ignored" do
let(:user) { Fabricate.build(:user) }
before do
SiteSetting.discourse_narrative_bot_ignored_usernames = 'discourse|test'
end
before { SiteSetting.discourse_narrative_bot_ignored_usernames = "discourse|test" }
['discourse', 'test'].each do |username|
it 'should not initiate the bot' do
%w[discourse test].each do |username|
it "should not initiate the bot" do
expect { user.update!(username: username) }.to_not change { Post.count }
end
end
end
end
describe 'when a user has been destroyed' do
describe "when a user has been destroyed" do
it "should clean up plugin's store" do
DiscourseNarrativeBot::Store.set(user.id, 'test')
DiscourseNarrativeBot::Store.set(user.id, "test")
user.destroy!
@ -167,8 +158,8 @@ RSpec.describe User do
end
end
describe '#manually_disabled_discobot?' do
it 'returns true if the user manually disabled new user tips' do
describe "#manually_disabled_discobot?" do
it "returns true if the user manually disabled new user tips" do
user.user_option.skip_new_user_tips = true
expect(user.manually_disabled_discobot?).to eq(true)

View File

@ -9,40 +9,40 @@
enabled_site_setting :presence_enabled
hide_plugin if self.respond_to?(:hide_plugin)
register_asset 'stylesheets/presence.scss'
register_asset "stylesheets/presence.scss"
after_initialize do
register_presence_channel_prefix("discourse-presence") do |channel_name|
if topic_id = channel_name[/\/discourse-presence\/reply\/(\d+)/, 1]
if topic_id = channel_name[%r{/discourse-presence/reply/(\d+)}, 1]
topic = Topic.find(topic_id)
config = PresenceChannel::Config.new
if topic.private_message?
config.allowed_user_ids = topic.allowed_users.pluck(:id)
config.allowed_group_ids = topic.allowed_groups.pluck(:group_id) + [::Group::AUTO_GROUPS[:staff]]
config.allowed_group_ids =
topic.allowed_groups.pluck(:group_id) + [::Group::AUTO_GROUPS[:staff]]
elsif secure_group_ids = topic.secure_group_ids
config.allowed_group_ids = secure_group_ids + [::Group::AUTO_GROUPS[:admins]]
else
# config.public=true would make data available to anon, so use the tl0 group instead
config.allowed_group_ids = [ ::Group::AUTO_GROUPS[:trust_level_0] ]
config.allowed_group_ids = [::Group::AUTO_GROUPS[:trust_level_0]]
end
config
elsif topic_id = channel_name[/\/discourse-presence\/whisper\/(\d+)/, 1]
elsif topic_id = channel_name[%r{/discourse-presence/whisper/(\d+)}, 1]
Topic.find(topic_id) # Just ensure it exists
PresenceChannel::Config.new(allowed_group_ids: [::Group::AUTO_GROUPS[:staff]])
elsif post_id = channel_name[/\/discourse-presence\/edit\/(\d+)/, 1]
elsif post_id = channel_name[%r{/discourse-presence/edit/(\d+)}, 1]
post = Post.find(post_id)
topic = Topic.find(post.topic_id)
config = PresenceChannel::Config.new
config.allowed_group_ids = [ ::Group::AUTO_GROUPS[:staff] ]
config.allowed_group_ids = [::Group::AUTO_GROUPS[:staff]]
# Locked and whisper posts are staff only
next config if post.locked? || post.whisper?
config.allowed_user_ids = [ post.user_id ]
config.allowed_user_ids = [post.user_id]
if topic.private_message? && post.wiki
# Ignore trust level and just publish to all allowed groups since
@ -52,14 +52,17 @@ after_initialize do
config.allowed_user_ids += topic.allowed_users.pluck(:id)
config.allowed_group_ids += topic.allowed_groups.pluck(:id)
elsif post.wiki
config.allowed_group_ids << Group::AUTO_GROUPS[:"trust_level_#{SiteSetting.min_trust_to_edit_wiki_post}"]
config.allowed_group_ids << Group::AUTO_GROUPS[
:"trust_level_#{SiteSetting.min_trust_to_edit_wiki_post}"
]
end
if !topic.private_message? && SiteSetting.trusted_users_can_edit_others?
config.allowed_group_ids << Group::AUTO_GROUPS[:trust_level_4]
end
if SiteSetting.enable_category_group_moderation? && group_id = topic.category&.reviewable_by_group_id
if SiteSetting.enable_category_group_moderation? &&
group_id = topic.category&.reviewable_by_group_id
config.allowed_group_ids << group_id
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.describe "discourse-presence" do
describe 'PresenceChannel configuration' do
describe "PresenceChannel configuration" do
fab!(:user) { Fabricate(:user) }
fab!(:user2) { Fabricate(:user) }
fab!(:admin) { Fabricate(:admin) }
@ -16,25 +16,21 @@ RSpec.describe "discourse-presence" do
fab!(:private_topic) { Fabricate(:topic, category: category) }
fab!(:public_topic) { Fabricate(:topic, first_post: Fabricate(:post)) }
fab!(:private_message) do
Fabricate(:private_message_topic,
allowed_groups: [group]
)
end
fab!(:private_message) { Fabricate(:private_message_topic, allowed_groups: [group]) }
before { PresenceChannel.clear_all! }
it 'handles invalid topic IDs' do
expect do
PresenceChannel.new('/discourse-presence/reply/-999').config
end.to raise_error(PresenceChannel::NotFound)
it "handles invalid topic IDs" do
expect do PresenceChannel.new("/discourse-presence/reply/-999").config end.to raise_error(
PresenceChannel::NotFound,
)
expect do
PresenceChannel.new('/discourse-presence/reply/blah').config
end.to raise_error(PresenceChannel::NotFound)
expect do PresenceChannel.new("/discourse-presence/reply/blah").config end.to raise_error(
PresenceChannel::NotFound,
)
end
it 'handles deleted topics' do
it "handles deleted topics" do
public_topic.trash!
expect do
@ -50,7 +46,7 @@ RSpec.describe "discourse-presence" do
end.to raise_error(PresenceChannel::NotFound)
end
it 'handles secure category permissions for reply' do
it "handles secure category permissions for reply" do
c = PresenceChannel.new("/discourse-presence/reply/#{private_topic.id}")
expect(c.can_view?(user_id: user.id)).to eq(true)
expect(c.can_enter?(user_id: user.id)).to eq(true)
@ -62,14 +58,14 @@ RSpec.describe "discourse-presence" do
expect(c.can_enter?(user_id: user.id)).to eq(false)
end
it 'handles secure category permissions for edit' do
it "handles secure category permissions for edit" do
p = Fabricate(:post, topic: private_topic, user: private_topic.user)
c = PresenceChannel.new("/discourse-presence/edit/#{p.id}")
expect(c.can_view?(user_id: user.id)).to eq(false)
expect(c.can_view?(user_id: private_topic.user.id)).to eq(true)
end
it 'handles category moderators for edit' do
it "handles category moderators for edit" do
SiteSetting.trusted_users_can_edit_others = false
p = Fabricate(:post, topic: private_topic, user: private_topic.user)
@ -83,25 +79,25 @@ RSpec.describe "discourse-presence" do
expect(c.config.allowed_group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff], group.id)
end
it 'handles permissions for a public topic' do
it "handles permissions for a public topic" do
c = PresenceChannel.new("/discourse-presence/reply/#{public_topic.id}")
expect(c.config.public).to eq(false)
expect(c.config.allowed_group_ids).to contain_exactly(::Group::AUTO_GROUPS[:trust_level_0])
end
it 'handles permissions for secure category topics' do
it "handles permissions for secure category topics" do
c = PresenceChannel.new("/discourse-presence/reply/#{private_topic.id}")
expect(c.config.public).to eq(false)
expect(c.config.allowed_group_ids).to contain_exactly(group.id, Group::AUTO_GROUPS[:admins])
expect(c.config.allowed_user_ids).to eq(nil)
end
it 'handles permissions for private messages' do
it "handles permissions for private messages" do
c = PresenceChannel.new("/discourse-presence/reply/#{private_message.id}")
expect(c.config.public).to eq(false)
expect(c.config.allowed_group_ids).to contain_exactly(group.id, Group::AUTO_GROUPS[:staff])
expect(c.config.allowed_user_ids).to contain_exactly(
*private_message.topic_allowed_users.pluck(:user_id)
*private_message.topic_allowed_users.pluck(:user_id),
)
end
@ -112,7 +108,7 @@ RSpec.describe "discourse-presence" do
expect(c.config.allowed_user_ids).to eq(nil)
end
it 'only allows staff when editing whispers' do
it "only allows staff when editing whispers" do
p = Fabricate(:whisper, topic: public_topic, user: admin)
c = PresenceChannel.new("/discourse-presence/edit/#{p.id}")
expect(c.config.public).to eq(false)
@ -120,7 +116,7 @@ RSpec.describe "discourse-presence" do
expect(c.config.allowed_user_ids).to eq(nil)
end
it 'only allows staff when editing a locked post' do
it "only allows staff when editing a locked post" do
p = Fabricate(:post, topic: public_topic, user: admin, locked_by_id: Discourse.system_user.id)
c = PresenceChannel.new("/discourse-presence/edit/#{p.id}")
expect(c.config.public).to eq(false)
@ -134,7 +130,7 @@ RSpec.describe "discourse-presence" do
expect(c.config.public).to eq(false)
expect(c.config.allowed_group_ids).to contain_exactly(
Group::AUTO_GROUPS[:trust_level_4],
Group::AUTO_GROUPS[:staff]
Group::AUTO_GROUPS[:staff],
)
expect(c.config.allowed_user_ids).to contain_exactly(user.id)
end
@ -145,9 +141,7 @@ RSpec.describe "discourse-presence" do
p = Fabricate(:post, topic: public_topic, user: user)
c = PresenceChannel.new("/discourse-presence/edit/#{p.id}")
expect(c.config.public).to eq(false)
expect(c.config.allowed_group_ids).to contain_exactly(
Group::AUTO_GROUPS[:staff]
)
expect(c.config.allowed_group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff])
expect(c.config.allowed_user_ids).to contain_exactly(user.id)
end
@ -160,7 +154,7 @@ RSpec.describe "discourse-presence" do
expect(c.config.public).to eq(false)
expect(c.config.allowed_group_ids).to contain_exactly(
Group::AUTO_GROUPS[:staff],
Group::AUTO_GROUPS[:trust_level_1]
Group::AUTO_GROUPS[:trust_level_1],
)
expect(c.config.allowed_user_ids).to contain_exactly(user.id)
end
@ -170,9 +164,7 @@ RSpec.describe "discourse-presence" do
c = PresenceChannel.new("/discourse-presence/edit/#{post.id}")
expect(c.config.public).to eq(false)
expect(c.config.allowed_group_ids).to contain_exactly(
Group::AUTO_GROUPS[:staff]
)
expect(c.config.allowed_group_ids).to contain_exactly(Group::AUTO_GROUPS[:staff])
expect(c.config.allowed_user_ids).to contain_exactly(user.id)
end
@ -183,9 +175,12 @@ RSpec.describe "discourse-presence" do
expect(c.config.public).to eq(false)
expect(c.config.allowed_group_ids).to contain_exactly(
Group::AUTO_GROUPS[:staff],
*private_message.allowed_groups.pluck(:id)
*private_message.allowed_groups.pluck(:id),
)
expect(c.config.allowed_user_ids).to contain_exactly(
user.id,
*private_message.allowed_users.pluck(:id),
)
expect(c.config.allowed_user_ids).to contain_exactly(user.id, *private_message.allowed_users.pluck(:id))
end
end
end

View File

@ -21,11 +21,10 @@ class Onebox::Engine::YoutubeOnebox
alias_method :yt_onebox_to_html, :to_html
def to_html
if video_id && !params['list']
size_restricted = [params['width'], params['height']].any?
video_width = (params['width'] && params['width'].to_i <= 695) ? params['width'] : 690 # embed width
video_height = (params['height'] && params['height'].to_i <= 500) ? params['height'] : 388 # embed height
if video_id && !params["list"]
size_restricted = [params["width"], params["height"]].any?
video_width = (params["width"] && params["width"].to_i <= 695) ? params["width"] : 690 # embed width
video_height = (params["height"] && params["height"].to_i <= 500) ? params["height"] : 388 # embed height
size_tags = ["width=\"#{video_width}\"", "height=\"#{video_height}\""]
result = parse_embed_response
@ -40,12 +39,12 @@ class Onebox::Engine::YoutubeOnebox
<div class="onebox lazyYT lazyYT-container"
data-youtube-id="#{video_id}"
data-youtube-title="#{escaped_title}"
#{size_restricted ? size_tags.map { |t| "data-#{t}" }.join(' ') : ""}
#{size_restricted ? size_tags.map { |t| "data-#{t}" }.join(" ") : ""}
data-parameters="#{embed_params}">
<a href="https://www.youtube.com/watch?v=#{video_id}" target="_blank">
<img class="ytp-thumbnail-image"
src="#{thumbnail_url}"
#{size_restricted ? size_tags.join(' ') : ""}
#{size_restricted ? size_tags.join(" ") : ""}
title="#{escaped_title}">
</a>
</div>
@ -54,23 +53,22 @@ class Onebox::Engine::YoutubeOnebox
yt_onebox_to_html
end
end
end
after_initialize do
on(:reduce_cooked) do |fragment|
fragment.css(".lazyYT").each do |yt|
begin
youtube_id = yt["data-youtube-id"]
parameters = yt["data-parameters"]
uri = URI("https://www.youtube.com/embed/#{youtube_id}?autoplay=1&#{parameters}")
yt.replace %{<p><a href="#{uri.to_s}">https://#{uri.host}#{uri.path}</a></p>}
rescue URI::InvalidURIError
# remove any invalid/weird URIs
yt.remove
fragment
.css(".lazyYT")
.each do |yt|
begin
youtube_id = yt["data-youtube-id"]
parameters = yt["data-parameters"]
uri = URI("https://www.youtube.com/embed/#{youtube_id}?autoplay=1&#{parameters}")
yt.replace %{<p><a href="#{uri.to_s}">https://#{uri.host}#{uri.path}</a></p>}
rescue URI::InvalidURIError
# remove any invalid/weird URIs
yt.remove
end
end
end
end
end

View File

@ -3,7 +3,7 @@
class DiscoursePoll::PollsController < ::ApplicationController
requires_plugin DiscoursePoll::PLUGIN_NAME
before_action :ensure_logged_in, except: [:voters, :grouped_poll_results]
before_action :ensure_logged_in, except: %i[voters grouped_poll_results]
def vote
post_id = params.require(:post_id)
@ -63,8 +63,14 @@ class DiscoursePoll::PollsController < ::ApplicationController
begin
render json: {
grouped_results: DiscoursePoll::Poll.grouped_poll_results(current_user, post_id, poll_name, user_field_name)
}
grouped_results:
DiscoursePoll::Poll.grouped_poll_results(
current_user,
post_id,
poll_name,
user_field_name,
),
}
rescue DiscoursePoll::Error => e
render_json_error e.message
end

View File

@ -9,33 +9,15 @@ class Poll < ActiveRecord::Base
has_many :poll_options, -> { order(:id) }, dependent: :destroy
has_many :poll_votes
enum type: {
regular: 0,
multiple: 1,
number: 2,
}, _scopes: false
enum type: { regular: 0, multiple: 1, number: 2 }, _scopes: false
enum status: {
open: 0,
closed: 1,
}, _scopes: false
enum status: { open: 0, closed: 1 }, _scopes: false
enum results: {
always: 0,
on_vote: 1,
on_close: 2,
staff_only: 3,
}, _scopes: false
enum results: { always: 0, on_vote: 1, on_close: 2, staff_only: 3 }, _scopes: false
enum visibility: {
secret: 0,
everyone: 1,
}, _scopes: false
enum visibility: { secret: 0, everyone: 1 }, _scopes: false
enum chart_type: {
bar: 0,
pie: 1
}, _scopes: false
enum chart_type: { bar: 0, pie: 1 }, _scopes: false
validates :min, numericality: { allow_nil: true, only_integer: true, greater_than_or_equal_to: 0 }
validates :max, numericality: { allow_nil: true, only_integer: true, greater_than: 0 }

View File

@ -48,13 +48,15 @@ class PollSerializer < ApplicationSerializer
PollOptionSerializer.new(
option,
root: false,
scope: { can_see_results: can_see_results }
scope: {
can_see_results: can_see_results,
},
).as_json
end
end
def voters
object.poll_votes.count('DISTINCT user_id') + object.anonymous_voters.to_i
object.poll_votes.count("DISTINCT user_id") + object.anonymous_voters.to_i
end
def close
@ -72,5 +74,4 @@ class PollSerializer < ApplicationSerializer
def include_preloaded_voters?
object.can_see_voters?(scope.user)
end
end

View File

@ -1,31 +1,33 @@
# frozen_string_literal: true
class RenameTotalVotesToVoters < ActiveRecord::Migration[4.2]
def up
PostCustomField.where(name: "polls").find_each do |pcf|
polls = ::JSON.parse(pcf.value)
polls.each_value do |poll|
next if poll.has_key?("voters")
poll["voters"] = poll["total_votes"]
poll.delete("total_votes")
PostCustomField
.where(name: "polls")
.find_each do |pcf|
polls = ::JSON.parse(pcf.value)
polls.each_value do |poll|
next if poll.has_key?("voters")
poll["voters"] = poll["total_votes"]
poll.delete("total_votes")
end
pcf.value = polls.to_json
pcf.save
end
pcf.value = polls.to_json
pcf.save
end
end
def down
PostCustomField.where(name: "polls").find_each do |pcf|
polls = ::JSON.parse(pcf.value)
polls.each_value do |poll|
next if poll.has_key?("total_votes")
poll["total_votes"] = poll["voters"]
poll.delete("voters")
PostCustomField
.where(name: "polls")
.find_each do |pcf|
polls = ::JSON.parse(pcf.value)
polls.each_value do |poll|
next if poll.has_key?("total_votes")
poll["total_votes"] = poll["voters"]
poll.delete("voters")
end
pcf.value = polls.to_json
pcf.save
end
pcf.value = polls.to_json
pcf.save
end
end
end

View File

@ -1,22 +1,27 @@
# frozen_string_literal: true
class MergePollsVotes < ActiveRecord::Migration[4.2]
def up
PostCustomField.where(name: "polls").order(:post_id).pluck(:post_id).each do |post_id|
polls_votes = {}
PostCustomField.where(post_id: post_id).where("name LIKE 'polls-votes-%'").find_each do |pcf|
user_id = pcf.name["polls-votes-".size..-1]
polls_votes["#{user_id}"] = ::JSON.parse(pcf.value || "{}")
end
PostCustomField
.where(name: "polls")
.order(:post_id)
.pluck(:post_id)
.each do |post_id|
polls_votes = {}
PostCustomField
.where(post_id: post_id)
.where("name LIKE 'polls-votes-%'")
.find_each do |pcf|
user_id = pcf.name["polls-votes-".size..-1]
polls_votes["#{user_id}"] = ::JSON.parse(pcf.value || "{}")
end
pcf = PostCustomField.find_or_create_by(name: "polls-votes", post_id: post_id)
pcf.value = ::JSON.parse(pcf.value || "{}").merge(polls_votes).to_json
pcf.save
end
pcf = PostCustomField.find_or_create_by(name: "polls-votes", post_id: post_id)
pcf.value = ::JSON.parse(pcf.value || "{}").merge(polls_votes).to_json
pcf.save
end
end
def down
end
end

View File

@ -1,20 +1,19 @@
# frozen_string_literal: true
class ClosePollsInClosedTopics < ActiveRecord::Migration[4.2]
def up
PostCustomField.joins(post: :topic)
PostCustomField
.joins(post: :topic)
.where("post_custom_fields.name = 'polls'")
.where("topics.closed")
.find_each do |pcf|
polls = ::JSON.parse(pcf.value || "{}")
polls.values.each { |poll| poll["status"] = "closed" }
pcf.value = polls.to_json
pcf.save
end
polls = ::JSON.parse(pcf.value || "{}")
polls.values.each { |poll| poll["status"] = "closed" }
pcf.value = polls.to_json
pcf.save
end
end
def down
end
end

View File

@ -17,7 +17,7 @@ class CreatePollsTables < ActiveRecord::Migration[5.2]
t.timestamps
end
add_index :polls, [:post_id, :name], unique: true
add_index :polls, %i[post_id name], unique: true
create_table :poll_options do |t|
t.references :poll, index: true, foreign_key: true
@ -27,7 +27,7 @@ class CreatePollsTables < ActiveRecord::Migration[5.2]
t.timestamps
end
add_index :poll_options, [:poll_id, :digest], unique: true
add_index :poll_options, %i[poll_id digest], unique: true
create_table :poll_votes, id: false do |t|
t.references :poll, foreign_key: true
@ -36,6 +36,6 @@ class CreatePollsTables < ActiveRecord::Migration[5.2]
t.timestamps
end
add_index :poll_votes, [:poll_id, :poll_option_id, :user_id], unique: true
add_index :poll_votes, %i[poll_id poll_option_id user_id], unique: true
end
end

View File

@ -5,11 +5,7 @@ class MigratePollsData < ActiveRecord::Migration[5.2]
PG::Connection.escape_string(text)
end
POLL_TYPES ||= {
"regular" => 0,
"multiple" => 1,
"number" => 2,
}
POLL_TYPES ||= { "regular" => 0, "multiple" => 1, "number" => 2 }
PG_INTEGER_MAX ||= 2_147_483_647
@ -61,43 +57,59 @@ class MigratePollsData < ActiveRecord::Migration[5.2]
ORDER BY polls.post_id
SQL
DB.query(sql).each do |r|
# for some reasons, polls or votes might be an array
r.polls = r.polls[0] if Array === r.polls && r.polls.size > 0
r.votes = r.votes[0] if Array === r.votes && r.votes.size > 0
DB
.query(sql)
.each do |r|
# for some reasons, polls or votes might be an array
r.polls = r.polls[0] if Array === r.polls && r.polls.size > 0
r.votes = r.votes[0] if Array === r.votes && r.votes.size > 0
existing_user_ids = User.where(id: r.votes.keys).pluck(:id).to_set
existing_user_ids = User.where(id: r.votes.keys).pluck(:id).to_set
# Poll votes are stored in a JSON object with the following hierarchy
# user_id -> poll_name -> options
# Since we're iterating over polls, we need to change the hierarchy to
# poll_name -> user_id -> options
# Poll votes are stored in a JSON object with the following hierarchy
# user_id -> poll_name -> options
# Since we're iterating over polls, we need to change the hierarchy to
# poll_name -> user_id -> options
votes = {}
r.votes.each do |user_id, user_votes|
# don't migrate votes from deleted/non-existing users
next unless existing_user_ids.include?(user_id.to_i)
votes = {}
r.votes.each do |user_id, user_votes|
# don't migrate votes from deleted/non-existing users
next unless existing_user_ids.include?(user_id.to_i)
user_votes.each do |poll_name, options|
votes[poll_name] ||= {}
votes[poll_name][user_id] = options
user_votes.each do |poll_name, options|
votes[poll_name] ||= {}
votes[poll_name][user_id] = options
end
end
end
r.polls.values.each do |poll|
name = escape(poll["name"].presence || "poll")
type = POLL_TYPES[(poll["type"].presence || "")[/(regular|multiple|number)/, 1] || "regular"]
status = poll["status"] == "open" ? 0 : 1
visibility = poll["public"] == "true" ? 1 : 0
close_at = (Time.zone.parse(poll["close"]) rescue nil)
min = poll["min"].to_i.clamp(0, PG_INTEGER_MAX)
max = poll["max"].to_i.clamp(0, PG_INTEGER_MAX)
step = poll["step"].to_i.clamp(0, max)
anonymous_voters = poll["anonymous_voters"].to_i.clamp(0, PG_INTEGER_MAX)
r.polls.values.each do |poll|
name = escape(poll["name"].presence || "poll")
type =
POLL_TYPES[(poll["type"].presence || "")[/(regular|multiple|number)/, 1] || "regular"]
status = poll["status"] == "open" ? 0 : 1
visibility = poll["public"] == "true" ? 1 : 0
close_at =
(
begin
Time.zone.parse(poll["close"])
rescue StandardError
nil
end
)
min = poll["min"].to_i.clamp(0, PG_INTEGER_MAX)
max = poll["max"].to_i.clamp(0, PG_INTEGER_MAX)
step = poll["step"].to_i.clamp(0, max)
anonymous_voters = poll["anonymous_voters"].to_i.clamp(0, PG_INTEGER_MAX)
next if DB.query_single("SELECT COUNT(*) FROM polls WHERE post_id = ? AND name = ? LIMIT 1", r.post_id, name).first > 0
if DB.query_single(
"SELECT COUNT(*) FROM polls WHERE post_id = ? AND name = ? LIMIT 1",
r.post_id,
name,
).first > 0
next
end
poll_id = execute(<<~SQL
poll_id = execute(<<~SQL)[0]["id"]
INSERT INTO polls (
post_id,
name,
@ -126,38 +138,41 @@ class MigratePollsData < ActiveRecord::Migration[5.2]
'#{r.updated_at}'
) RETURNING id
SQL
)[0]["id"]
option_ids = Hash[*DB.query_single(<<~SQL
option_ids = Hash[*DB.query_single(<<~SQL)]
INSERT INTO poll_options
(poll_id, digest, html, anonymous_votes, created_at, updated_at)
VALUES
#{poll["options"].map { |option|
"(#{poll_id}, '#{escape(option["id"])}', '#{escape(option["html"].strip)}', #{option["anonymous_votes"].to_i}, '#{r.created_at}', '#{r.updated_at}')" }.join(",")
}
#{
poll["options"]
.map do |option|
"(#{poll_id}, '#{escape(option["id"])}', '#{escape(option["html"].strip)}', #{option["anonymous_votes"].to_i}, '#{r.created_at}', '#{r.updated_at}')"
end
.join(",")
}
RETURNING digest, id
SQL
)]
if votes[name].present?
poll_votes = votes[name].map do |user_id, options|
options
.select { |o| option_ids.has_key?(o) }
.map { |o| "(#{poll_id}, #{option_ids[o]}, #{user_id.to_i}, '#{r.created_at}', '#{r.updated_at}')" }
end
if votes[name].present?
poll_votes =
votes[name].map do |user_id, options|
options
.select { |o| option_ids.has_key?(o) }
.map do |o|
"(#{poll_id}, #{option_ids[o]}, #{user_id.to_i}, '#{r.created_at}', '#{r.updated_at}')"
end
end
poll_votes.flatten!
poll_votes.uniq!
poll_votes.flatten!
poll_votes.uniq!
if poll_votes.present?
execute <<~SQL
execute <<~SQL if poll_votes.present?
INSERT INTO poll_votes (poll_id, poll_option_id, user_id, created_at, updated_at)
VALUES #{poll_votes.join(",")}
SQL
end
end
end
end
execute <<~SQL
INSERT INTO post_custom_fields (name, value, post_id, created_at, updated_at)

View File

@ -1,14 +1,9 @@
# frozen_string_literal: true
module Jobs
class ClosePoll < ::Jobs::Base
def execute(args)
%i{
post_id
poll_name
}.each do |key|
%i[post_id poll_name].each do |key|
raise Discourse::InvalidParameters.new(key) if args[key].blank?
end
@ -17,10 +12,8 @@ module Jobs
args[:post_id],
args[:poll_name],
"closed",
false
false,
)
end
end
end

View File

@ -4,38 +4,42 @@ class DiscoursePoll::Poll
def self.vote(user, post_id, poll_name, options)
poll_id = nil
serialized_poll = DiscoursePoll::Poll.change_vote(user, post_id, poll_name) do |poll|
poll_id = poll.id
# remove options that aren't available in the poll
available_options = poll.poll_options.map { |o| o.digest }.to_set
options.select! { |o| available_options.include?(o) }
serialized_poll =
DiscoursePoll::Poll.change_vote(user, post_id, poll_name) do |poll|
poll_id = poll.id
# remove options that aren't available in the poll
available_options = poll.poll_options.map { |o| o.digest }.to_set
options.select! { |o| available_options.include?(o) }
raise DiscoursePoll::Error.new I18n.t("poll.requires_at_least_1_valid_option") if options.empty?
if options.empty?
raise DiscoursePoll::Error.new I18n.t("poll.requires_at_least_1_valid_option")
end
new_option_ids = poll.poll_options.each_with_object([]) do |option, obj|
obj << option.id if options.include?(option.digest)
end
new_option_ids =
poll
.poll_options
.each_with_object([]) do |option, obj|
obj << option.id if options.include?(option.digest)
end
self.validate_votes!(poll, new_option_ids)
self.validate_votes!(poll, new_option_ids)
old_option_ids = poll.poll_options.each_with_object([]) do |option, obj|
if option.poll_votes.where(user_id: user.id).exists?
obj << option.id
old_option_ids =
poll
.poll_options
.each_with_object([]) do |option, obj|
obj << option.id if option.poll_votes.where(user_id: user.id).exists?
end
# remove non-selected votes
PollVote.where(poll: poll, user: user).where.not(poll_option_id: new_option_ids).delete_all
# create missing votes
(new_option_ids - old_option_ids).each do |option_id|
PollVote.create!(poll: poll, user: user, poll_option_id: option_id)
end
end
# remove non-selected votes
PollVote
.where(poll: poll, user: user)
.where.not(poll_option_id: new_option_ids)
.delete_all
# create missing votes
(new_option_ids - old_option_ids).each do |option_id|
PollVote.create!(poll: poll, user: user, poll_option_id: option_id)
end
end
# Ensure consistency here as we do not have a unique index to limit the
# number of votes per the poll's configuration.
is_multiple = serialized_poll[:type] == "multiple"
@ -79,20 +83,26 @@ class DiscoursePoll::Poll
# topic must not be archived
if post.topic&.archived
raise DiscoursePoll::Error.new I18n.t("poll.topic_must_be_open_to_toggle_status") if raise_errors
if raise_errors
raise DiscoursePoll::Error.new I18n.t("poll.topic_must_be_open_to_toggle_status")
end
return
end
# either staff member or OP
unless post.user_id == user&.id || user&.staff?
raise DiscoursePoll::Error.new I18n.t("poll.only_staff_or_op_can_toggle_status") if raise_errors
if raise_errors
raise DiscoursePoll::Error.new I18n.t("poll.only_staff_or_op_can_toggle_status")
end
return
end
poll = Poll.find_by(post_id: post_id, name: poll_name)
if !poll
raise DiscoursePoll::Error.new I18n.t("poll.no_poll_with_this_name", name: poll_name) if raise_errors
if raise_errors
raise DiscoursePoll::Error.new I18n.t("poll.no_poll_with_this_name", name: poll_name)
end
return
end
@ -110,7 +120,7 @@ class DiscoursePoll::Poll
def self.serialized_voters(poll, opts = {})
limit = (opts["limit"] || 25).to_i
limit = 0 if limit < 0
limit = 0 if limit < 0
limit = 50 if limit > 50
page = (opts["page"] || 1).to_i
@ -121,13 +131,14 @@ class DiscoursePoll::Poll
option_digest = opts["option_id"].to_s
if poll.number?
user_ids = PollVote
.where(poll: poll)
.group(:user_id)
.order("MIN(created_at)")
.offset(offset)
.limit(limit)
.pluck(:user_id)
user_ids =
PollVote
.where(poll: poll)
.group(:user_id)
.order("MIN(created_at)")
.offset(offset)
.limit(limit)
.pluck(:user_id)
result = User.where(id: user_ids).map { |u| UserNameSerializer.new(u).serializable_hash }
elsif option_digest.present?
@ -135,13 +146,14 @@ class DiscoursePoll::Poll
raise Discourse::InvalidParameters.new(:option_id) unless poll_option
user_ids = PollVote
.where(poll: poll, poll_option: poll_option)
.group(:user_id)
.order("MIN(created_at)")
.offset(offset)
.limit(limit)
.pluck(:user_id)
user_ids =
PollVote
.where(poll: poll, poll_option: poll_option)
.group(:user_id)
.order("MIN(created_at)")
.offset(offset)
.limit(limit)
.pluck(:user_id)
user_hashes = User.where(id: user_ids).map { |u| UserNameSerializer.new(u).serializable_hash }
@ -163,10 +175,11 @@ class DiscoursePoll::Poll
user_ids = votes.map(&:user_id).uniq
user_hashes = User
.where(id: user_ids)
.map { |u| [u.id, UserNameSerializer.new(u).serializable_hash] }
.to_h
user_hashes =
User
.where(id: user_ids)
.map { |u| [u.id, UserNameSerializer.new(u).serializable_hash] }
.to_h
result = {}
votes.each do |v|
@ -186,10 +199,13 @@ class DiscoursePoll::Poll
def self.grouped_poll_results(user, post_id, poll_name, user_field_name)
raise Discourse::InvalidParameters.new(:post_id) if !Post.where(id: post_id).exists?
poll = Poll.includes(:poll_options).includes(:poll_votes).find_by(post_id: post_id, name: poll_name)
poll =
Poll.includes(:poll_options).includes(:poll_votes).find_by(post_id: post_id, name: poll_name)
raise Discourse::InvalidParameters.new(:poll_name) unless poll
raise Discourse::InvalidParameters.new(:user_field_name) unless SiteSetting.poll_groupable_user_fields.split('|').include?(user_field_name)
unless SiteSetting.poll_groupable_user_fields.split("|").include?(user_field_name)
raise Discourse::InvalidParameters.new(:user_field_name)
end
poll_votes = poll.poll_votes
@ -199,7 +215,11 @@ class DiscoursePoll::Poll
end
user_ids = poll_votes.map(&:user_id).uniq
user_fields = UserCustomField.where(user_id: user_ids, name: transform_for_user_field_override(user_field_name))
user_fields =
UserCustomField.where(
user_id: user_ids,
name: transform_for_user_field_override(user_field_name),
)
user_field_map = {}
user_fields.each do |f|
@ -207,78 +227,80 @@ class DiscoursePoll::Poll
user_field_map[f.user_id] = f.value
end
votes_with_field = poll_votes.map do |vote|
v = vote.attributes
v[:field_value] = user_field_map[vote.user_id]
v
end
votes_with_field =
poll_votes.map do |vote|
v = vote.attributes
v[:field_value] = user_field_map[vote.user_id]
v
end
chart_data = []
votes_with_field.group_by { |vote| vote[:field_value] }.each do |field_answer, votes|
grouped_selected_options = {}
votes_with_field
.group_by { |vote| vote[:field_value] }
.each do |field_answer, votes|
grouped_selected_options = {}
# Create all the options with 0 votes. This ensures all the charts will have the same order of options, and same colors per option.
poll_options.each do |id, option|
grouped_selected_options[id] = {
digest: option[:digest],
html: option[:html],
votes: 0
}
# Create all the options with 0 votes. This ensures all the charts will have the same order of options, and same colors per option.
poll_options.each do |id, option|
grouped_selected_options[id] = { digest: option[:digest], html: option[:html], votes: 0 }
end
# Now go back and update the vote counts. Using hashes so we dont have n^2
votes
.group_by { |v| v["poll_option_id"] }
.each do |option_id, votes_for_option|
grouped_selected_options[option_id.to_s][:votes] = votes_for_option.length
end
group_label = field_answer ? field_answer.titleize : I18n.t("poll.user_field.no_data")
chart_data << { group: group_label, options: grouped_selected_options.values }
end
# Now go back and update the vote counts. Using hashes so we dont have n^2
votes.group_by { |v| v["poll_option_id"] }.each do |option_id, votes_for_option|
grouped_selected_options[option_id.to_s][:votes] = votes_for_option.length
end
group_label = field_answer ? field_answer.titleize : I18n.t("poll.user_field.no_data")
chart_data << { group: group_label, options: grouped_selected_options.values }
end
chart_data
end
def self.schedule_jobs(post)
Poll.where(post: post).find_each do |poll|
job_args = {
post_id: post.id,
poll_name: poll.name
}
Poll
.where(post: post)
.find_each do |poll|
job_args = { post_id: post.id, poll_name: poll.name }
Jobs.cancel_scheduled_job(:close_poll, job_args)
Jobs.cancel_scheduled_job(:close_poll, job_args)
if poll.open? && poll.close_at && poll.close_at > Time.zone.now
Jobs.enqueue_at(poll.close_at, :close_poll, job_args)
if poll.open? && poll.close_at && poll.close_at > Time.zone.now
Jobs.enqueue_at(poll.close_at, :close_poll, job_args)
end
end
end
end
def self.create!(post_id, poll)
close_at = begin
Time.zone.parse(poll["close"] || '')
rescue ArgumentError
end
close_at =
begin
Time.zone.parse(poll["close"] || "")
rescue ArgumentError
end
created_poll = Poll.create!(
post_id: post_id,
name: poll["name"].presence || "poll",
close_at: close_at,
type: poll["type"].presence || "regular",
status: poll["status"].presence || "open",
visibility: poll["public"] == "true" ? "everyone" : "secret",
title: poll["title"],
results: poll["results"].presence || "always",
min: poll["min"],
max: poll["max"],
step: poll["step"],
chart_type: poll["charttype"] || "bar",
groups: poll["groups"]
)
created_poll =
Poll.create!(
post_id: post_id,
name: poll["name"].presence || "poll",
close_at: close_at,
type: poll["type"].presence || "regular",
status: poll["status"].presence || "open",
visibility: poll["public"] == "true" ? "everyone" : "secret",
title: poll["title"],
results: poll["results"].presence || "always",
min: poll["min"],
max: poll["max"],
step: poll["step"],
chart_type: poll["charttype"] || "bar",
groups: poll["groups"],
)
poll["options"].each do |option|
PollOption.create!(
poll: created_poll,
digest: option["id"].presence,
html: option["html"].presence&.strip
html: option["html"].presence&.strip,
)
end
end
@ -286,33 +308,38 @@ class DiscoursePoll::Poll
def self.extract(raw, topic_id, user_id = nil)
# TODO: we should fix the callback mess so that the cooked version is available
# in the validators instead of cooking twice
raw = raw.sub(/\[quote.+\/quote\]/m, '')
raw = raw.sub(%r{\[quote.+/quote\]}m, "")
cooked = PrettyText.cook(raw, topic_id: topic_id, user_id: user_id)
Nokogiri::HTML5(cooked).css("div.poll").map do |p|
poll = { "options" => [], "name" => DiscoursePoll::DEFAULT_POLL_NAME }
Nokogiri
.HTML5(cooked)
.css("div.poll")
.map do |p|
poll = { "options" => [], "name" => DiscoursePoll::DEFAULT_POLL_NAME }
# attributes
p.attributes.values.each do |attribute|
if attribute.name.start_with?(DiscoursePoll::DATA_PREFIX)
poll[attribute.name[DiscoursePoll::DATA_PREFIX.length..-1]] = CGI.escapeHTML(attribute.value || "")
# attributes
p.attributes.values.each do |attribute|
if attribute.name.start_with?(DiscoursePoll::DATA_PREFIX)
poll[attribute.name[DiscoursePoll::DATA_PREFIX.length..-1]] = CGI.escapeHTML(
attribute.value || "",
)
end
end
end
# options
p.css("li[#{DiscoursePoll::DATA_PREFIX}option-id]").each do |o|
option_id = o.attributes[DiscoursePoll::DATA_PREFIX + "option-id"].value.to_s
poll["options"] << { "id" => option_id, "html" => o.inner_html.strip }
end
# options
p
.css("li[#{DiscoursePoll::DATA_PREFIX}option-id]")
.each do |o|
option_id = o.attributes[DiscoursePoll::DATA_PREFIX + "option-id"].value.to_s
poll["options"] << { "id" => option_id, "html" => o.inner_html.strip }
end
# title
title_element = p.css(".poll-title").first
if title_element
poll["title"] = title_element.inner_html.strip
end
# title
title_element = p.css(".poll-title").first
poll["title"] = title_element.inner_html.strip if title_element
poll
end
poll
end
end
def self.validate_votes!(poll, options)
@ -320,15 +347,9 @@ class DiscoursePoll::Poll
if poll.multiple?
if poll.min && (num_of_options < poll.min)
raise DiscoursePoll::Error.new(I18n.t(
"poll.min_vote_per_user",
count: poll.min
))
raise DiscoursePoll::Error.new(I18n.t("poll.min_vote_per_user", count: poll.min))
elsif poll.max && (num_of_options > poll.max)
raise DiscoursePoll::Error.new(I18n.t(
"poll.max_vote_per_user",
count: poll.max
))
raise DiscoursePoll::Error.new(I18n.t("poll.max_vote_per_user", count: poll.max))
end
elsif num_of_options > 1
raise DiscoursePoll::Error.new(I18n.t("poll.one_vote_per_user"))
@ -341,9 +362,7 @@ class DiscoursePoll::Poll
post = Post.find_by(id: post_id)
# post must not be deleted
if post.nil? || post.trashed?
raise DiscoursePoll::Error.new I18n.t("poll.post_is_deleted")
end
raise DiscoursePoll::Error.new I18n.t("poll.post_is_deleted") if post.nil? || post.trashed?
# topic must not be archived
if post.topic&.archived
@ -358,7 +377,9 @@ class DiscoursePoll::Poll
poll = Poll.includes(:poll_options).find_by(post_id: post_id, name: poll_name)
raise DiscoursePoll::Error.new I18n.t("poll.no_poll_with_this_name", name: poll_name) unless poll
unless poll
raise DiscoursePoll::Error.new I18n.t("poll.no_poll_with_this_name", name: poll_name)
end
raise DiscoursePoll::Error.new I18n.t("poll.poll_must_be_open_to_vote") if poll.is_closed?
if poll.groups

View File

@ -2,8 +2,7 @@
module DiscoursePoll
class PollsUpdater
POLL_ATTRIBUTES ||= %w{close_at max min results status step type visibility title groups}
POLL_ATTRIBUTES ||= %w[close_at max min results status step type visibility title groups]
def self.update(post, polls)
::Poll.transaction do
@ -24,64 +23,81 @@ module DiscoursePoll
# create polls
if created_poll_names.present?
has_changed = true
polls.slice(*created_poll_names).values.each do |poll|
Poll.create!(post.id, poll)
end
polls.slice(*created_poll_names).values.each { |poll| Poll.create!(post.id, poll) }
end
# update polls
::Poll.includes(:poll_votes, :poll_options).where(post: post).find_each do |old_poll|
new_poll = polls[old_poll.name]
new_poll_options = new_poll["options"]
::Poll
.includes(:poll_votes, :poll_options)
.where(post: post)
.find_each do |old_poll|
new_poll = polls[old_poll.name]
new_poll_options = new_poll["options"]
attributes = new_poll.slice(*POLL_ATTRIBUTES)
attributes["visibility"] = new_poll["public"] == "true" ? "everyone" : "secret"
attributes["close_at"] = Time.zone.parse(new_poll["close"]) rescue nil
attributes["status"] = old_poll["status"]
attributes["groups"] = new_poll["groups"]
poll = ::Poll.new(attributes)
attributes = new_poll.slice(*POLL_ATTRIBUTES)
attributes["visibility"] = new_poll["public"] == "true" ? "everyone" : "secret"
attributes["close_at"] = begin
Time.zone.parse(new_poll["close"])
rescue StandardError
nil
end
attributes["status"] = old_poll["status"]
attributes["groups"] = new_poll["groups"]
poll = ::Poll.new(attributes)
if is_different?(old_poll, poll, new_poll_options)
if is_different?(old_poll, poll, new_poll_options)
# only prevent changes when there's at least 1 vote
if old_poll.poll_votes.size > 0
# can't change after edit window (when enabled)
if edit_window > 0 && old_poll.created_at < edit_window.minutes.ago
error =
(
if poll.name == DiscoursePoll::DEFAULT_POLL_NAME
I18n.t(
"poll.edit_window_expired.cannot_edit_default_poll_with_votes",
minutes: edit_window,
)
else
I18n.t(
"poll.edit_window_expired.cannot_edit_named_poll_with_votes",
minutes: edit_window,
name: poll.name,
)
end
)
# only prevent changes when there's at least 1 vote
if old_poll.poll_votes.size > 0
# can't change after edit window (when enabled)
if edit_window > 0 && old_poll.created_at < edit_window.minutes.ago
error = poll.name == DiscoursePoll::DEFAULT_POLL_NAME ?
I18n.t("poll.edit_window_expired.cannot_edit_default_poll_with_votes", minutes: edit_window) :
I18n.t("poll.edit_window_expired.cannot_edit_named_poll_with_votes", minutes: edit_window, name: poll.name)
post.errors.add(:base, error)
return
post.errors.add(:base, error)
return
end
end
# update poll
POLL_ATTRIBUTES.each do |attr|
old_poll.public_send("#{attr}=", poll.public_send(attr))
end
old_poll.save!
# keep track of anonymous votes
anonymous_votes =
old_poll.poll_options.map { |pv| [pv.digest, pv.anonymous_votes] }.to_h
# destroy existing options & votes
::PollOption.where(poll: old_poll).destroy_all
# create new options
new_poll_options.each do |option|
::PollOption.create!(
poll: old_poll,
digest: option["id"],
html: option["html"].strip,
anonymous_votes: anonymous_votes[option["id"]],
)
end
has_changed = true
end
# update poll
POLL_ATTRIBUTES.each do |attr|
old_poll.public_send("#{attr}=", poll.public_send(attr))
end
old_poll.save!
# keep track of anonymous votes
anonymous_votes = old_poll.poll_options.map { |pv| [pv.digest, pv.anonymous_votes] }.to_h
# destroy existing options & votes
::PollOption.where(poll: old_poll).destroy_all
# create new options
new_poll_options.each do |option|
::PollOption.create!(
poll: old_poll,
digest: option["id"],
html: option["html"].strip,
anonymous_votes: anonymous_votes[option["id"]],
)
end
has_changed = true
end
end
if ::Poll.exists?(post: post)
post.custom_fields[HAS_POLLS] = true
@ -93,7 +109,13 @@ module DiscoursePoll
if has_changed
polls = ::Poll.includes(poll_options: :poll_votes).where(post: post)
polls = ActiveModel::ArraySerializer.new(polls, each_serializer: PollSerializer, root: false, scope: Guardian.new(nil)).as_json
polls =
ActiveModel::ArraySerializer.new(
polls,
each_serializer: PollSerializer,
root: false,
scope: Guardian.new(nil),
).as_json
post.publish_message!("/polls/#{post.topic_id}", post_id: post.id, polls: polls)
end
end
@ -108,11 +130,12 @@ module DiscoursePoll
end
# an option was changed?
return true if old_poll.poll_options.map { |o| o.digest }.sort != new_options.map { |o| o["id"] }.sort
if old_poll.poll_options.map { |o| o.digest }.sort != new_options.map { |o| o["id"] }.sort
return true
end
# it's the same!
false
end
end
end

View File

@ -2,7 +2,6 @@
module DiscoursePoll
class PollsValidator
MAX_VALUE = 2_147_483_647
def initialize(post)
@ -12,17 +11,19 @@ module DiscoursePoll
def validate_polls
polls = {}
DiscoursePoll::Poll::extract(@post.raw, @post.topic_id, @post.user_id).each do |poll|
return false unless valid_arguments?(poll)
return false unless valid_numbers?(poll)
return false unless unique_poll_name?(polls, poll)
return false unless unique_options?(poll)
return false unless any_blank_options?(poll)
return false unless at_least_one_option?(poll)
return false unless valid_number_of_options?(poll)
return false unless valid_multiple_choice_settings?(poll)
polls[poll["name"]] = poll
end
DiscoursePoll::Poll
.extract(@post.raw, @post.topic_id, @post.user_id)
.each do |poll|
return false unless valid_arguments?(poll)
return false unless valid_numbers?(poll)
return false unless unique_poll_name?(polls, poll)
return false unless unique_options?(poll)
return false unless any_blank_options?(poll)
return false unless at_least_one_option?(poll)
return false unless valid_number_of_options?(poll)
return false unless valid_multiple_choice_settings?(poll)
polls[poll["name"]] = poll
end
polls
end
@ -33,17 +34,26 @@ module DiscoursePoll
valid = true
if poll["type"].present? && !::Poll.types.has_key?(poll["type"])
@post.errors.add(:base, I18n.t("poll.invalid_argument", argument: "type", value: poll["type"]))
@post.errors.add(
:base,
I18n.t("poll.invalid_argument", argument: "type", value: poll["type"]),
)
valid = false
end
if poll["status"].present? && !::Poll.statuses.has_key?(poll["status"])
@post.errors.add(:base, I18n.t("poll.invalid_argument", argument: "status", value: poll["status"]))
@post.errors.add(
:base,
I18n.t("poll.invalid_argument", argument: "status", value: poll["status"]),
)
valid = false
end
if poll["results"].present? && !::Poll.results.has_key?(poll["results"])
@post.errors.add(:base, I18n.t("poll.invalid_argument", argument: "results", value: poll["results"]))
@post.errors.add(
:base,
I18n.t("poll.invalid_argument", argument: "results", value: poll["results"]),
)
valid = false
end
@ -69,7 +79,10 @@ module DiscoursePoll
if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME
@post.errors.add(:base, I18n.t("poll.default_poll_must_have_different_options"))
else
@post.errors.add(:base, I18n.t("poll.named_poll_must_have_different_options", name: poll["name"]))
@post.errors.add(
:base,
I18n.t("poll.named_poll_must_have_different_options", name: poll["name"]),
)
end
return false
@ -83,7 +96,10 @@ module DiscoursePoll
if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME
@post.errors.add(:base, I18n.t("poll.default_poll_must_not_have_any_empty_options"))
else
@post.errors.add(:base, I18n.t("poll.named_poll_must_not_have_any_empty_options", name: poll["name"]))
@post.errors.add(
:base,
I18n.t("poll.named_poll_must_not_have_any_empty_options", name: poll["name"]),
)
end
return false
@ -97,7 +113,10 @@ module DiscoursePoll
if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME
@post.errors.add(:base, I18n.t("poll.default_poll_must_have_at_least_1_option"))
else
@post.errors.add(:base, I18n.t("poll.named_poll_must_have_at_least_1_option", name: poll["name"]))
@post.errors.add(
:base,
I18n.t("poll.named_poll_must_have_at_least_1_option", name: poll["name"]),
)
end
return false
@ -109,9 +128,22 @@ module DiscoursePoll
def valid_number_of_options?(poll)
if poll["options"].size > SiteSetting.poll_maximum_options
if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME
@post.errors.add(:base, I18n.t("poll.default_poll_must_have_less_options", count: SiteSetting.poll_maximum_options))
@post.errors.add(
:base,
I18n.t(
"poll.default_poll_must_have_less_options",
count: SiteSetting.poll_maximum_options,
),
)
else
@post.errors.add(:base, I18n.t("poll.named_poll_must_have_less_options", name: poll["name"], count: SiteSetting.poll_maximum_options))
@post.errors.add(
:base,
I18n.t(
"poll.named_poll_must_have_less_options",
name: poll["name"],
count: SiteSetting.poll_maximum_options,
),
)
end
return false
@ -128,9 +160,18 @@ module DiscoursePoll
if min > max || min <= 0 || max <= 0 || max > options || min >= options
if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME
@post.errors.add(:base, I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters"))
@post.errors.add(
:base,
I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters"),
)
else
@post.errors.add(:base, I18n.t("poll.named_poll_with_multiple_choices_has_invalid_parameters", name: poll["name"]))
@post.errors.add(
:base,
I18n.t(
"poll.named_poll_with_multiple_choices_has_invalid_parameters",
name: poll["name"],
),
)
end
return false
@ -172,7 +213,10 @@ module DiscoursePoll
if poll["name"] == ::DiscoursePoll::DEFAULT_POLL_NAME
@post.errors.add(:base, I18n.t("poll.default_poll_must_have_at_least_1_option"))
else
@post.errors.add(:base, I18n.t("poll.named_poll_must_have_at_least_1_option", name: poll["name"]))
@post.errors.add(
:base,
I18n.t("poll.named_poll_must_have_at_least_1_option", name: poll["name"]),
)
end
valid = false
end

View File

@ -9,7 +9,13 @@ module DiscoursePoll
def validate_post
min_trust_level = SiteSetting.poll_minimum_trust_level_to_create
if (@post.acting_user && (@post.acting_user.staff? || @post.acting_user.trust_level >= TrustLevel[min_trust_level])) || @post.topic&.pm_with_non_human_user?
if (
@post.acting_user &&
(
@post.acting_user.staff? ||
@post.acting_user.trust_level >= TrustLevel[min_trust_level]
)
) || @post.topic&.pm_with_non_human_user?
true
else
@post.errors.add(:base, I18n.t("poll.insufficient_rights_to_create"))

View File

@ -28,57 +28,63 @@ end
desc "Migrate old polls to new syntax"
task "poll:migrate_old_polls" => :environment do
# iterate over all polls
PluginStoreRow.where(plugin_name: "poll")
PluginStoreRow
.where(plugin_name: "poll")
.where("key LIKE 'poll_options_%'")
.pluck(:key)
.each do |poll_options_key|
# extract the post_id
post_id = poll_options_key["poll_options_".length..-1].to_i
# load the post from the db
if post = Post.find_by(id: post_id)
putc "."
# skip if already migrated
next if post.custom_fields.include?("polls")
# go back in time
freeze_time(post.created_at + 1.minute) do
raw = post.raw.gsub(/\n\n([ ]*[-\*\+] )/, "\n\\1") + "\n\n"
# fix the RAW when needed
if raw !~ /\[poll\]/
lists = /^[ ]*[-\*\+] .+?$\n\n/m.match(raw)
next if lists.blank? || lists.length == 0
first_list = lists[0]
raw = raw.sub(first_list, "\n[poll]\n#{first_list}\n[/poll]\n")
end
# save the poll
post.raw = raw
post.save
# make sure we have a poll
next if post.custom_fields.blank? || !post.custom_fields.include?("polls")
# retrieve the new options
options = post.custom_fields["polls"]["poll"]["options"]
# iterate over all votes
PluginStoreRow.where(plugin_name: "poll")
.where("key LIKE ?", "poll_vote_#{post_id}_%")
.pluck(:key, :value)
.each do |poll_vote_key, vote|
# extract the user_id
user_id = poll_vote_key["poll_vote_#{post_id}_%".length..-1].to_i
# find the selected option
vote = vote.strip
selected_option = options.detect { |o| o["html"].strip === vote }
# make sure we have a match
next if selected_option.blank?
# submit vote
DiscoursePoll::Poll.vote(post_id, "poll", [selected_option["id"]], user_id) rescue nil
end
# close the poll
if post.topic.archived? || post.topic.closed? || poll_was_closed?(post.topic.title)
post.custom_fields["polls"]["poll"]["status"] = "closed"
post.save_custom_fields(true)
# extract the post_id
post_id = poll_options_key["poll_options_".length..-1].to_i
# load the post from the db
if post = Post.find_by(id: post_id)
putc "."
# skip if already migrated
next if post.custom_fields.include?("polls")
# go back in time
freeze_time(post.created_at + 1.minute) do
raw = post.raw.gsub(/\n\n([ ]*[-\*\+] )/, "\n\\1") + "\n\n"
# fix the RAW when needed
if raw !~ /\[poll\]/
lists = /^[ ]*[-\*\+] .+?$\n\n/m.match(raw)
next if lists.blank? || lists.length == 0
first_list = lists[0]
raw = raw.sub(first_list, "\n[poll]\n#{first_list}\n[/poll]\n")
end
# save the poll
post.raw = raw
post.save
# make sure we have a poll
next if post.custom_fields.blank? || !post.custom_fields.include?("polls")
# retrieve the new options
options = post.custom_fields["polls"]["poll"]["options"]
# iterate over all votes
PluginStoreRow
.where(plugin_name: "poll")
.where("key LIKE ?", "poll_vote_#{post_id}_%")
.pluck(:key, :value)
.each do |poll_vote_key, vote|
# extract the user_id
user_id = poll_vote_key["poll_vote_#{post_id}_%".length..-1].to_i
# find the selected option
vote = vote.strip
selected_option = options.detect { |o| o["html"].strip === vote }
# make sure we have a match
next if selected_option.blank?
# submit vote
begin
DiscoursePoll::Poll.vote(post_id, "poll", [selected_option["id"]], user_id)
rescue StandardError
nil
end
end
# close the poll
if post.topic.archived? || post.topic.closed? || poll_was_closed?(post.topic.title)
post.custom_fields["polls"]["poll"]["status"] = "closed"
post.save_custom_fields(true)
end
end
end
end
end
puts "", "Done!"
end

View File

@ -30,7 +30,8 @@ after_initialize do
isolate_namespace DiscoursePoll
end
class Error < StandardError; end
class Error < StandardError
end
end
require_relative "app/controllers/polls_controller.rb"
@ -49,13 +50,11 @@ after_initialize do
put "/vote" => "polls#vote"
delete "/vote" => "polls#remove_vote"
put "/toggle_status" => "polls#toggle_status"
get "/voters" => 'polls#voters'
get "/grouped_poll_results" => 'polls#grouped_poll_results'
get "/voters" => "polls#voters"
get "/grouped_poll_results" => "polls#grouped_poll_results"
end
Discourse::Application.routes.append do
mount ::DiscoursePoll::Engine, at: "/polls"
end
Discourse::Application.routes.append { mount ::DiscoursePoll::Engine, at: "/polls" }
allow_new_queued_post_payload_attribute("is_poll")
register_post_custom_field_type(DiscoursePoll::HAS_POLLS, :boolean)
@ -74,18 +73,14 @@ after_initialize do
post = self
Poll.transaction do
polls.values.each do |poll|
DiscoursePoll::Poll.create!(post.id, poll)
end
polls.values.each { |poll| DiscoursePoll::Poll.create!(post.id, poll) }
post.custom_fields[DiscoursePoll::HAS_POLLS] = true
post.save_custom_fields(true)
end
end
end
User.class_eval do
has_many :poll_votes, dependent: :delete_all
end
User.class_eval { has_many :poll_votes, dependent: :delete_all }
end
validate(:post, :validate_polls) do |force = nil|
@ -115,9 +110,7 @@ after_initialize do
if !DiscoursePoll::PollsValidator.new(post).validate_polls
result = NewPostResult.new(:poll, false)
post.errors.full_messages.each do |message|
result.add_error(message)
end
post.errors.full_messages.each { |message| result.add_error(message) }
result
else
@ -127,9 +120,7 @@ after_initialize do
end
on(:approved_post) do |queued_post, created_post|
if queued_post.payload["is_poll"]
created_post.validate_polls(true)
end
created_post.validate_polls(true) if queued_post.payload["is_poll"]
end
on(:reduce_cooked) do |fragment, post|
@ -137,22 +128,27 @@ after_initialize do
fragment.css(".poll, [data-poll-name]").each(&:remove)
else
post_url = post.full_url
fragment.css(".poll, [data-poll-name]").each do |poll|
poll.replace "<p><a href='#{post_url}'>#{I18n.t("poll.email.link_to_poll")}</a></p>"
end
fragment
.css(".poll, [data-poll-name]")
.each do |poll|
poll.replace "<p><a href='#{post_url}'>#{I18n.t("poll.email.link_to_poll")}</a></p>"
end
end
end
on(:reduce_excerpt) do |doc, options|
post = options[:post]
replacement = post&.url.present? ?
"<a href='#{UrlHelper.normalized_encode(post.url)}'>#{I18n.t("poll.poll")}</a>" :
I18n.t("poll.poll")
replacement =
(
if post&.url.present?
"<a href='#{UrlHelper.normalized_encode(post.url)}'>#{I18n.t("poll.poll")}</a>"
else
I18n.t("poll.poll")
end
)
doc.css("div.poll").each do |poll|
poll.replace(replacement)
end
doc.css("div.poll").each { |poll| poll.replace(replacement) }
end
on(:post_created) do |post, _opts, user|
@ -162,7 +158,13 @@ after_initialize do
next if post.is_first_post?
next if post.custom_fields[DiscoursePoll::HAS_POLLS].blank?
polls = ActiveModel::ArraySerializer.new(post.polls, each_serializer: PollSerializer, root: false, scope: guardian).as_json
polls =
ActiveModel::ArraySerializer.new(
post.polls,
each_serializer: PollSerializer,
root: false,
scope: guardian,
).as_json
post.publish_message!("/polls/#{post.topic_id}", post_id: post.id, polls: polls)
end
@ -171,63 +173,62 @@ after_initialize do
end
add_to_class(:topic_view, :polls) do
@polls ||= begin
polls = {}
@polls ||=
begin
polls = {}
post_with_polls = @post_custom_fields.each_with_object([]) do |fields, obj|
obj << fields[0] if fields[1][DiscoursePoll::HAS_POLLS]
end
if post_with_polls.present?
Poll
.where(post_id: post_with_polls)
.each do |p|
polls[p.post_id] ||= []
polls[p.post_id] << p
post_with_polls =
@post_custom_fields.each_with_object([]) do |fields, obj|
obj << fields[0] if fields[1][DiscoursePoll::HAS_POLLS]
end
end
polls
end
if post_with_polls.present?
Poll
.where(post_id: post_with_polls)
.each do |p|
polls[p.post_id] ||= []
polls[p.post_id] << p
end
end
polls
end
end
add_to_serializer(:post, :preloaded_polls, false) do
@preloaded_polls ||= if @topic_view.present?
@topic_view.polls[object.id]
else
Poll.includes(:poll_options).where(post: object)
end
@preloaded_polls ||=
if @topic_view.present?
@topic_view.polls[object.id]
else
Poll.includes(:poll_options).where(post: object)
end
end
add_to_serializer(:post, :include_preloaded_polls?) do
false
end
add_to_serializer(:post, :include_preloaded_polls?) { false }
add_to_serializer(:post, :polls, false) do
preloaded_polls.map { |p| PollSerializer.new(p, root: false, scope: self.scope) }
end
add_to_serializer(:post, :include_polls?) do
SiteSetting.poll_enabled && preloaded_polls.present?
end
add_to_serializer(:post, :include_polls?) { SiteSetting.poll_enabled && preloaded_polls.present? }
add_to_serializer(:post, :polls_votes, false) do
preloaded_polls.map do |poll|
user_poll_votes =
poll
.poll_votes
.where(user_id: scope.user.id)
.joins(:poll_option)
.pluck("poll_options.digest")
preloaded_polls
.map do |poll|
user_poll_votes =
poll
.poll_votes
.where(user_id: scope.user.id)
.joins(:poll_option)
.pluck("poll_options.digest")
[poll.name, user_poll_votes]
end.to_h
[poll.name, user_poll_votes]
end
.to_h
end
add_to_serializer(:post, :include_polls_votes?) do
SiteSetting.poll_enabled &&
scope.user&.id.present? &&
preloaded_polls.present? &&
preloaded_polls.any? { |p| p.has_voted?(scope.user) }
SiteSetting.poll_enabled && scope.user&.id.present? && preloaded_polls.present? &&
preloaded_polls.any? { |p| p.has_voted?(scope.user) }
end
end

View File

@ -7,20 +7,48 @@ RSpec.describe ::DiscoursePoll::PollsController do
let!(:user) { log_in }
let(:topic) { Fabricate(:topic) }
let(:poll) { Fabricate(:post, topic: topic, user: user, raw: "[poll]\n- A\n- B\n[/poll]") }
let(:multi_poll) { Fabricate(:post, topic: topic, user: user, raw: "[poll min=1 max=2 type=multiple public=true]\n- A\n- B\n[/poll]") }
let(:public_poll_on_vote) { Fabricate(:post, topic: topic, user: user, raw: "[poll public=true results=on_vote]\n- A\n- B\n[/poll]") }
let(:public_poll_on_close) { Fabricate(:post, topic: topic, user: user, raw: "[poll public=true results=on_close]\n- A\n- B\n[/poll]") }
let(:poll) { Fabricate(:post, topic: topic, user: user, raw: "[poll]\n- A\n- B\n[/poll]") }
let(:multi_poll) do
Fabricate(
:post,
topic: topic,
user: user,
raw: "[poll min=1 max=2 type=multiple public=true]\n- A\n- B\n[/poll]",
)
end
let(:public_poll_on_vote) do
Fabricate(
:post,
topic: topic,
user: user,
raw: "[poll public=true results=on_vote]\n- A\n- B\n[/poll]",
)
end
let(:public_poll_on_close) do
Fabricate(
:post,
topic: topic,
user: user,
raw: "[poll public=true results=on_close]\n- A\n- B\n[/poll]",
)
end
describe "#vote" do
it "works" do
channel = "/polls/#{poll.topic_id}"
message = MessageBus.track_publish(channel) do
put :vote, params: {
post_id: poll.id, poll_name: "poll", options: ["5c24fc1df56d764b550ceae1b9319125"]
}, format: :json
end.first
message =
MessageBus
.track_publish(channel) do
put :vote,
params: {
post_id: poll.id,
poll_name: "poll",
options: ["5c24fc1df56d764b550ceae1b9319125"],
},
format: :json
end
.first
expect(response.status).to eq(200)
@ -36,19 +64,30 @@ RSpec.describe ::DiscoursePoll::PollsController do
it "works in PM" do
user2 = Fabricate(:user)
topic = Fabricate(:private_message_topic, topic_allowed_users: [
Fabricate.build(:topic_allowed_user, user: user),
Fabricate.build(:topic_allowed_user, user: user2)
])
topic =
Fabricate(
:private_message_topic,
topic_allowed_users: [
Fabricate.build(:topic_allowed_user, user: user),
Fabricate.build(:topic_allowed_user, user: user2),
],
)
poll = Fabricate(:post, topic: topic, user: user, raw: "[poll]\n- A\n- B\n[/poll]")
channel = "/polls/#{poll.topic_id}"
message = MessageBus.track_publish(channel) do
put :vote, params: {
post_id: poll.id, poll_name: "poll", options: ["5c24fc1df56d764b550ceae1b9319125"]
}, format: :json
end.first
message =
MessageBus
.track_publish(channel) do
put :vote,
params: {
post_id: poll.id,
poll_name: "poll",
options: ["5c24fc1df56d764b550ceae1b9319125"],
},
format: :json
end
.first
expect(response.status).to eq(200)
@ -71,11 +110,18 @@ RSpec.describe ::DiscoursePoll::PollsController do
channel = "/polls/#{poll.topic_id}"
message = MessageBus.track_publish(channel) do
put :vote, params: {
post_id: poll.id, poll_name: "poll", options: ["5c24fc1df56d764b550ceae1b9319125"]
}, format: :json
end.first
message =
MessageBus
.track_publish(channel) do
put :vote,
params: {
post_id: poll.id,
poll_name: "poll",
options: ["5c24fc1df56d764b550ceae1b9319125"],
},
format: :json
end
.first
expect(response.status).to eq(200)
@ -90,9 +136,7 @@ RSpec.describe ::DiscoursePoll::PollsController do
end
it "requires at least 1 valid option" do
put :vote, params: {
post_id: poll.id, poll_name: "poll", options: ["A", "B"]
}, format: :json
put :vote, params: { post_id: poll.id, poll_name: "poll", options: %w[A B] }, format: :json
expect(response.status).not_to eq(200)
json = response.parsed_body
@ -100,15 +144,23 @@ RSpec.describe ::DiscoursePoll::PollsController do
end
it "supports vote changes" do
put :vote, params: {
post_id: poll.id, poll_name: "poll", options: ["5c24fc1df56d764b550ceae1b9319125"]
}, format: :json
put :vote,
params: {
post_id: poll.id,
poll_name: "poll",
options: ["5c24fc1df56d764b550ceae1b9319125"],
},
format: :json
expect(response.status).to eq(200)
put :vote, params: {
post_id: poll.id, poll_name: "poll", options: ["e89dec30bbd9bf50fabf6a05b4324edf"]
}, format: :json
put :vote,
params: {
post_id: poll.id,
poll_name: "poll",
options: ["e89dec30bbd9bf50fabf6a05b4324edf"],
},
format: :json
expect(response.status).to eq(200)
json = response.parsed_body
@ -118,15 +170,17 @@ RSpec.describe ::DiscoursePoll::PollsController do
end
it "supports removing votes" do
put :vote, params: {
post_id: poll.id, poll_name: "poll", options: ["5c24fc1df56d764b550ceae1b9319125"]
}, format: :json
put :vote,
params: {
post_id: poll.id,
poll_name: "poll",
options: ["5c24fc1df56d764b550ceae1b9319125"],
},
format: :json
expect(response.status).to eq(200)
delete :remove_vote, params: {
post_id: poll.id, poll_name: "poll"
}, format: :json
delete :remove_vote, params: { post_id: poll.id, poll_name: "poll" }, format: :json
expect(response.status).to eq(200)
json = response.parsed_body
@ -138,9 +192,13 @@ RSpec.describe ::DiscoursePoll::PollsController do
it "works on closed topics" do
topic.update_attribute(:closed, true)
put :vote, params: {
post_id: poll.id, poll_name: "poll", options: ["5c24fc1df56d764b550ceae1b9319125"]
}, format: :json
put :vote,
params: {
post_id: poll.id,
poll_name: "poll",
options: ["5c24fc1df56d764b550ceae1b9319125"],
},
format: :json
expect(response.status).to eq(200)
end
@ -148,9 +206,7 @@ RSpec.describe ::DiscoursePoll::PollsController do
it "ensures topic is not archived" do
topic.update_attribute(:archived, true)
put :vote, params: {
post_id: poll.id, poll_name: "poll", options: ["A"]
}, format: :json
put :vote, params: { post_id: poll.id, poll_name: "poll", options: ["A"] }, format: :json
expect(response.status).not_to eq(200)
json = response.parsed_body
@ -160,9 +216,7 @@ RSpec.describe ::DiscoursePoll::PollsController do
it "ensures post is not trashed" do
poll.trash!
put :vote, params: {
post_id: poll.id, poll_name: "poll", options: ["A"]
}, format: :json
put :vote, params: { post_id: poll.id, poll_name: "poll", options: ["A"] }, format: :json
expect(response.status).not_to eq(200)
json = response.parsed_body
@ -172,9 +226,7 @@ RSpec.describe ::DiscoursePoll::PollsController do
it "ensures user can post in topic" do
Guardian.any_instance.expects(:can_create_post?).returns(false)
put :vote, params: {
post_id: poll.id, poll_name: "poll", options: ["A"]
}, format: :json
put :vote, params: { post_id: poll.id, poll_name: "poll", options: ["A"] }, format: :json
expect(response.status).not_to eq(200)
json = response.parsed_body
@ -182,9 +234,7 @@ RSpec.describe ::DiscoursePoll::PollsController do
end
it "checks the name of the poll" do
put :vote, params: {
post_id: poll.id, poll_name: "foobar", options: ["A"]
}, format: :json
put :vote, params: { post_id: poll.id, poll_name: "foobar", options: ["A"] }, format: :json
expect(response.status).not_to eq(200)
json = response.parsed_body
@ -194,9 +244,13 @@ RSpec.describe ::DiscoursePoll::PollsController do
it "ensures poll is open" do
closed_poll = create_post(raw: "[poll status=closed]\n- A\n- B\n[/poll]")
put :vote, params: {
post_id: closed_poll.id, poll_name: "poll", options: ["5c24fc1df56d764b550ceae1b9319125"]
}, format: :json
put :vote,
params: {
post_id: closed_poll.id,
poll_name: "poll",
options: ["5c24fc1df56d764b550ceae1b9319125"],
},
format: :json
expect(response.status).not_to eq(200)
json = response.parsed_body
@ -206,13 +260,19 @@ RSpec.describe ::DiscoursePoll::PollsController do
it "ensures user has required trust level" do
poll = create_post(raw: "[poll groups=#{Fabricate(:group).name}]\n- A\n- B\n[/poll]")
put :vote, params: {
post_id: poll.id, poll_name: "poll", options: ["5c24fc1df56d764b550ceae1b9319125"]
}, format: :json
put :vote,
params: {
post_id: poll.id,
poll_name: "poll",
options: ["5c24fc1df56d764b550ceae1b9319125"],
},
format: :json
expect(response.status).not_to eq(200)
json = response.parsed_body
expect(json["errors"][0]).to eq(I18n.t("js.poll.results.groups.title", groups: poll.polls.first.groups))
expect(json["errors"][0]).to eq(
I18n.t("js.poll.results.groups.title", groups: poll.polls.first.groups),
)
end
it "doesn't discard anonymous votes when someone votes" do
@ -221,9 +281,13 @@ RSpec.describe ::DiscoursePoll::PollsController do
the_poll.poll_options[0].update_attribute(:anonymous_votes, 11)
the_poll.poll_options[1].update_attribute(:anonymous_votes, 6)
put :vote, params: {
post_id: poll.id, poll_name: "poll", options: ["5c24fc1df56d764b550ceae1b9319125"]
}, format: :json
put :vote,
params: {
post_id: poll.id,
poll_name: "poll",
options: ["5c24fc1df56d764b550ceae1b9319125"],
},
format: :json
expect(response.status).to eq(200)
@ -238,13 +302,20 @@ RSpec.describe ::DiscoursePoll::PollsController do
it "works for OP" do
channel = "/polls/#{poll.topic_id}"
message = MessageBus.track_publish(channel) do
put :toggle_status, params: {
post_id: poll.id, poll_name: "poll", status: "closed"
}, format: :json
message =
MessageBus
.track_publish(channel) do
put :toggle_status,
params: {
post_id: poll.id,
poll_name: "poll",
status: "closed",
},
format: :json
expect(response.status).to eq(200)
end.first
expect(response.status).to eq(200)
end
.first
json = response.parsed_body
expect(json["poll"]["status"]).to eq("closed")
@ -256,13 +327,20 @@ RSpec.describe ::DiscoursePoll::PollsController do
channel = "/polls/#{poll.topic_id}"
message = MessageBus.track_publish(channel) do
put :toggle_status, params: {
post_id: poll.id, poll_name: "poll", status: "closed"
}, format: :json
message =
MessageBus
.track_publish(channel) do
put :toggle_status,
params: {
post_id: poll.id,
poll_name: "poll",
status: "closed",
},
format: :json
expect(response.status).to eq(200)
end.first
expect(response.status).to eq(200)
end
.first
json = response.parsed_body
expect(json["poll"]["status"]).to eq("closed")
@ -272,9 +350,13 @@ RSpec.describe ::DiscoursePoll::PollsController do
it "ensures post is not trashed" do
poll.trash!
put :toggle_status, params: {
post_id: poll.id, poll_name: "poll", status: "closed"
}, format: :json
put :toggle_status,
params: {
post_id: poll.id,
poll_name: "poll",
status: "closed",
},
format: :json
expect(response.status).not_to eq(200)
json = response.parsed_body
@ -289,31 +371,41 @@ RSpec.describe ::DiscoursePoll::PollsController do
it "correctly handles offset" do
user1 = log_in
put :vote, params: {
post_id: multi_poll.id, poll_name: "poll", options: [first]
}, format: :json
put :vote,
params: {
post_id: multi_poll.id,
poll_name: "poll",
options: [first],
},
format: :json
expect(response.status).to eq(200)
user2 = log_in
put :vote, params: {
post_id: multi_poll.id, poll_name: "poll", options: [first]
}, format: :json
put :vote,
params: {
post_id: multi_poll.id,
poll_name: "poll",
options: [first],
},
format: :json
expect(response.status).to eq(200)
user3 = log_in
put :vote, params: {
post_id: multi_poll.id, poll_name: "poll", options: [first, second]
}, format: :json
put :vote,
params: {
post_id: multi_poll.id,
poll_name: "poll",
options: [first, second],
},
format: :json
expect(response.status).to eq(200)
get :voters, params: {
poll_name: 'poll', post_id: multi_poll.id, limit: 2
}, format: :json
get :voters, params: { poll_name: "poll", post_id: multi_poll.id, limit: 2 }, format: :json
expect(response.status).to eq(200)
@ -325,15 +417,17 @@ RSpec.describe ::DiscoursePoll::PollsController do
end
it "ensures voters can only be seen after casting a vote" do
put :vote, params: {
post_id: public_poll_on_vote.id, poll_name: "poll", options: [first]
}, format: :json
put :vote,
params: {
post_id: public_poll_on_vote.id,
poll_name: "poll",
options: [first],
},
format: :json
expect(response.status).to eq(200)
get :voters, params: {
poll_name: "poll", post_id: public_poll_on_vote.id
}, format: :json
get :voters, params: { poll_name: "poll", post_id: public_poll_on_vote.id }, format: :json
expect(response.status).to eq(200)
@ -343,21 +437,21 @@ RSpec.describe ::DiscoursePoll::PollsController do
_user2 = log_in
get :voters, params: {
poll_name: "poll", post_id: public_poll_on_vote.id
}, format: :json
get :voters, params: { poll_name: "poll", post_id: public_poll_on_vote.id }, format: :json
expect(response.status).to eq(400)
put :vote, params: {
post_id: public_poll_on_vote.id, poll_name: "poll", options: [second]
}, format: :json
put :vote,
params: {
post_id: public_poll_on_vote.id,
poll_name: "poll",
options: [second],
},
format: :json
expect(response.status).to eq(200)
get :voters, params: {
poll_name: "poll", post_id: public_poll_on_vote.id
}, format: :json
get :voters, params: { poll_name: "poll", post_id: public_poll_on_vote.id }, format: :json
expect(response.status).to eq(200)
@ -368,27 +462,31 @@ RSpec.describe ::DiscoursePoll::PollsController do
end
it "ensures voters can only be seen when poll is closed" do
put :vote, params: {
post_id: public_poll_on_close.id, poll_name: "poll", options: [first]
}, format: :json
put :vote,
params: {
post_id: public_poll_on_close.id,
poll_name: "poll",
options: [first],
},
format: :json
expect(response.status).to eq(200)
get :voters, params: {
poll_name: "poll", post_id: public_poll_on_close.id
}, format: :json
get :voters, params: { poll_name: "poll", post_id: public_poll_on_close.id }, format: :json
expect(response.status).to eq(400)
put :toggle_status, params: {
post_id: public_poll_on_close.id, poll_name: "poll", status: "closed"
}, format: :json
put :toggle_status,
params: {
post_id: public_poll_on_close.id,
poll_name: "poll",
status: "closed",
},
format: :json
expect(response.status).to eq(200)
get :voters, params: {
poll_name: "poll", post_id: public_poll_on_close.id
}, format: :json
get :voters, params: { poll_name: "poll", post_id: public_poll_on_close.id }, format: :json
expect(response.status).to eq(200)

View File

@ -6,15 +6,11 @@ RSpec.describe PostsController do
let!(:user) { log_in }
let!(:title) { "Testing Poll Plugin" }
before do
SiteSetting.min_first_post_typing_time = 0
end
before { SiteSetting.min_first_post_typing_time = 0 }
describe "polls" do
it "works" do
post :create, params: {
title: title, raw: "[poll]\n- A\n- B\n[/poll]"
}, format: :json
post :create, params: { title: title, raw: "[poll]\n- A\n- B\n[/poll]" }, format: :json
expect(response.status).to eq(200)
json = response.parsed_body
@ -25,9 +21,12 @@ RSpec.describe PostsController do
it "works on any post" do
post_1 = Fabricate(:post)
post :create, params: {
topic_id: post_1.topic.id, raw: "[poll]\n- A\n- B\n[/poll]"
}, format: :json
post :create,
params: {
topic_id: post_1.topic.id,
raw: "[poll]\n- A\n- B\n[/poll]",
},
format: :json
expect(response.status).to eq(200)
json = response.parsed_body
@ -41,12 +40,13 @@ RSpec.describe PostsController do
close_date = 1.month.from_now.round
expect do
post :create, params: {
title: title,
raw: "[poll name=#{name} close=#{close_date.iso8601}]\n- A\n- B\n[/poll]"
}, format: :json
end.to change { Jobs::ClosePoll.jobs.size }.by(1) &
change { Poll.count }.by(1)
post :create,
params: {
title: title,
raw: "[poll name=#{name} close=#{close_date.iso8601}]\n- A\n- B\n[/poll]",
},
format: :json
end.to change { Jobs::ClosePoll.jobs.size }.by(1) & change { Poll.count }.by(1)
expect(response.status).to eq(200)
json = response.parsed_body
@ -62,9 +62,7 @@ RSpec.describe PostsController do
end
it "should have different options" do
post :create, params: {
title: title, raw: "[poll]\n- A\n- A\n[/poll]"
}, format: :json
post :create, params: { title: title, raw: "[poll]\n- A\n- A\n[/poll]" }, format: :json
expect(response).not_to be_successful
json = response.parsed_body
@ -72,19 +70,20 @@ RSpec.describe PostsController do
end
it "accepts different Chinese options" do
SiteSetting.default_locale = 'zh_CN'
SiteSetting.default_locale = "zh_CN"
post :create, params: {
title: title, raw: "[poll]\n- Microsoft Edge\n- Microsoft Edge\n[/poll]"
}, format: :json
post :create,
params: {
title: title,
raw: "[poll]\n- Microsoft Edge\n- Microsoft Edge\n[/poll]",
},
format: :json
expect(response).to be_successful
end
it "should have at least 1 options" do
post :create, params: {
title: title, raw: "[poll]\n[/poll]"
}, format: :json
post :create, params: { title: title, raw: "[poll]\n[/poll]" }, format: :json
expect(response).not_to be_successful
json = response.parsed_body
@ -96,41 +95,54 @@ RSpec.describe PostsController do
(SiteSetting.poll_maximum_options + 1).times { |n| raw << "\n- #{n}" }
raw << "\n[/poll]"
post :create, params: {
title: title, raw: raw
}, format: :json
post :create, params: { title: title, raw: raw }, format: :json
expect(response).not_to be_successful
json = response.parsed_body
expect(json["errors"][0]).to eq(I18n.t("poll.default_poll_must_have_less_options", count: SiteSetting.poll_maximum_options))
expect(json["errors"][0]).to eq(
I18n.t("poll.default_poll_must_have_less_options", count: SiteSetting.poll_maximum_options),
)
end
it "should have valid parameters" do
post :create, params: {
title: title, raw: "[poll type=multiple min=5]\n- A\n- B\n[/poll]"
}, format: :json
post :create,
params: {
title: title,
raw: "[poll type=multiple min=5]\n- A\n- B\n[/poll]",
},
format: :json
expect(response).not_to be_successful
json = response.parsed_body
expect(json["errors"][0]).to eq(I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters"))
expect(json["errors"][0]).to eq(
I18n.t("poll.default_poll_with_multiple_choices_has_invalid_parameters"),
)
end
it "prevents self-xss" do
post :create, params: {
title: title, raw: "[poll name=<script>alert('xss')</script>]\n- A\n- B\n[/poll]"
}, format: :json
post :create,
params: {
title: title,
raw: "[poll name=<script>alert('xss')</script>]\n- A\n- B\n[/poll]",
},
format: :json
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["cooked"]).to match("data-poll-")
expect(json["cooked"]).to include("&lt;script&gt;")
expect(Poll.find_by(post_id: json["id"]).name).to eq("&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;")
expect(Poll.find_by(post_id: json["id"]).name).to eq(
"&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;",
)
end
it "also works when there is a link starting with '[poll'" do
post :create, params: {
title: title, raw: "[Polls are awesome](/foobar)\n[poll]\n- A\n- B\n[/poll]"
}, format: :json
post :create,
params: {
title: title,
raw: "[Polls are awesome](/foobar)\n[poll]\n- A\n- B\n[/poll]",
},
format: :json
expect(response.status).to eq(200)
json = response.parsed_body
@ -139,9 +151,12 @@ RSpec.describe PostsController do
end
it "prevents poll-inception" do
post :create, params: {
title: title, raw: "[poll name=1]\n- A\n[poll name=2]\n- B\n- C\n[/poll]\n- D\n[/poll]"
}, format: :json
post :create,
params: {
title: title,
raw: "[poll name=1]\n- A\n[poll name=2]\n- B\n- C\n[/poll]\n- D\n[/poll]",
},
format: :json
expect(response.status).to eq(200)
json = response.parsed_body
@ -150,9 +165,12 @@ RSpec.describe PostsController do
end
it "accepts polls with titles" do
post :create, params: {
title: title, raw: "[poll]\n# What's up?\n- one\n[/poll]"
}, format: :json
post :create,
params: {
title: title,
raw: "[poll]\n# What's up?\n- one\n[/poll]",
},
format: :json
expect(response).to be_successful
poll = Poll.last
@ -161,23 +179,24 @@ RSpec.describe PostsController do
end
describe "edit window" do
describe "within the first 5 minutes" do
let(:post_id) do
freeze_time(4.minutes.ago) do
post :create, params: {
title: title, raw: "[poll]\n- A\n- B\n[/poll]"
}, format: :json
post :create, params: { title: title, raw: "[poll]\n- A\n- B\n[/poll]" }, format: :json
response.parsed_body["id"]
end
end
it "can be changed" do
put :update, params: {
id: post_id, post: { raw: "[poll]\n- A\n- B\n- C\n[/poll]" }
}, format: :json
put :update,
params: {
id: post_id,
post: {
raw: "[poll]\n- A\n- B\n- C\n[/poll]",
},
},
format: :json
expect(response.status).to eq(200)
json = response.parsed_body
@ -187,28 +206,29 @@ RSpec.describe PostsController do
it "resets the votes" do
DiscoursePoll::Poll.vote(user, post_id, "poll", ["5c24fc1df56d764b550ceae1b9319125"])
put :update, params: {
id: post_id, post: { raw: "[poll]\n- A\n- B\n- C\n[/poll]" }
}, format: :json
put :update,
params: {
id: post_id,
post: {
raw: "[poll]\n- A\n- B\n- C\n[/poll]",
},
},
format: :json
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["post"]["polls_votes"]).to_not be
end
end
describe "after the poll edit window has expired" do
let(:poll) { "[poll]\n- A\n- B\n[/poll]" }
let(:new_option) { "[poll]\n- A\n- C\n[/poll]" }
let(:updated) { "before\n\n[poll]\n- A\n- B\n[/poll]\n\nafter" }
let(:post_id) do
freeze_time(6.minutes.ago) do
post :create, params: {
title: title, raw: poll
}, format: :json
post :create, params: { title: title, raw: poll }, format: :json
response.parsed_body["id"]
end
@ -216,16 +236,11 @@ RSpec.describe PostsController do
let(:poll_edit_window_mins) { 6 }
before do
SiteSetting.poll_edit_window_mins = poll_edit_window_mins
end
before { SiteSetting.poll_edit_window_mins = poll_edit_window_mins }
describe "with no vote" do
it "can change the options" do
put :update, params: {
id: post_id, post: { raw: new_option }
}, format: :json
put :update, params: { id: post_id, post: { raw: new_option } }, format: :json
expect(response.status).to eq(200)
json = response.parsed_body
@ -238,26 +253,24 @@ RSpec.describe PostsController do
json = response.parsed_body
expect(json["post"]["cooked"]).to match("before")
end
end
describe "with at least one vote" do
before do
DiscoursePoll::Poll.vote(user, post_id, "poll", ["5c24fc1df56d764b550ceae1b9319125"])
end
it "cannot change the options" do
put :update, params: {
id: post_id, post: { raw: new_option }
}, format: :json
put :update, params: { id: post_id, post: { raw: new_option } }, format: :json
expect(response).not_to be_successful
json = response.parsed_body
expect(json["errors"][0]).to eq(I18n.t(
"poll.edit_window_expired.cannot_edit_default_poll_with_votes",
minutes: poll_edit_window_mins
))
expect(json["errors"][0]).to eq(
I18n.t(
"poll.edit_window_expired.cannot_edit_default_poll_with_votes",
minutes: poll_edit_window_mins,
),
)
end
it "support changes on the post" do
@ -266,45 +279,49 @@ RSpec.describe PostsController do
json = response.parsed_body
expect(json["post"]["cooked"]).to match("before")
end
end
end
end
end
describe "named polls" do
it "should have different options" do
post :create, params: {
title: title, raw: "[poll name=""foo""]\n- A\n- A\n[/poll]"
}, format: :json
post :create,
params: {
title: title,
raw:
"[poll name=" \
"foo" \
"]\n- A\n- A\n[/poll]",
},
format: :json
expect(response).not_to be_successful
json = response.parsed_body
expect(json["errors"][0]).to eq(I18n.t("poll.named_poll_must_have_different_options", name: "foo"))
expect(json["errors"][0]).to eq(
I18n.t("poll.named_poll_must_have_different_options", name: "foo"),
)
end
it "should have at least 1 option" do
post :create, params: {
title: title, raw: "[poll name='foo']\n[/poll]"
}, format: :json
post :create, params: { title: title, raw: "[poll name='foo']\n[/poll]" }, format: :json
expect(response).not_to be_successful
json = response.parsed_body
expect(json["errors"][0]).to eq(I18n.t("poll.named_poll_must_have_at_least_1_option", name: "foo"))
expect(json["errors"][0]).to eq(
I18n.t("poll.named_poll_must_have_at_least_1_option", name: "foo"),
)
end
end
describe "multiple polls" do
it "works" do
post :create, params: {
title: title, raw: "[poll]\n- A\n- B\n[/poll]\n[poll name=foo]\n- A\n- B\n[/poll]"
}, format: :json
post :create,
params: {
title: title,
raw: "[poll]\n- A\n- B\n[/poll]\n[poll name=foo]\n- A\n- B\n[/poll]",
},
format: :json
expect(response.status).to eq(200)
json = response.parsed_body
@ -313,9 +330,12 @@ RSpec.describe PostsController do
end
it "should have a name" do
post :create, params: {
title: title, raw: "[poll]\n- A\n- B\n[/poll]\n[poll]\n- A\n- B\n[/poll]"
}, format: :json
post :create,
params: {
title: title,
raw: "[poll]\n- A\n- B\n[/poll]\n[poll]\n- A\n- B\n[/poll]",
},
format: :json
expect(response).not_to be_successful
json = response.parsed_body
@ -323,46 +343,42 @@ RSpec.describe PostsController do
end
it "should have unique name" do
post :create, params: {
title: title, raw: "[poll name=foo]\n- A\n- B\n[/poll]\n[poll name=foo]\n- A\n- B\n[/poll]"
}, format: :json
post :create,
params: {
title: title,
raw: "[poll name=foo]\n- A\n- B\n[/poll]\n[poll name=foo]\n- A\n- B\n[/poll]",
},
format: :json
expect(response).not_to be_successful
json = response.parsed_body
expect(json["errors"][0]).to eq(I18n.t("poll.multiple_polls_with_same_name", name: "foo"))
end
end
describe "disabled polls" do
before do
SiteSetting.poll_enabled = false
end
before { SiteSetting.poll_enabled = false }
it "doesnt cook the poll" do
log_in_user(Fabricate(:user, admin: true, trust_level: 4))
post :create, params: {
title: title, raw: "[poll]\n- A\n- B\n[/poll]"
}, format: :json
post :create, params: { title: title, raw: "[poll]\n- A\n- B\n[/poll]" }, format: :json
expect(response.status).to eq(200)
json = response.parsed_body
expect(json["cooked"]).to eq("<p>[poll]</p>\n<ul>\n<li>A</li>\n<li>B<br>\n[/poll]</li>\n</ul>")
expect(json["cooked"]).to eq(
"<p>[poll]</p>\n<ul>\n<li>A</li>\n<li>B<br>\n[/poll]</li>\n</ul>",
)
end
end
describe "regular user with insufficient trust level" do
before do
SiteSetting.poll_minimum_trust_level_to_create = 2
end
before { SiteSetting.poll_minimum_trust_level_to_create = 2 }
it "invalidates the post" do
log_in_user(Fabricate(:user, trust_level: 1))
post :create, params: {
title: title, raw: "[poll]\n- A\n- B\n[/poll]"
}, format: :json
post :create, params: { title: title, raw: "[poll]\n- A\n- B\n[/poll]" }, format: :json
expect(response).not_to be_successful
json = response.parsed_body
@ -371,33 +387,31 @@ RSpec.describe PostsController do
it "skips the check in PMs with bots" do
user = Fabricate(:user, trust_level: 1)
topic = Fabricate(:private_message_topic, topic_allowed_users: [
Fabricate.build(:topic_allowed_user, user: user),
Fabricate.build(:topic_allowed_user, user: Discourse.system_user)
])
topic =
Fabricate(
:private_message_topic,
topic_allowed_users: [
Fabricate.build(:topic_allowed_user, user: user),
Fabricate.build(:topic_allowed_user, user: Discourse.system_user),
],
)
Fabricate(:post, topic_id: topic.id, user_id: Discourse::SYSTEM_USER_ID)
log_in_user(user)
post :create, params: {
topic_id: topic.id, raw: "[poll]\n- A\n- B\n[/poll]"
}, format: :json
post :create, params: { topic_id: topic.id, raw: "[poll]\n- A\n- B\n[/poll]" }, format: :json
expect(response.parsed_body["errors"]).to eq(nil)
end
end
describe "regular user with equal trust level" do
before do
SiteSetting.poll_minimum_trust_level_to_create = 2
end
before { SiteSetting.poll_minimum_trust_level_to_create = 2 }
it "validates the post" do
log_in_user(Fabricate(:user, trust_level: 2))
post :create, params: {
title: title, raw: "[poll]\n- A\n- B\n[/poll]"
}, format: :json
post :create, params: { title: title, raw: "[poll]\n- A\n- B\n[/poll]" }, format: :json
expect(response.status).to eq(200)
json = response.parsed_body
@ -407,16 +421,12 @@ RSpec.describe PostsController do
end
describe "regular user with superior trust level" do
before do
SiteSetting.poll_minimum_trust_level_to_create = 2
end
before { SiteSetting.poll_minimum_trust_level_to_create = 2 }
it "validates the post" do
log_in_user(Fabricate(:user, trust_level: 3))
post :create, params: {
title: title, raw: "[poll]\n- A\n- B\n[/poll]"
}, format: :json
post :create, params: { title: title, raw: "[poll]\n- A\n- B\n[/poll]" }, format: :json
expect(response.status).to eq(200)
json = response.parsed_body
@ -426,16 +436,12 @@ RSpec.describe PostsController do
end
describe "staff with insufficient trust level" do
before do
SiteSetting.poll_minimum_trust_level_to_create = 2
end
before { SiteSetting.poll_minimum_trust_level_to_create = 2 }
it "validates the post" do
log_in_user(Fabricate(:user, moderator: true, trust_level: 1))
post :create, params: {
title: title, raw: "[poll]\n- A\n- B\n[/poll]"
}, format: :json
post :create, params: { title: title, raw: "[poll]\n- A\n- B\n[/poll]" }, format: :json
expect(response.status).to eq(200)
json = response.parsed_body
@ -445,9 +451,7 @@ RSpec.describe PostsController do
end
describe "staff editing posts of users with insufficient trust level" do
before do
SiteSetting.poll_minimum_trust_level_to_create = 2
end
before { SiteSetting.poll_minimum_trust_level_to_create = 2 }
it "validates the post" do
log_in_user(Fabricate(:user, trust_level: 1))
@ -459,9 +463,14 @@ RSpec.describe PostsController do
log_in_user(Fabricate(:admin))
put :update, params: {
id: post_id, post: { raw: "#{title}\n[poll]\n- A\n- B\n- C\n[/poll]" }
}, format: :json
put :update,
params: {
id: post_id,
post: {
raw: "#{title}\n[poll]\n- A\n- B\n- C\n[/poll]",
},
},
format: :json
expect(response.status).to eq(200)
expect(response.parsed_body["post"]["polls"][0]["options"][2]["html"]).to eq("C")

View File

@ -7,31 +7,25 @@ RSpec.describe "DiscoursePoll endpoints" do
fab!(:user) { Fabricate(:user) }
fab!(:post) { Fabricate(:post, raw: "[poll public=true]\n- A\n- B\n[/poll]") }
fab!(:post_with_multiple_poll) do
Fabricate(:post, raw: <<~SQL)
fab!(:post_with_multiple_poll) { Fabricate(:post, raw: <<~SQL) }
[poll type=multiple public=true min=1 max=2]
- A
- B
- C
[/poll]
SQL
end
let(:option_a) { "5c24fc1df56d764b550ceae1b9319125" }
let(:option_b) { "e89dec30bbd9bf50fabf6a05b4324edf" }
it "should return the right response" do
DiscoursePoll::Poll.vote(
user,
post.id,
DiscoursePoll::DEFAULT_POLL_NAME,
[option_a]
)
DiscoursePoll::Poll.vote(user, post.id, DiscoursePoll::DEFAULT_POLL_NAME, [option_a])
get "/polls/voters.json", params: {
post_id: post.id,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME
}
get "/polls/voters.json",
params: {
post_id: post.id,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME,
}
expect(response.status).to eq(200)
@ -43,19 +37,20 @@ RSpec.describe "DiscoursePoll endpoints" do
expect(option.first["username"]).to eq(user.username)
end
it 'should return the right response for a single option' do
it "should return the right response for a single option" do
DiscoursePoll::Poll.vote(
user,
post_with_multiple_poll.id,
DiscoursePoll::DEFAULT_POLL_NAME,
[option_a, option_b]
[option_a, option_b],
)
get "/polls/voters.json", params: {
post_id: post_with_multiple_poll.id,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME,
option_id: option_b
}
get "/polls/voters.json",
params: {
post_id: post_with_multiple_poll.id,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME,
option_id: option_b,
}
expect(response.status).to eq(200)
@ -70,56 +65,60 @@ RSpec.describe "DiscoursePoll endpoints" do
expect(option.first["username"]).to eq(user.username)
end
describe 'when post_id is blank' do
it 'should raise the right error' do
describe "when post_id is blank" do
it "should raise the right error" do
get "/polls/voters.json", params: { poll_name: DiscoursePoll::DEFAULT_POLL_NAME }
expect(response.status).to eq(400)
end
end
describe 'when post_id is not valid' do
it 'should raise the right error' do
get "/polls/voters.json", params: {
post_id: -1,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME
}
describe "when post_id is not valid" do
it "should raise the right error" do
get "/polls/voters.json",
params: {
post_id: -1,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME,
}
expect(response.status).to eq(400)
expect(response.body).to include('post_id')
expect(response.body).to include("post_id")
end
end
describe 'when poll_name is blank' do
it 'should raise the right error' do
describe "when poll_name is blank" do
it "should raise the right error" do
get "/polls/voters.json", params: { post_id: post.id }
expect(response.status).to eq(400)
end
end
describe 'when poll_name is not valid' do
it 'should raise the right error' do
get "/polls/voters.json", params: { post_id: post.id, poll_name: 'wrongpoll' }
describe "when poll_name is not valid" do
it "should raise the right error" do
get "/polls/voters.json", params: { post_id: post.id, poll_name: "wrongpoll" }
expect(response.status).to eq(400)
expect(response.body).to include('poll_name')
expect(response.body).to include("poll_name")
end
end
context "with number poll" do
let(:post) { Fabricate(:post, raw: "[poll type=number min=1 max=20 step=1 public=true]\n[/poll]") }
let(:post) do
Fabricate(:post, raw: "[poll type=number min=1 max=20 step=1 public=true]\n[/poll]")
end
it 'should return the right response' do
it "should return the right response" do
post
DiscoursePoll::Poll.vote(
user,
post.id,
DiscoursePoll::DEFAULT_POLL_NAME,
["4d8a15e3cc35750f016ce15a43937620"]
["4d8a15e3cc35750f016ce15a43937620"],
)
get "/polls/voters.json", params: {
post_id: post.id,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME
}
get "/polls/voters.json",
params: {
post_id: post.id,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME,
}
expect(response.status).to eq(200)
@ -137,31 +136,25 @@ RSpec.describe "DiscoursePoll endpoints" do
fab!(:user3) { Fabricate(:user) }
fab!(:user4) { Fabricate(:user) }
fab!(:post) do
Fabricate(:post, raw: <<~SQL)
fab!(:post) { Fabricate(:post, raw: <<~SQL) }
[poll type=multiple public=true min=1 max=2]
- A
- B
[/poll]
SQL
end
let(:option_a) { "5c24fc1df56d764b550ceae1b9319125" }
let(:option_b) { "e89dec30bbd9bf50fabf6a05b4324edf" }
before do
user_votes = {
user_0: option_a,
user_1: option_a,
user_2: option_b,
}
user_votes = { user_0: option_a, user_1: option_a, user_2: option_b }
[user1, user2, user3].each_with_index do |user, index|
DiscoursePoll::Poll.vote(
user,
post.id,
DiscoursePoll::DEFAULT_POLL_NAME,
[user_votes["user_#{index}".to_sym]]
[user_votes["user_#{index}".to_sym]],
)
UserCustomField.create(user_id: user.id, name: "something", value: "value#{index}")
end
@ -171,7 +164,7 @@ RSpec.describe "DiscoursePoll endpoints" do
user4,
post.id,
DiscoursePoll::DEFAULT_POLL_NAME,
[option_a, option_b]
[option_a, option_b],
)
UserCustomField.create(user_id: user4.id, name: "something", value: "value1")
end
@ -179,32 +172,52 @@ RSpec.describe "DiscoursePoll endpoints" do
it "returns grouped poll results based on user field" do
SiteSetting.poll_groupable_user_fields = "something"
get "/polls/grouped_poll_results.json", params: {
post_id: post.id,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME,
user_field_name: "something"
}
get "/polls/grouped_poll_results.json",
params: {
post_id: post.id,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME,
user_field_name: "something",
}
expect(response.status).to eq(200)
expect(response.parsed_body.deep_symbolize_keys).to eq(
grouped_results: [
{ group: "Value0", options: [{ digest: option_a, html: "A", votes: 1 }, { digest: option_b, html: "B", votes: 0 }] },
{ group: "Value1", options: [{ digest: option_a, html: "A", votes: 2 }, { digest: option_b, html: "B", votes: 1 }] },
{ group: "Value2", options: [{ digest: option_a, html: "A", votes: 0 }, { digest: option_b, html: "B", votes: 1 }] },
]
{
group: "Value0",
options: [
{ digest: option_a, html: "A", votes: 1 },
{ digest: option_b, html: "B", votes: 0 },
],
},
{
group: "Value1",
options: [
{ digest: option_a, html: "A", votes: 2 },
{ digest: option_b, html: "B", votes: 1 },
],
},
{
group: "Value2",
options: [
{ digest: option_a, html: "A", votes: 0 },
{ digest: option_b, html: "B", votes: 1 },
],
},
],
)
end
it "returns an error when poll_groupable_user_fields is empty" do
SiteSetting.poll_groupable_user_fields = ""
get "/polls/grouped_poll_results.json", params: {
post_id: post.id,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME,
user_field_name: "something"
}
get "/polls/grouped_poll_results.json",
params: {
post_id: post.id,
poll_name: DiscoursePoll::DEFAULT_POLL_NAME,
user_field_name: "something",
}
expect(response.status).to eq(400)
expect(response.body).to include('user_field_name')
expect(response.body).to include("user_field_name")
end
end
end

View File

@ -5,15 +5,17 @@ require "rails_helper"
RSpec.describe Jobs::ClosePoll do
let(:post) { Fabricate(:post, raw: "[poll]\n- A\n- B\n[/poll]") }
describe 'missing arguments' do
it 'should raise the right error' do
expect do
Jobs::ClosePoll.new.execute(post_id: post.id)
end.to raise_error(Discourse::InvalidParameters, "poll_name")
describe "missing arguments" do
it "should raise the right error" do
expect do Jobs::ClosePoll.new.execute(post_id: post.id) end.to raise_error(
Discourse::InvalidParameters,
"poll_name",
)
expect do
Jobs::ClosePoll.new.execute(poll_name: "poll")
end.to raise_error(Discourse::InvalidParameters, "post_id")
expect do Jobs::ClosePoll.new.execute(poll_name: "poll") end.to raise_error(
Discourse::InvalidParameters,
"post_id",
)
end
end
@ -24,5 +26,4 @@ RSpec.describe Jobs::ClosePoll do
expect(post.polls.first.closed?).to eq(true)
end
end

View File

@ -7,9 +7,7 @@ RSpec.describe NewPostManager do
let(:admin) { Fabricate(:admin) }
describe "when new post containing a poll is queued for approval" do
before do
SiteSetting.poll_minimum_trust_level_to_create = 0
end
before { SiteSetting.poll_minimum_trust_level_to_create = 0 }
let(:params) do
{
@ -23,9 +21,10 @@ RSpec.describe NewPostManager do
is_warning: false,
title: "This is a test post with a poll",
ip_address: "127.0.0.1",
user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 Safari/537.36",
user_agent:
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 Safari/537.36",
referrer: "http://localhost:3000/",
first_post_checks: true
first_post_checks: true,
}
end
@ -38,7 +37,7 @@ RSpec.describe NewPostManager do
expect(Poll.where(post: review_result.created_post).exists?).to eq(true)
end
it 're-validates the poll when the approve_post event is triggered' do
it "re-validates the poll when the approve_post event is triggered" do
invalid_raw_poll = <<~MD
[poll type=multiple min=0]
* 1

View File

@ -4,17 +4,14 @@ RSpec.describe DiscoursePoll::Poll do
fab!(:user) { Fabricate(:user) }
fab!(:user_2) { Fabricate(:user) }
fab!(:post_with_regular_poll) do
Fabricate(:post, raw: <<~RAW)
fab!(:post_with_regular_poll) { Fabricate(:post, raw: <<~RAW) }
[poll]
* 1
* 2
[/poll]
RAW
end
fab!(:post_with_multiple_poll) do
Fabricate(:post, raw: <<~RAW)
fab!(:post_with_multiple_poll) { Fabricate(:post, raw: <<~RAW) }
[poll type=multiple min=2 max=3]
* 1
* 2
@ -23,10 +20,9 @@ RSpec.describe DiscoursePoll::Poll do
* 5
[/poll]
RAW
end
describe '.vote' do
it 'should only allow one vote per user for a regular poll' do
describe ".vote" do
it "should only allow one vote per user for a regular poll" do
poll = post_with_regular_poll.polls.first
expect do
@ -34,46 +30,35 @@ RSpec.describe DiscoursePoll::Poll do
user,
post_with_regular_poll.id,
"poll",
poll.poll_options.map(&:digest)
poll.poll_options.map(&:digest),
)
end.to raise_error(DiscoursePoll::Error, I18n.t("poll.one_vote_per_user"))
end
it 'should clean up bad votes for a regular poll' do
it "should clean up bad votes for a regular poll" do
poll = post_with_regular_poll.polls.first
PollVote.create!(
poll: poll,
poll_option: poll.poll_options.first,
user: user
)
PollVote.create!(poll: poll, poll_option: poll.poll_options.first, user: user)
PollVote.create!(
poll: poll,
poll_option: poll.poll_options.last,
user: user
)
PollVote.create!(poll: poll, poll_option: poll.poll_options.last, user: user)
DiscoursePoll::Poll.vote(
user,
post_with_regular_poll.id,
"poll",
[poll.poll_options.first.digest]
[poll.poll_options.first.digest],
)
expect(PollVote.where(poll: poll, user: user).pluck(:poll_option_id))
.to contain_exactly(poll.poll_options.first.id)
expect(PollVote.where(poll: poll, user: user).pluck(:poll_option_id)).to contain_exactly(
poll.poll_options.first.id,
)
end
it 'allows user to vote on multiple options correctly for a multiple poll' do
it "allows user to vote on multiple options correctly for a multiple poll" do
poll = post_with_multiple_poll.polls.first
poll_options = poll.poll_options
[
poll_options.first,
poll_options.second,
poll_options.third,
].each do |poll_option|
[poll_options.first, poll_options.second, poll_options.third].each do |poll_option|
PollVote.create!(poll: poll, poll_option: poll_option, user: user)
end
@ -81,24 +66,28 @@ RSpec.describe DiscoursePoll::Poll do
user,
post_with_multiple_poll.id,
"poll",
[poll_options.first.digest, poll_options.second.digest]
[poll_options.first.digest, poll_options.second.digest],
)
DiscoursePoll::Poll.vote(
user_2,
post_with_multiple_poll.id,
"poll",
[poll_options.third.digest, poll_options.fourth.digest]
[poll_options.third.digest, poll_options.fourth.digest],
)
expect(PollVote.where(poll: poll, user: user).pluck(:poll_option_id))
.to contain_exactly(poll_options.first.id, poll_options.second.id)
expect(PollVote.where(poll: poll, user: user).pluck(:poll_option_id)).to contain_exactly(
poll_options.first.id,
poll_options.second.id,
)
expect(PollVote.where(poll: poll, user: user_2).pluck(:poll_option_id))
.to contain_exactly(poll_options.third.id, poll_options.fourth.id)
expect(PollVote.where(poll: poll, user: user_2).pluck(:poll_option_id)).to contain_exactly(
poll_options.third.id,
poll_options.fourth.id,
)
end
it 'should respect the min/max votes per user for a multiple poll' do
it "should respect the min/max votes per user for a multiple poll" do
poll = post_with_multiple_poll.polls.first
expect do
@ -106,27 +95,21 @@ RSpec.describe DiscoursePoll::Poll do
user,
post_with_multiple_poll.id,
"poll",
poll.poll_options.map(&:digest)
poll.poll_options.map(&:digest),
)
end.to raise_error(
DiscoursePoll::Error,
I18n.t("poll.max_vote_per_user", count: poll.max)
)
end.to raise_error(DiscoursePoll::Error, I18n.t("poll.max_vote_per_user", count: poll.max))
expect do
DiscoursePoll::Poll.vote(
user,
post_with_multiple_poll.id,
"poll",
[poll.poll_options.first.digest]
[poll.poll_options.first.digest],
)
end.to raise_error(
DiscoursePoll::Error,
I18n.t("poll.min_vote_per_user", count: poll.min)
)
end.to raise_error(DiscoursePoll::Error, I18n.t("poll.min_vote_per_user", count: poll.min))
end
it 'should allow user to vote on a multiple poll even if min option is not configured' do
it "should allow user to vote on a multiple poll even if min option is not configured" do
post_with_multiple_poll = Fabricate(:post, raw: <<~RAW)
[poll type=multiple max=3]
* 1
@ -143,14 +126,15 @@ RSpec.describe DiscoursePoll::Poll do
user,
post_with_multiple_poll.id,
"poll",
[poll.poll_options.first.digest]
[poll.poll_options.first.digest],
)
expect(PollVote.where(poll: poll, user: user).pluck(:poll_option_id))
.to contain_exactly(poll.poll_options.first.id)
expect(PollVote.where(poll: poll, user: user).pluck(:poll_option_id)).to contain_exactly(
poll.poll_options.first.id,
)
end
it 'should allow user to vote on a multiple poll even if max option is not configured' do
it "should allow user to vote on a multiple poll even if max option is not configured" do
post_with_multiple_poll = Fabricate(:post, raw: <<~RAW)
[poll type=multiple min=1]
* 1
@ -167,11 +151,13 @@ RSpec.describe DiscoursePoll::Poll do
user,
post_with_multiple_poll.id,
"poll",
[poll.poll_options.first.digest, poll.poll_options.second.digest]
[poll.poll_options.first.digest, poll.poll_options.second.digest],
)
expect(PollVote.where(poll: poll, user: user).pluck(:poll_option_id))
.to contain_exactly(poll.poll_options.first.id, poll.poll_options.second.id)
expect(PollVote.where(poll: poll, user: user).pluck(:poll_option_id)).to contain_exactly(
poll.poll_options.first.id,
poll.poll_options.second.id,
)
end
end
@ -179,19 +165,14 @@ RSpec.describe DiscoursePoll::Poll do
it "publishes on message bus if a there are polls" do
first_post = Fabricate(:post)
topic = first_post.topic
creator = PostCreator.new(user,
topic_id: topic.id,
raw: <<~RAW
creator = PostCreator.new(user, topic_id: topic.id, raw: <<~RAW)
[poll]
* 1
* 2
[/poll]
RAW
)
messages = MessageBus.track_publish("/polls/#{topic.id}") do
creator.create!
end
messages = MessageBus.track_publish("/polls/#{topic.id}") { creator.create! }
expect(messages.count).to eq(1)
end
@ -199,20 +180,16 @@ RSpec.describe DiscoursePoll::Poll do
it "does not publish on message bus when a post with no polls is created" do
first_post = Fabricate(:post)
topic = first_post.topic
creator = PostCreator.new(user,
topic_id: topic.id,
raw: "Just a post with definitely no polls"
)
creator =
PostCreator.new(user, topic_id: topic.id, raw: "Just a post with definitely no polls")
messages = MessageBus.track_publish("/polls/#{topic.id}") do
creator.create!
end
messages = MessageBus.track_publish("/polls/#{topic.id}") { creator.create! }
expect(messages.count).to eq(0)
end
end
describe '.extract' do
describe ".extract" do
it "skips the polls inside quote" do
raw = <<~RAW
[quote="username, post:1, topic:2"]
@ -230,18 +207,17 @@ RSpec.describe DiscoursePoll::Poll do
Post with a poll and a quoted poll.
RAW
expect(DiscoursePoll::Poll.extract(raw, 2)).to contain_exactly({
"name" => "poll",
"options" => [{
"html" => "3",
"id" => "68b434ff88aeae7054e42cd05a4d9056"
}, {
"html" => "4",
"id" => "aa2393b424f2f395abb63bf785760a3b"
}],
"status" => "open",
"type" => "regular"
})
expect(DiscoursePoll::Poll.extract(raw, 2)).to contain_exactly(
{
"name" => "poll",
"options" => [
{ "html" => "3", "id" => "68b434ff88aeae7054e42cd05a4d9056" },
{ "html" => "4", "id" => "aa2393b424f2f395abb63bf785760a3b" },
],
"status" => "open",
"type" => "regular",
},
)
end
end
end

View File

@ -1,68 +1,54 @@
# frozen_string_literal: true
RSpec.describe DiscoursePoll::PollsUpdater do
def update(post, polls)
DiscoursePoll::PollsUpdater.update(post, polls)
end
let(:user) { Fabricate(:user) }
let(:post) {
Fabricate(:post, raw: <<~RAW)
let(:post) { Fabricate(:post, raw: <<~RAW) }
[poll]
* 1
* 2
[/poll]
RAW
}
let(:post_with_3_options) {
Fabricate(:post, raw: <<~RAW)
let(:post_with_3_options) { Fabricate(:post, raw: <<~RAW) }
[poll]
- a
- b
- c
[/poll]
RAW
}
let(:post_with_some_attributes) {
Fabricate(:post, raw: <<~RAW)
let(:post_with_some_attributes) { Fabricate(:post, raw: <<~RAW) }
[poll close=#{1.week.from_now.to_formatted_s(:iso8601)} results=on_close]
- A
- B
- C
[/poll]
RAW
}
let(:polls) {
DiscoursePoll::PollsValidator.new(post).validate_polls
}
let(:polls) { DiscoursePoll::PollsValidator.new(post).validate_polls }
let(:polls_with_3_options) {
let(:polls_with_3_options) do
DiscoursePoll::PollsValidator.new(post_with_3_options).validate_polls
}
end
let(:polls_with_some_attributes) {
let(:polls_with_some_attributes) do
DiscoursePoll::PollsValidator.new(post_with_some_attributes).validate_polls
}
end
describe "update" do
it "does nothing when there are no changes" do
message = MessageBus.track_publish("/polls/#{post.topic_id}") do
update(post, polls)
end.first
message = MessageBus.track_publish("/polls/#{post.topic_id}") { update(post, polls) }.first
expect(message).to be(nil)
end
describe "when editing" do
let(:raw) do
<<~RAW
let(:raw) { <<~RAW }
This is a new poll with three options.
[poll type=multiple results=always min=1 max=2]
@ -71,7 +57,6 @@ RSpec.describe DiscoursePoll::PollsUpdater do
* third
[/poll]
RAW
end
let(:post) { Fabricate(:post, raw: raw) }
@ -84,11 +69,9 @@ RSpec.describe DiscoursePoll::PollsUpdater do
expect(post.errors[:base].size).to equal(0)
end
end
describe "deletes polls" do
it "that were removed" do
update(post, {})
@ -97,19 +80,15 @@ RSpec.describe DiscoursePoll::PollsUpdater do
expect(Poll.where(post: post).exists?).to eq(false)
expect(post.custom_fields[DiscoursePoll::HAS_POLLS]).to eq(nil)
end
end
describe "creates polls" do
it "that were added" do
post = Fabricate(:post)
expect(Poll.find_by(post: post)).to_not be
message = MessageBus.track_publish("/polls/#{post.topic_id}") do
update(post, polls)
end.first
message = MessageBus.track_publish("/polls/#{post.topic_id}") { update(post, polls) }.first
poll = Poll.find_by(post: post)
@ -121,21 +100,19 @@ RSpec.describe DiscoursePoll::PollsUpdater do
expect(message.data[:post_id]).to eq(post.id)
expect(message.data[:polls][0][:name]).to eq(poll.name)
end
end
describe "updates polls" do
describe "when there are no votes" do
it "at any time" do
post # create the post
freeze_time 1.month.from_now
message = MessageBus.track_publish("/polls/#{post.topic_id}") do
update(post, polls_with_some_attributes)
end.first
message =
MessageBus
.track_publish("/polls/#{post.topic_id}") { update(post, polls_with_some_attributes) }
.first
poll = Poll.find_by(post: post)
@ -150,11 +127,9 @@ RSpec.describe DiscoursePoll::PollsUpdater do
expect(message.data[:post_id]).to eq(post.id)
expect(message.data[:polls][0][:name]).to eq(poll.name)
end
end
describe "when there are votes" do
before do
expect {
DiscoursePoll::Poll.vote(user, post.id, "poll", [polls["poll"]["options"][0]["id"]])
@ -162,11 +137,13 @@ RSpec.describe DiscoursePoll::PollsUpdater do
end
describe "inside the edit window" do
it "and deletes the votes" do
message = MessageBus.track_publish("/polls/#{post.topic_id}") do
update(post, polls_with_some_attributes)
end.first
message =
MessageBus
.track_publish("/polls/#{post.topic_id}") do
update(post, polls_with_some_attributes)
end
.first
poll = Poll.find_by(post: post)
@ -181,11 +158,9 @@ RSpec.describe DiscoursePoll::PollsUpdater do
expect(message.data[:post_id]).to eq(post.id)
expect(message.data[:polls][0][:name]).to eq(poll.name)
end
end
describe "outside the edit window" do
it "throws an error" do
edit_window = SiteSetting.poll_edit_window_mins
@ -204,17 +179,12 @@ RSpec.describe DiscoursePoll::PollsUpdater do
expect(post.errors[:base]).to include(
I18n.t(
"poll.edit_window_expired.cannot_edit_default_poll_with_votes",
minutes: edit_window
)
minutes: edit_window,
),
)
end
end
end
end
end
end

Some files were not shown because too many files have changed in this diff Show More