mirror of
https://github.com/discourse/discourse.git
synced 2026-08-13 06:25:11 -05:00
DEV: Move discourse-chat to the core repo. (#18776)
As part of this move, we are also renaming `discourse-chat` to `chat`.
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# From time to time, site admins may choose to sunset a chat channel and archive
|
||||
# the messages within. The main use case for this is a topic-based channel, but
|
||||
# it can be used for category channels just fine. It cannot be used for DM channels
|
||||
# in its current iteration.
|
||||
#
|
||||
# To archive a channel, we mark it read_only first to prevent any further message
|
||||
# additions or changes, and create a record to track whether the archive topic
|
||||
# will be new or existing. When we archive the channel, messages are copied into
|
||||
# posts in batches using the [chat] BBCode to quote the messages. The messages are
|
||||
# deleted once the batch has its post made. The execute action of this class is
|
||||
# idempotent, so if we fail halfway through the archive process it can be run again.
|
||||
#
|
||||
# Once all of the messages have been copied then we mark the channel as archived.
|
||||
class Chat::ChatChannelArchiveService
|
||||
ARCHIVED_MESSAGES_PER_POST = 100
|
||||
|
||||
def self.begin_archive_process(chat_channel:, acting_user:, topic_params:)
|
||||
return if ChatChannelArchive.exists?(chat_channel: chat_channel)
|
||||
|
||||
ChatChannelArchive.transaction do
|
||||
chat_channel.read_only!(acting_user)
|
||||
|
||||
archive =
|
||||
ChatChannelArchive.create!(
|
||||
chat_channel: chat_channel,
|
||||
archived_by: acting_user,
|
||||
total_messages: chat_channel.chat_messages.count,
|
||||
destination_topic_id: topic_params[:topic_id],
|
||||
destination_topic_title: topic_params[:topic_title],
|
||||
destination_category_id: topic_params[:category_id],
|
||||
destination_tags: topic_params[:tags],
|
||||
)
|
||||
Jobs.enqueue(:chat_channel_archive, chat_channel_archive_id: archive.id)
|
||||
|
||||
archive
|
||||
end
|
||||
end
|
||||
|
||||
def self.retry_archive_process(chat_channel:)
|
||||
return if !chat_channel.chat_channel_archive&.failed?
|
||||
Jobs.enqueue(
|
||||
:chat_channel_archive,
|
||||
chat_channel_archive_id: chat_channel.chat_channel_archive.id,
|
||||
)
|
||||
end
|
||||
|
||||
attr_reader :chat_channel_archive, :chat_channel, :chat_channel_title
|
||||
|
||||
def initialize(chat_channel_archive)
|
||||
@chat_channel_archive = chat_channel_archive
|
||||
@chat_channel = chat_channel_archive.chat_channel
|
||||
@chat_channel_title = chat_channel.title(chat_channel_archive.archived_by)
|
||||
end
|
||||
|
||||
def execute
|
||||
chat_channel_archive.update(archive_error: nil)
|
||||
|
||||
begin
|
||||
ensure_destination_topic_exists!
|
||||
|
||||
Rails.logger.info(
|
||||
"Creating posts from message batches for #{chat_channel_title} archive, #{chat_channel_archive.total_messages} messages to archive (#{chat_channel_archive.total_messages / ARCHIVED_MESSAGES_PER_POST} posts).",
|
||||
)
|
||||
|
||||
# a batch should be idempotent, either the post is created and the
|
||||
# messages are deleted or we roll back the whole thing.
|
||||
#
|
||||
# at some point we may want to reconsider disabling post validations,
|
||||
# and add in things like dynamic resizing of the number of messages per
|
||||
# post based on post length, but that can be done later
|
||||
#
|
||||
# another future improvement is to send a MessageBus message for each
|
||||
# completed batch, so the UI can receive updates and show a progress
|
||||
# bar or something similar
|
||||
chat_channel
|
||||
.chat_messages
|
||||
.find_in_batches(batch_size: ARCHIVED_MESSAGES_PER_POST) do |chat_messages|
|
||||
create_post(
|
||||
ChatTranscriptService.new(
|
||||
chat_channel,
|
||||
chat_channel_archive.archived_by,
|
||||
messages_or_ids: chat_messages,
|
||||
opts: {
|
||||
no_link: true,
|
||||
include_reactions: true,
|
||||
},
|
||||
).generate_markdown,
|
||||
) { delete_message_batch(chat_messages.map(&:id)) }
|
||||
end
|
||||
|
||||
kick_all_users
|
||||
complete_archive
|
||||
rescue => err
|
||||
notify_archiver(:failed, error: err)
|
||||
raise err
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_post(raw)
|
||||
pc = nil
|
||||
Post.transaction do
|
||||
pc =
|
||||
PostCreator.new(
|
||||
Discourse.system_user,
|
||||
raw: raw,
|
||||
# we must skip these because the posts are created in a big transaction,
|
||||
# we do them all at the end instead
|
||||
skip_jobs: true,
|
||||
# we do not want to be sending out notifications etc. from this
|
||||
# automatic background process
|
||||
import_mode: true,
|
||||
# don't want to be stopped by watched word or post length validations
|
||||
skip_validations: true,
|
||||
topic_id: chat_channel_archive.destination_topic_id,
|
||||
)
|
||||
|
||||
pc.create
|
||||
|
||||
# so we can also delete chat messages in the same transaction
|
||||
yield if block_given?
|
||||
end
|
||||
pc.enqueue_jobs
|
||||
end
|
||||
|
||||
def ensure_destination_topic_exists!
|
||||
if !chat_channel_archive.destination_topic.present?
|
||||
Rails.logger.info("Creating topic for #{chat_channel_title} archive.")
|
||||
Topic.transaction do
|
||||
topic_creator =
|
||||
TopicCreator.new(
|
||||
Discourse.system_user,
|
||||
Guardian.new(chat_channel_archive.archived_by),
|
||||
{
|
||||
title: chat_channel_archive.destination_topic_title,
|
||||
category: chat_channel_archive.destination_category_id,
|
||||
tags: chat_channel_archive.destination_tags,
|
||||
import_mode: true,
|
||||
},
|
||||
)
|
||||
|
||||
chat_channel_archive.update!(destination_topic: topic_creator.create)
|
||||
end
|
||||
|
||||
Rails.logger.info("Creating first post for #{chat_channel_title} archive.")
|
||||
create_post(
|
||||
I18n.t(
|
||||
"chat.channel.archive.first_post_raw",
|
||||
channel_name: chat_channel_title,
|
||||
channel_url: chat_channel.url,
|
||||
),
|
||||
)
|
||||
else
|
||||
Rails.logger.info("Topic already exists for #{chat_channel_title} archive.")
|
||||
end
|
||||
|
||||
update_destination_topic_status
|
||||
end
|
||||
|
||||
def update_destination_topic_status
|
||||
# we only want to do this when the destination topic is new, not an
|
||||
# existing topic, because we don't want to update the status unexpectedly
|
||||
# on an existing topic
|
||||
if chat_channel_archive.destination_topic_title.present?
|
||||
if SiteSetting.chat_archive_destination_topic_status == "archived"
|
||||
chat_channel_archive.destination_topic.update!(archived: true)
|
||||
elsif SiteSetting.chat_archive_destination_topic_status == "closed"
|
||||
chat_channel_archive.destination_topic.update!(closed: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def delete_message_batch(message_ids)
|
||||
ChatMessage.transaction do
|
||||
ChatMessage.where(id: message_ids).update_all(
|
||||
deleted_at: DateTime.now,
|
||||
deleted_by_id: chat_channel_archive.archived_by.id,
|
||||
)
|
||||
|
||||
chat_channel_archive.update!(
|
||||
archived_messages: chat_channel_archive.archived_messages + message_ids.length,
|
||||
)
|
||||
end
|
||||
|
||||
Rails.logger.info(
|
||||
"Archived #{chat_channel_archive.archived_messages} messages for #{chat_channel_title} archive.",
|
||||
)
|
||||
end
|
||||
|
||||
def complete_archive
|
||||
Rails.logger.info("Creating posts completed for #{chat_channel_title} archive.")
|
||||
chat_channel.archived!(chat_channel_archive.archived_by)
|
||||
notify_archiver(:success)
|
||||
end
|
||||
|
||||
def notify_archiver(result, error: nil)
|
||||
base_translation_params = {
|
||||
channel_name: chat_channel_title,
|
||||
topic_title: chat_channel_archive.destination_topic.title,
|
||||
topic_url: chat_channel_archive.destination_topic.url,
|
||||
}
|
||||
|
||||
if result == :failed
|
||||
Discourse.warn_exception(
|
||||
error,
|
||||
message: "Error when archiving chat channel #{chat_channel_title}.",
|
||||
env: {
|
||||
chat_channel_id: chat_channel.id,
|
||||
chat_channel_name: chat_channel_title,
|
||||
},
|
||||
)
|
||||
error_translation_params =
|
||||
base_translation_params.merge(
|
||||
channel_url: chat_channel.url,
|
||||
messages_archived: chat_channel_archive.archived_messages,
|
||||
)
|
||||
chat_channel_archive.update(archive_error: error.message)
|
||||
SystemMessage.create_from_system_user(
|
||||
chat_channel_archive.archived_by,
|
||||
:chat_channel_archive_failed,
|
||||
error_translation_params,
|
||||
)
|
||||
else
|
||||
SystemMessage.create_from_system_user(
|
||||
chat_channel_archive.archived_by,
|
||||
:chat_channel_archive_complete,
|
||||
base_translation_params,
|
||||
)
|
||||
end
|
||||
|
||||
ChatPublisher.publish_archive_status(
|
||||
chat_channel,
|
||||
archive_status: result,
|
||||
archived_messages: chat_channel_archive.archived_messages,
|
||||
archive_topic_id: chat_channel_archive.destination_topic_id,
|
||||
total_messages: chat_channel_archive.total_messages,
|
||||
)
|
||||
end
|
||||
|
||||
def kick_all_users
|
||||
Chat::ChatChannelMembershipManager.new(chat_channel).unfollow_all_users
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,221 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat::ChatChannelFetcher
|
||||
MAX_PUBLIC_CHANNEL_RESULTS = 50
|
||||
|
||||
def self.structured(guardian)
|
||||
memberships = Chat::ChatChannelMembershipManager.all_for_user(guardian.user)
|
||||
{
|
||||
public_channels:
|
||||
secured_public_channels(guardian, memberships, status: :open, following: true),
|
||||
direct_message_channels:
|
||||
secured_direct_message_channels(guardian.user.id, memberships, guardian),
|
||||
memberships: memberships,
|
||||
}
|
||||
end
|
||||
|
||||
def self.all_secured_channel_ids(guardian, following: true)
|
||||
allowed_channel_ids_sql = generate_allowed_channel_ids_sql(guardian)
|
||||
|
||||
return DB.query_single(allowed_channel_ids_sql) if !following
|
||||
|
||||
DB.query_single(<<~SQL, user_id: guardian.user.id)
|
||||
SELECT chat_channel_id
|
||||
FROM user_chat_channel_memberships
|
||||
WHERE user_chat_channel_memberships.user_id = :user_id
|
||||
AND user_chat_channel_memberships.chat_channel_id IN (
|
||||
#{allowed_channel_ids_sql}
|
||||
)
|
||||
SQL
|
||||
end
|
||||
|
||||
def self.generate_allowed_channel_ids_sql(guardian)
|
||||
<<~SQL
|
||||
-- secured category chat channels
|
||||
#{
|
||||
ChatChannel
|
||||
.select(:id)
|
||||
.joins(
|
||||
"INNER JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'",
|
||||
)
|
||||
.where(
|
||||
"categories.id IN (:allowed_category_ids)",
|
||||
allowed_category_ids: guardian.allowed_category_ids,
|
||||
)
|
||||
.to_sql
|
||||
}
|
||||
|
||||
UNION
|
||||
|
||||
-- secured direct message chat channels
|
||||
#{
|
||||
ChatChannel
|
||||
.select(:id)
|
||||
.joins(
|
||||
"INNER JOIN direct_message_channels ON direct_message_channels.id = chat_channels.chatable_id
|
||||
AND chat_channels.chatable_type = 'DirectMessageChannel'
|
||||
INNER JOIN direct_message_users ON direct_message_users.direct_message_channel_id = direct_message_channels.id",
|
||||
)
|
||||
.where("direct_message_users.user_id = :user_id", user_id: guardian.user.id)
|
||||
.to_sql
|
||||
}
|
||||
SQL
|
||||
end
|
||||
|
||||
def self.secured_public_channel_search(guardian, options = {})
|
||||
channels =
|
||||
ChatChannel
|
||||
.includes(:chat_channel_archive)
|
||||
.includes(chatable: [:topic_only_relative_url])
|
||||
.joins(
|
||||
"LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'",
|
||||
)
|
||||
.where(chatable_type: ChatChannel.public_channel_chatable_types)
|
||||
.where("chat_channels.id IN (#{generate_allowed_channel_ids_sql(guardian)})")
|
||||
|
||||
channels = channels.where(status: options[:status]) if options[:status].present?
|
||||
|
||||
if options[:filter].present?
|
||||
sql = "chat_channels.name ILIKE :filter OR categories.name ILIKE :filter"
|
||||
channels =
|
||||
channels.where(sql, filter: "%#{options[:filter].downcase}%").order(
|
||||
"chat_channels.name ASC, categories.name ASC",
|
||||
)
|
||||
end
|
||||
|
||||
if options.key?(:following)
|
||||
if options[:following]
|
||||
channels =
|
||||
channels.joins(:user_chat_channel_memberships).where(
|
||||
user_chat_channel_memberships: {
|
||||
user_id: guardian.user.id,
|
||||
following: true,
|
||||
},
|
||||
)
|
||||
else
|
||||
channels =
|
||||
channels.where(
|
||||
"chat_channels.id NOT IN (SELECT chat_channel_id FROM user_chat_channel_memberships uccm WHERE uccm.chat_channel_id = chat_channels.id AND following IS TRUE AND user_id = ?)",
|
||||
guardian.user.id,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
options[:limit] = (options[:limit] || MAX_PUBLIC_CHANNEL_RESULTS).to_i.clamp(
|
||||
1,
|
||||
MAX_PUBLIC_CHANNEL_RESULTS,
|
||||
)
|
||||
options[:offset] = [options[:offset].to_i, 0].max
|
||||
|
||||
channels.limit(options[:limit]).offset(options[:offset])
|
||||
end
|
||||
|
||||
def self.secured_public_channels(guardian, memberships, options = { following: true })
|
||||
channels = secured_public_channel_search(guardian, options)
|
||||
decorate_memberships_with_tracking_data(guardian, channels, memberships)
|
||||
channels = channels.to_a
|
||||
preload_custom_fields_for(channels)
|
||||
channels
|
||||
end
|
||||
|
||||
def self.preload_custom_fields_for(channels)
|
||||
preload_fields = Category.instance_variable_get(:@custom_field_types).keys
|
||||
Category.preload_custom_fields(
|
||||
channels.select { |c| c.chatable_type == "Category" }.map(&:chatable),
|
||||
preload_fields,
|
||||
)
|
||||
end
|
||||
|
||||
def self.secured_direct_message_channels(user_id, memberships, guardian)
|
||||
query = ChatChannel.includes(chatable: [{ direct_message_users: :user }, :users])
|
||||
query = query.includes(chatable: [{ users: :user_status }]) if SiteSetting.enable_user_status
|
||||
|
||||
channels =
|
||||
query
|
||||
.joins(:user_chat_channel_memberships)
|
||||
.where(user_chat_channel_memberships: { user_id: user_id, following: true })
|
||||
.where(chatable_type: "DirectMessageChannel")
|
||||
.where("chat_channels.id IN (#{generate_allowed_channel_ids_sql(guardian)})")
|
||||
.order(last_message_sent_at: :desc)
|
||||
.to_a
|
||||
|
||||
preload_fields =
|
||||
User.allowed_user_custom_fields(guardian) +
|
||||
UserField.all.pluck(:id).map { |fid| "#{User::USER_FIELD_PREFIX}#{fid}" }
|
||||
User.preload_custom_fields(channels.map { |c| c.chatable.users }.flatten, preload_fields)
|
||||
|
||||
decorate_memberships_with_tracking_data(guardian, channels, memberships)
|
||||
end
|
||||
|
||||
def self.decorate_memberships_with_tracking_data(guardian, channels, memberships)
|
||||
unread_counts_per_channel = unread_counts(channels, guardian.user.id)
|
||||
|
||||
mention_notifications =
|
||||
Notification.unread.where(
|
||||
user_id: guardian.user.id,
|
||||
notification_type: Notification.types[:chat_mention],
|
||||
)
|
||||
mention_notification_data = mention_notifications.map { |m| JSON.parse(m.data) }
|
||||
|
||||
channels.each do |channel|
|
||||
membership = memberships.find { |m| m.chat_channel_id == channel.id }
|
||||
|
||||
if membership
|
||||
membership.unread_mentions =
|
||||
mention_notification_data.count do |data|
|
||||
data["chat_channel_id"] == channel.id &&
|
||||
data["chat_message_id"] > (membership.last_read_message_id || 0)
|
||||
end
|
||||
|
||||
membership.unread_count = unread_counts_per_channel[channel.id] if !membership.muted
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.unread_counts(channels, user_id)
|
||||
unread_counts = DB.query_array(<<~SQL, channel_ids: channels.map(&:id), user_id: user_id).to_h
|
||||
SELECT cc.id, COUNT(*) as count
|
||||
FROM chat_messages cm
|
||||
JOIN chat_channels cc ON cc.id = cm.chat_channel_id
|
||||
JOIN user_chat_channel_memberships uccm ON uccm.chat_channel_id = cc.id
|
||||
WHERE cc.id IN (:channel_ids)
|
||||
AND cm.user_id != :user_id
|
||||
AND uccm.user_id = :user_id
|
||||
AND cm.id > COALESCE(uccm.last_read_message_id, 0)
|
||||
AND cm.deleted_at IS NULL
|
||||
GROUP BY cc.id
|
||||
SQL
|
||||
unread_counts.default = 0
|
||||
unread_counts
|
||||
end
|
||||
|
||||
def self.find_with_access_check(channel_id_or_name, guardian)
|
||||
begin
|
||||
channel_id_or_name = Integer(channel_id_or_name)
|
||||
rescue ArgumentError
|
||||
end
|
||||
|
||||
base_channel_relation =
|
||||
ChatChannel.includes(:chatable).joins(
|
||||
"LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'",
|
||||
)
|
||||
|
||||
if guardian.user.staff?
|
||||
base_channel_relation = base_channel_relation.includes(:chat_channel_archive)
|
||||
end
|
||||
|
||||
if channel_id_or_name.is_a? Integer
|
||||
chat_channel = base_channel_relation.find_by(id: channel_id_or_name)
|
||||
else
|
||||
chat_channel =
|
||||
base_channel_relation.find_by(
|
||||
"LOWER(categories.name) = :name OR LOWER(chat_channels.name) = :name",
|
||||
name: channel_id_or_name.downcase,
|
||||
)
|
||||
end
|
||||
|
||||
raise Discourse::NotFound if chat_channel.blank?
|
||||
raise Discourse::InvalidAccess if !guardian.can_see_chat_channel?(chat_channel)
|
||||
chat_channel
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::ChatChannelMembershipManager
|
||||
def self.all_for_user(user)
|
||||
UserChatChannelMembership.where(user: user)
|
||||
end
|
||||
|
||||
attr_reader :channel
|
||||
|
||||
def initialize(channel)
|
||||
@channel = channel
|
||||
end
|
||||
|
||||
def find_for_user(user, following: nil)
|
||||
params = { user_id: user.id, chat_channel_id: channel.id }
|
||||
params[:following] = following if following.present?
|
||||
|
||||
UserChatChannelMembership.includes(:user, :chat_channel).find_by(params)
|
||||
end
|
||||
|
||||
def follow(user)
|
||||
membership =
|
||||
find_for_user(user) ||
|
||||
UserChatChannelMembership.new(user: user, chat_channel: channel, following: true)
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
if membership.new_record?
|
||||
membership.save!
|
||||
recalculate_user_count
|
||||
elsif !membership.following
|
||||
membership.update!(following: true)
|
||||
recalculate_user_count
|
||||
end
|
||||
end
|
||||
|
||||
membership
|
||||
end
|
||||
|
||||
def unfollow(user)
|
||||
membership = find_for_user(user)
|
||||
|
||||
return if membership.blank?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
if membership.following
|
||||
membership.update!(following: false)
|
||||
recalculate_user_count
|
||||
end
|
||||
end
|
||||
|
||||
membership
|
||||
end
|
||||
|
||||
def recalculate_user_count
|
||||
return if ChatChannel.exists?(id: channel.id, user_count_stale: true)
|
||||
channel.update!(user_count_stale: true)
|
||||
Jobs.enqueue_in(3.seconds, :update_channel_user_count, chat_channel_id: channel.id)
|
||||
end
|
||||
|
||||
def unfollow_all_users
|
||||
UserChatChannelMembership.where(chat_channel: channel).update_all(
|
||||
following: false,
|
||||
last_read_message_id: channel.chat_messages.last&.id,
|
||||
)
|
||||
end
|
||||
|
||||
def enforce_automatic_channel_memberships
|
||||
Jobs.enqueue(:auto_manage_channel_memberships, chat_channel_id: channel.id)
|
||||
end
|
||||
|
||||
def enforce_automatic_user_membership(user)
|
||||
Jobs.enqueue(
|
||||
:auto_join_channel_batch,
|
||||
chat_channel_id: channel.id,
|
||||
starts_at: user.id,
|
||||
ends_at: user.id,
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::ChatMailer
|
||||
def self.send_unread_mentions_summary
|
||||
return unless SiteSetting.chat_enabled
|
||||
|
||||
users_with_unprocessed_unread_mentions.find_each do |user|
|
||||
# user#memberships_with_unread_messages is a nested array that looks like [[membership_id, unread_message_id]]
|
||||
# Find the max unread id per membership.
|
||||
membership_and_max_unread_mention_ids =
|
||||
user
|
||||
.memberships_with_unread_messages
|
||||
.group_by { |memberships| memberships[0] }
|
||||
.transform_values do |membership_and_msg_ids|
|
||||
membership_and_msg_ids.max_by { |membership, msg| msg }
|
||||
end
|
||||
.values
|
||||
|
||||
Jobs.enqueue(
|
||||
:user_email,
|
||||
type: "chat_summary",
|
||||
user_id: user.id,
|
||||
force_respect_seen_recently: true,
|
||||
memberships_to_update_data: membership_and_max_unread_mention_ids,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.users_with_unprocessed_unread_mentions
|
||||
when_away_frequency = UserOption.chat_email_frequencies[:when_away]
|
||||
allowed_group_ids = Chat.allowed_group_ids
|
||||
|
||||
User
|
||||
.select("users.id", "ARRAY_AGG(ARRAY[uccm.id, c_msg.id]) AS memberships_with_unread_messages")
|
||||
.joins(:user_option)
|
||||
.where(user_options: { chat_enabled: true, chat_email_frequency: when_away_frequency })
|
||||
.where("users.last_seen_at < ?", 15.minutes.ago)
|
||||
.joins(:groups)
|
||||
.where(groups: { id: allowed_group_ids })
|
||||
.joins("INNER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id")
|
||||
.joins("INNER JOIN chat_channels cc ON cc.id = uccm.chat_channel_id")
|
||||
.joins("INNER JOIN chat_messages c_msg ON c_msg.chat_channel_id = uccm.chat_channel_id")
|
||||
.joins("LEFT OUTER JOIN chat_mentions c_mentions ON c_mentions.chat_message_id = c_msg.id")
|
||||
.where("c_msg.deleted_at IS NULL AND c_msg.user_id <> users.id")
|
||||
.where("c_msg.created_at > ?", 1.week.ago)
|
||||
.where(<<~SQL)
|
||||
(uccm.last_read_message_id IS NULL OR c_msg.id > uccm.last_read_message_id) AND
|
||||
(uccm.last_unread_mention_when_emailed_id IS NULL OR c_msg.id > uccm.last_unread_mention_when_emailed_id) AND
|
||||
(
|
||||
(uccm.user_id = c_mentions.user_id AND uccm.following IS true AND cc.chatable_type = 'Category') OR
|
||||
(cc.chatable_type = 'DirectMessageChannel')
|
||||
)
|
||||
SQL
|
||||
.group("users.id, uccm.user_id")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,69 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ChatMessageBookmarkable < BaseBookmarkable
|
||||
def self.model
|
||||
ChatMessage
|
||||
end
|
||||
|
||||
def self.serializer
|
||||
UserChatMessageBookmarkSerializer
|
||||
end
|
||||
|
||||
def self.preload_associations
|
||||
[:chat_channel]
|
||||
end
|
||||
|
||||
def self.list_query(user, guardian)
|
||||
accessible_channel_ids = Chat::ChatChannelFetcher.all_secured_channel_ids(guardian)
|
||||
return if accessible_channel_ids.empty?
|
||||
user
|
||||
.bookmarks_of_type("ChatMessage")
|
||||
.joins(
|
||||
"INNER JOIN chat_messages ON chat_messages.id = bookmarks.bookmarkable_id
|
||||
AND chat_messages.deleted_at IS NULL
|
||||
AND bookmarks.bookmarkable_type = 'ChatMessage'",
|
||||
)
|
||||
.where("chat_messages.chat_channel_id IN (?)", accessible_channel_ids)
|
||||
end
|
||||
|
||||
def self.search_query(bookmarks, query, ts_query, &bookmarkable_search)
|
||||
bookmarkable_search.call(bookmarks, "chat_messages.message ILIKE :q")
|
||||
end
|
||||
|
||||
def self.validate_before_create(guardian, bookmarkable)
|
||||
if bookmarkable.blank? || !guardian.can_see_chat_channel?(bookmarkable.chat_channel)
|
||||
raise Discourse::InvalidAccess
|
||||
end
|
||||
end
|
||||
|
||||
def self.reminder_handler(bookmark)
|
||||
send_reminder_notification(
|
||||
bookmark,
|
||||
data: {
|
||||
title:
|
||||
I18n.t(
|
||||
"chat.bookmarkable.notification_title",
|
||||
channel_name: bookmark.bookmarkable.chat_channel.title(bookmark.user),
|
||||
),
|
||||
bookmarkable_url: bookmark.bookmarkable.url,
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
def self.reminder_conditions(bookmark)
|
||||
bookmark.bookmarkable.present? && bookmark.bookmarkable.chat_channel.present?
|
||||
end
|
||||
|
||||
def self.can_see?(guardian, bookmark)
|
||||
guardian.can_see_chat_channel?(bookmark.bookmarkable.chat_channel)
|
||||
end
|
||||
|
||||
def self.cleanup_deleted
|
||||
DB.query(<<~SQL, grace_time: 3.days.ago)
|
||||
DELETE FROM bookmarks b
|
||||
USING chat_messages cm
|
||||
WHERE b.bookmarkable_id = cm.id AND b.bookmarkable_type = 'ChatMessage'
|
||||
AND (cm.deleted_at < :grace_time)
|
||||
SQL
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,101 @@
|
||||
# frozen_string_literal: true
|
||||
class Chat::ChatMessageCreator
|
||||
attr_reader :error, :chat_message
|
||||
|
||||
def self.create(opts)
|
||||
instance = new(**opts)
|
||||
instance.create
|
||||
instance
|
||||
end
|
||||
|
||||
def initialize(
|
||||
chat_channel:,
|
||||
in_reply_to_id: nil,
|
||||
user:,
|
||||
content:,
|
||||
staged_id: nil,
|
||||
incoming_chat_webhook: nil,
|
||||
upload_ids: nil
|
||||
)
|
||||
@chat_channel = chat_channel
|
||||
@user = user
|
||||
@guardian = Guardian.new(user)
|
||||
@in_reply_to_id = in_reply_to_id
|
||||
@content = content
|
||||
@staged_id = staged_id
|
||||
@incoming_chat_webhook = incoming_chat_webhook
|
||||
@upload_ids = upload_ids || []
|
||||
@error = nil
|
||||
|
||||
@chat_message =
|
||||
ChatMessage.new(
|
||||
chat_channel: @chat_channel,
|
||||
user_id: @user.id,
|
||||
in_reply_to_id: @in_reply_to_id,
|
||||
message: @content,
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
begin
|
||||
validate_channel_status!
|
||||
uploads = get_uploads
|
||||
validate_message!(has_uploads: uploads.any?)
|
||||
@chat_message.cook
|
||||
@chat_message.save!
|
||||
create_chat_webhook_event
|
||||
@chat_message.attach_uploads(uploads)
|
||||
ChatDraft.where(user_id: @user.id, chat_channel_id: @chat_channel.id).destroy_all
|
||||
ChatPublisher.publish_new!(@chat_channel, @chat_message, @staged_id)
|
||||
Jobs.enqueue(:process_chat_message, { chat_message_id: @chat_message.id })
|
||||
Chat::ChatNotifier.notify_new(
|
||||
chat_message: @chat_message,
|
||||
timestamp: @chat_message.created_at,
|
||||
)
|
||||
rescue => error
|
||||
@error = error
|
||||
end
|
||||
end
|
||||
|
||||
def failed?
|
||||
@error.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_channel_status!
|
||||
return if @guardian.can_create_channel_message?(@chat_channel)
|
||||
|
||||
if @chat_channel.direct_message_channel? && !@guardian.can_create_direct_message?
|
||||
raise StandardError.new(I18n.t("chat.errors.user_cannot_send_direct_messages"))
|
||||
else
|
||||
raise StandardError.new(
|
||||
I18n.t(
|
||||
"chat.errors.channel_new_message_disallowed",
|
||||
status: @chat_channel.status_name,
|
||||
),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def validate_message!(has_uploads:)
|
||||
@chat_message.validate_message(has_uploads: has_uploads)
|
||||
if @chat_message.errors.present?
|
||||
raise StandardError.new(@chat_message.errors.map(&:full_message).join(", "))
|
||||
end
|
||||
end
|
||||
|
||||
def create_chat_webhook_event
|
||||
return if @incoming_chat_webhook.blank?
|
||||
ChatWebhookEvent.create(
|
||||
chat_message: @chat_message,
|
||||
incoming_chat_webhook: @incoming_chat_webhook,
|
||||
)
|
||||
end
|
||||
|
||||
def get_uploads
|
||||
return [] if @upload_ids.blank? || !SiteSetting.chat_allow_uploads
|
||||
|
||||
Upload.where(id: @upload_ids, user_id: @user.id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::ChatMessageProcessor
|
||||
include ::CookedProcessorMixin
|
||||
|
||||
def initialize(chat_message)
|
||||
@model = chat_message
|
||||
@previous_cooked = (chat_message.cooked || "").dup
|
||||
@with_secure_uploads = false
|
||||
@size_cache = {}
|
||||
@opts = {}
|
||||
|
||||
cooked = ChatMessage.cook(chat_message.message)
|
||||
@doc = Loofah.fragment(cooked)
|
||||
end
|
||||
|
||||
def run!
|
||||
post_process_oneboxes
|
||||
DiscourseEvent.trigger(:chat_message_processed, @doc, @model)
|
||||
end
|
||||
|
||||
def large_images
|
||||
[]
|
||||
end
|
||||
|
||||
def broken_images
|
||||
[]
|
||||
end
|
||||
|
||||
def downloaded_images
|
||||
{}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::ChatMessageRateLimiter
|
||||
def self.run!(user)
|
||||
instance = self.new(user)
|
||||
instance.run!
|
||||
end
|
||||
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def run!
|
||||
return if @user.staff?
|
||||
|
||||
allowed_message_count =
|
||||
(
|
||||
if @user.trust_level == TrustLevel[0]
|
||||
SiteSetting.chat_allowed_messages_for_trust_level_0
|
||||
else
|
||||
SiteSetting.chat_allowed_messages_for_other_trust_levels
|
||||
end
|
||||
)
|
||||
return if allowed_message_count.zero?
|
||||
|
||||
@rate_limiter = RateLimiter.new(@user, "create_chat_message", allowed_message_count, 30.seconds)
|
||||
silence_user if @rate_limiter.remaining.zero?
|
||||
@rate_limiter.performed!
|
||||
end
|
||||
|
||||
def clear!
|
||||
# Used only for testing. Need to clear the rate limiter between tests.
|
||||
@rate_limiter.clear! if defined?(@rate_limiter)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def silence_user
|
||||
silenced_for_minutes = SiteSetting.chat_auto_silence_duration
|
||||
return unless silenced_for_minutes > 0
|
||||
|
||||
UserSilencer.silence(
|
||||
@user,
|
||||
Discourse.system_user,
|
||||
silenced_till: silenced_for_minutes.minutes.from_now,
|
||||
reason: I18n.t("chat.errors.rate_limit_exceeded"),
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::ChatMessageReactor
|
||||
ADD_REACTION = :add
|
||||
REMOVE_REACTION = :remove
|
||||
MAX_REACTIONS_LIMIT = 30
|
||||
|
||||
def initialize(user, chat_channel)
|
||||
@user = user
|
||||
@chat_channel = chat_channel
|
||||
@guardian = Guardian.new(user)
|
||||
end
|
||||
|
||||
def react!(message_id:, react_action:, emoji:)
|
||||
@guardian.ensure_can_see_chat_channel!(@chat_channel)
|
||||
@guardian.ensure_can_react!
|
||||
validate_channel_status!
|
||||
validate_reaction!(react_action, emoji)
|
||||
message = ensure_chat_message!(message_id)
|
||||
validate_max_reactions!(message, react_action, emoji)
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
enforce_channel_membership!
|
||||
create_reaction(message, react_action, emoji)
|
||||
end
|
||||
|
||||
publish_reaction(message, react_action, emoji)
|
||||
|
||||
message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_chat_message!(message_id)
|
||||
message = ChatMessage.find_by(id: message_id, chat_channel: @chat_channel)
|
||||
raise Discourse::NotFound unless message
|
||||
message
|
||||
end
|
||||
|
||||
def validate_reaction!(react_action, emoji)
|
||||
if ![ADD_REACTION, REMOVE_REACTION].include?(react_action) || !Emoji.exists?(emoji)
|
||||
raise Discourse::InvalidParameters
|
||||
end
|
||||
end
|
||||
|
||||
def enforce_channel_membership!
|
||||
Chat::ChatChannelMembershipManager.new(@chat_channel).follow(@user)
|
||||
end
|
||||
|
||||
def validate_channel_status!
|
||||
return if @guardian.can_create_channel_message?(@chat_channel)
|
||||
raise Discourse::InvalidAccess.new(
|
||||
nil,
|
||||
nil,
|
||||
custom_message: "chat.errors.channel_modify_message_disallowed",
|
||||
custom_message_params: {
|
||||
status: @chat_channel.status_name,
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
def validate_max_reactions!(message, react_action, emoji)
|
||||
if react_action == ADD_REACTION &&
|
||||
message.reactions.count("DISTINCT emoji") >= MAX_REACTIONS_LIMIT &&
|
||||
!message.reactions.exists?(emoji: emoji)
|
||||
raise Discourse::InvalidAccess.new(
|
||||
nil,
|
||||
nil,
|
||||
custom_message: "chat.errors.max_reactions_limit_reached",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def create_reaction(message, react_action, emoji)
|
||||
if react_action == ADD_REACTION
|
||||
message.reactions.find_or_create_by!(user: @user, emoji: emoji)
|
||||
else
|
||||
message.reactions.where(user: @user, emoji: emoji).destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
def publish_reaction(message, react_action, emoji)
|
||||
ChatPublisher.publish_reaction!(@chat_channel, message, react_action, @user, emoji)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,91 @@
|
||||
# frozen_string_literal: true
|
||||
class Chat::ChatMessageUpdater
|
||||
attr_reader :error
|
||||
|
||||
def self.update(opts)
|
||||
instance = new(**opts)
|
||||
instance.update
|
||||
instance
|
||||
end
|
||||
|
||||
def initialize(chat_message:, new_content:, upload_ids: nil)
|
||||
@chat_message = chat_message
|
||||
@old_message_content = chat_message.message
|
||||
@chat_channel = @chat_message.chat_channel
|
||||
@user = @chat_message.user
|
||||
@guardian = Guardian.new(@user)
|
||||
@new_content = new_content
|
||||
@upload_ids = upload_ids
|
||||
@error = nil
|
||||
end
|
||||
|
||||
def update
|
||||
begin
|
||||
validate_channel_status!
|
||||
@chat_message.message = @new_content
|
||||
upload_info = get_upload_info
|
||||
validate_message!(has_uploads: upload_info[:uploads].any?)
|
||||
@chat_message.cook
|
||||
@chat_message.save!
|
||||
update_uploads(upload_info)
|
||||
revision = save_revision!
|
||||
ChatPublisher.publish_edit!(@chat_channel, @chat_message)
|
||||
Jobs.enqueue(:process_chat_message, { chat_message_id: @chat_message.id })
|
||||
Chat::ChatNotifier.notify_edit(chat_message: @chat_message, timestamp: revision.created_at)
|
||||
rescue => error
|
||||
@error = error
|
||||
end
|
||||
end
|
||||
|
||||
def failed?
|
||||
@error.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_channel_status!
|
||||
return if @guardian.can_modify_channel_message?(@chat_channel)
|
||||
raise StandardError.new(
|
||||
I18n.t(
|
||||
"chat.errors.channel_modify_message_disallowed",
|
||||
status: @chat_channel.status_name,
|
||||
),
|
||||
)
|
||||
end
|
||||
|
||||
def validate_message!(has_uploads:)
|
||||
@chat_message.validate_message(has_uploads: has_uploads)
|
||||
if @chat_message.errors.present?
|
||||
raise StandardError.new(@chat_message.errors.map(&:full_message).join(", "))
|
||||
end
|
||||
end
|
||||
|
||||
def get_upload_info
|
||||
return { uploads: [] } if @upload_ids.nil? || !SiteSetting.chat_allow_uploads
|
||||
|
||||
uploads = Upload.where(id: @upload_ids, user_id: @user.id)
|
||||
if uploads.count != @upload_ids.count
|
||||
# User is passing upload_ids for uploads that they don't own. Don't change anything.
|
||||
return { uploads: @chat_message.uploads, changed: false }
|
||||
end
|
||||
|
||||
new_upload_ids = uploads.map(&:id)
|
||||
existing_upload_ids = @chat_message.upload_ids
|
||||
difference = (existing_upload_ids + new_upload_ids) - (existing_upload_ids & new_upload_ids)
|
||||
{ uploads: uploads, changed: difference.any? }
|
||||
end
|
||||
|
||||
def update_uploads(upload_info)
|
||||
return unless upload_info[:changed]
|
||||
|
||||
ChatUpload.where(chat_message: @chat_message).destroy_all
|
||||
@chat_message.attach_uploads(upload_info[:uploads])
|
||||
end
|
||||
|
||||
def save_revision!
|
||||
@chat_message.revisions.create!(
|
||||
old_message: @old_message_content,
|
||||
new_message: @chat_message.message,
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,335 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# When we are attempting to notify users based on a message we have to take
|
||||
# into account the following:
|
||||
#
|
||||
# * Individual user mentions like @alfred
|
||||
# * Group mentions that include N users such as @support
|
||||
# * Global @here and @all mentions
|
||||
# * Users watching the channel via UserChatChannelMembership
|
||||
#
|
||||
# For various reasons a mention may not notify a user:
|
||||
#
|
||||
# * The target user of the mention is ignoring or muting the user who created the message
|
||||
# * The target user either cannot chat or cannot see the chat channel, in which case
|
||||
# they are defined as `unreachable`
|
||||
# * The target user is not a member of the channel, in which case they are defined
|
||||
# as `welcome_to_join`
|
||||
# * In the case of global @here and @all mentions users with the preference
|
||||
# `ignore_channel_wide_mention` set to true will not be notified
|
||||
#
|
||||
# For any users that fall under the `unreachable` or `welcome_to_join` umbrellas
|
||||
# we send a MessageBus message to the UI and to inform the creating user. The
|
||||
# creating user can invite any `welcome_to_join` users to the channel. Target
|
||||
# users who are ignoring or muting the creating user _do not_ fall into this bucket.
|
||||
#
|
||||
# The ignore/mute filtering is also applied via the ChatNotifyWatching job,
|
||||
# which prevents desktop / push notifications being sent.
|
||||
class Chat::ChatNotifier
|
||||
class << self
|
||||
def user_has_seen_message?(membership, chat_message_id)
|
||||
(membership.last_read_message_id || 0) >= chat_message_id
|
||||
end
|
||||
|
||||
def push_notification_tag(type, chat_channel_id)
|
||||
"#{Discourse.current_hostname}-chat-#{type}-#{chat_channel_id}"
|
||||
end
|
||||
|
||||
def notify_edit(chat_message:, timestamp:)
|
||||
new(chat_message, timestamp).notify_edit
|
||||
end
|
||||
|
||||
def notify_new(chat_message:, timestamp:)
|
||||
new(chat_message, timestamp).notify_new
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(chat_message, timestamp)
|
||||
@chat_message = chat_message
|
||||
@timestamp = timestamp
|
||||
@chat_channel = @chat_message.chat_channel
|
||||
@user = @chat_message.user
|
||||
end
|
||||
|
||||
### Public API
|
||||
|
||||
def notify_new
|
||||
to_notify = list_users_to_notify
|
||||
inaccessible = to_notify.extract!(:unreachable, :welcome_to_join)
|
||||
mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids]
|
||||
|
||||
mentioned_user_ids.each do |member_id|
|
||||
ChatPublisher.publish_new_mention(member_id, @chat_channel.id, @chat_message.id)
|
||||
end
|
||||
|
||||
notify_creator_of_inaccessible_mentions(
|
||||
inaccessible[:unreachable],
|
||||
inaccessible[:welcome_to_join],
|
||||
)
|
||||
|
||||
notify_mentioned_users(to_notify)
|
||||
notify_watching_users(except: mentioned_user_ids << @user.id)
|
||||
|
||||
to_notify
|
||||
end
|
||||
|
||||
def notify_edit
|
||||
existing_notifications =
|
||||
ChatMention.includes(:user, :notification).where(chat_message: @chat_message)
|
||||
already_notified_user_ids = existing_notifications.map(&:user_id)
|
||||
|
||||
to_notify = list_users_to_notify
|
||||
inaccessible = to_notify.extract!(:unreachable, :welcome_to_join)
|
||||
mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids]
|
||||
|
||||
needs_deletion = already_notified_user_ids - mentioned_user_ids
|
||||
needs_deletion.each do |user_id|
|
||||
chat_mention = existing_notifications.detect { |n| n.user_id == user_id }
|
||||
chat_mention.notification.destroy!
|
||||
chat_mention.destroy!
|
||||
end
|
||||
|
||||
needs_notification_ids = mentioned_user_ids - already_notified_user_ids
|
||||
return if needs_notification_ids.blank?
|
||||
|
||||
notify_creator_of_inaccessible_mentions(
|
||||
inaccessible[:unreachable],
|
||||
inaccessible[:welcome_to_join],
|
||||
)
|
||||
|
||||
notify_mentioned_users(to_notify, already_notified_user_ids: already_notified_user_ids)
|
||||
|
||||
to_notify
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def list_users_to_notify
|
||||
{}.tap do |to_notify|
|
||||
# The order of these methods is the precedence
|
||||
# between different mention types.
|
||||
|
||||
already_covered_ids = []
|
||||
|
||||
expand_direct_mentions(to_notify, already_covered_ids)
|
||||
expand_group_mentions(to_notify, already_covered_ids)
|
||||
expand_here_mention(to_notify, already_covered_ids)
|
||||
expand_global_mention(to_notify, already_covered_ids)
|
||||
|
||||
filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids)
|
||||
|
||||
to_notify[:all_mentioned_user_ids] = already_covered_ids
|
||||
end
|
||||
end
|
||||
|
||||
def chat_users
|
||||
users =
|
||||
User.includes(:do_not_disturb_timings, :push_subscriptions, :user_chat_channel_memberships)
|
||||
|
||||
users
|
||||
.distinct
|
||||
.joins("LEFT OUTER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id")
|
||||
.joins(:user_option)
|
||||
.real
|
||||
.not_suspended
|
||||
.where(user_options: { chat_enabled: true })
|
||||
.where.not(username_lower: @user.username.downcase)
|
||||
end
|
||||
|
||||
def rest_of_the_channel
|
||||
chat_users.where(
|
||||
user_chat_channel_memberships: {
|
||||
following: true,
|
||||
chat_channel_id: @chat_channel.id,
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
def members_accepting_channel_wide_notifications
|
||||
rest_of_the_channel.where(user_options: { ignore_channel_wide_mention: [false, nil] })
|
||||
end
|
||||
|
||||
def direct_mentions_from_cooked
|
||||
@direct_mentions_from_cooked ||=
|
||||
Nokogiri::HTML5.fragment(@chat_message.cooked).css(".mention").map(&:text)
|
||||
end
|
||||
|
||||
def normalized_mentions(mentions)
|
||||
mentions.reduce([]) do |memo, mention|
|
||||
%w[@here @all].include?(mention.downcase) ? memo : (memo << mention[1..-1].downcase)
|
||||
end
|
||||
end
|
||||
|
||||
def expand_global_mention(to_notify, already_covered_ids)
|
||||
typed_global_mention = direct_mentions_from_cooked.include?("@all")
|
||||
|
||||
if typed_global_mention
|
||||
to_notify[:global_mentions] = members_accepting_channel_wide_notifications
|
||||
.where.not(username_lower: normalized_mentions(direct_mentions_from_cooked))
|
||||
.where.not(id: already_covered_ids)
|
||||
.pluck(:id)
|
||||
|
||||
already_covered_ids.concat(to_notify[:global_mentions])
|
||||
else
|
||||
to_notify[:global_mentions] = []
|
||||
end
|
||||
end
|
||||
|
||||
def expand_here_mention(to_notify, already_covered_ids)
|
||||
typed_here_mention = direct_mentions_from_cooked.include?("@here")
|
||||
|
||||
if typed_here_mention
|
||||
to_notify[:here_mentions] = members_accepting_channel_wide_notifications
|
||||
.where("last_seen_at > ?", 5.minutes.ago)
|
||||
.where.not(username_lower: normalized_mentions(direct_mentions_from_cooked))
|
||||
.where.not(id: already_covered_ids)
|
||||
.pluck(:id)
|
||||
|
||||
already_covered_ids.concat(to_notify[:here_mentions])
|
||||
else
|
||||
to_notify[:here_mentions] = []
|
||||
end
|
||||
end
|
||||
|
||||
def group_users_to_notify(users)
|
||||
potential_participants, unreachable =
|
||||
users.partition do |user|
|
||||
guardian = Guardian.new(user)
|
||||
guardian.can_chat?(user) && guardian.can_see_chat_channel?(@chat_channel)
|
||||
end
|
||||
|
||||
participants, welcome_to_join =
|
||||
potential_participants.partition do |participant|
|
||||
participant.user_chat_channel_memberships.any? do |m|
|
||||
predicate = m.chat_channel_id == @chat_channel.id
|
||||
predicate = predicate && m.following == true if @chat_channel.public_channel?
|
||||
predicate
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
already_participating: participants || [],
|
||||
welcome_to_join: welcome_to_join || [],
|
||||
unreachable: unreachable || [],
|
||||
}
|
||||
end
|
||||
|
||||
def expand_direct_mentions(to_notify, already_covered_ids)
|
||||
direct_mentions =
|
||||
chat_users
|
||||
.where(username_lower: normalized_mentions(direct_mentions_from_cooked))
|
||||
.where.not(id: already_covered_ids)
|
||||
|
||||
grouped = group_users_to_notify(direct_mentions)
|
||||
|
||||
to_notify[:direct_mentions] = grouped[:already_participating].map(&:id)
|
||||
to_notify[:welcome_to_join] = grouped[:welcome_to_join]
|
||||
to_notify[:unreachable] = grouped[:unreachable]
|
||||
already_covered_ids.concat(to_notify[:direct_mentions])
|
||||
end
|
||||
|
||||
def group_name_mentions
|
||||
@group_mentions_from_cooked ||=
|
||||
normalized_mentions(
|
||||
Nokogiri::HTML5.fragment(@chat_message.cooked).css(".mention-group").map(&:text),
|
||||
)
|
||||
end
|
||||
|
||||
def mentionable_groups
|
||||
@mentionable_groups ||=
|
||||
Group.mentionable(@user, include_public: false).where(
|
||||
"LOWER(name) IN (?)",
|
||||
group_name_mentions,
|
||||
)
|
||||
end
|
||||
|
||||
def expand_group_mentions(to_notify, already_covered_ids)
|
||||
return [] if mentionable_groups.empty?
|
||||
|
||||
mentionable_groups.each { |g| to_notify[g.name.downcase] = [] }
|
||||
|
||||
reached_by_group =
|
||||
chat_users.joins(:groups).where(groups: mentionable_groups).where.not(id: already_covered_ids)
|
||||
|
||||
grouped = group_users_to_notify(reached_by_group)
|
||||
|
||||
grouped[:already_participating].each do |user|
|
||||
# When a user is a member of multiple mentioned groups,
|
||||
# the most far to the left should take precedence.
|
||||
ordered_group_names = group_name_mentions & mentionable_groups.map { |mg| mg.name.downcase }
|
||||
user_group_names = user.groups.map { |ug| ug.name.downcase }
|
||||
group_name = ordered_group_names.detect { |gn| user_group_names.include?(gn) }
|
||||
|
||||
to_notify[group_name] << user.id
|
||||
end
|
||||
already_covered_ids.concat(grouped[:already_participating])
|
||||
|
||||
to_notify[:welcome_to_join] = to_notify[:welcome_to_join].concat(grouped[:welcome_to_join])
|
||||
to_notify[:unreachable] = to_notify[:unreachable].concat(grouped[:unreachable])
|
||||
end
|
||||
|
||||
def notify_creator_of_inaccessible_mentions(unreachable, welcome_to_join)
|
||||
return if unreachable.empty? && welcome_to_join.empty?
|
||||
|
||||
ChatPublisher.publish_inaccessible_mentions(
|
||||
@user.id,
|
||||
@chat_message,
|
||||
unreachable,
|
||||
welcome_to_join,
|
||||
)
|
||||
end
|
||||
|
||||
# Filters out users from global, here, group, and direct mentions that are
|
||||
# ignoring or muting the creator of the message, so they will not receive
|
||||
# a notification via the ChatNotifyMentioned job and are not prompted for
|
||||
# invitation by the creator.
|
||||
#
|
||||
# already_covered_ids and to_notify sometimes contain IDs and sometimes contain
|
||||
# Users, hence the gymnastics to resolve the user_id
|
||||
def filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids)
|
||||
user_ids_to_screen =
|
||||
already_covered_ids
|
||||
.map { |ac| user_id_resolver(ac) }
|
||||
.concat(to_notify.values.flatten.map { |tn| user_id_resolver(tn) })
|
||||
.uniq
|
||||
screener = UserCommScreener.new(acting_user: @user, target_user_ids: user_ids_to_screen)
|
||||
to_notify
|
||||
.except(:unreachable)
|
||||
.each do |key, users_or_ids|
|
||||
to_notify[key] = users_or_ids.reject do |user_or_id|
|
||||
screener.ignoring_or_muting_actor?(user_id_resolver(user_or_id))
|
||||
end
|
||||
end
|
||||
already_covered_ids.reject! do |already_covered|
|
||||
screener.ignoring_or_muting_actor?(user_id_resolver(already_covered))
|
||||
end
|
||||
end
|
||||
|
||||
def user_id_resolver(obj)
|
||||
obj.is_a?(User) ? obj.id : obj
|
||||
end
|
||||
|
||||
def notify_mentioned_users(to_notify, already_notified_user_ids: [])
|
||||
Jobs.enqueue(
|
||||
:chat_notify_mentioned,
|
||||
{
|
||||
chat_message_id: @chat_message.id,
|
||||
to_notify_ids_map: to_notify.as_json,
|
||||
already_notified_user_ids: already_notified_user_ids,
|
||||
timestamp: @timestamp.iso8601(6),
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
def notify_watching_users(except: [])
|
||||
Jobs.enqueue(
|
||||
:chat_notify_watching,
|
||||
{
|
||||
chat_message_id: @chat_message.id,
|
||||
except_user_ids: except,
|
||||
timestamp: @timestamp.iso8601(6),
|
||||
},
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,208 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Acceptable options:
|
||||
# - message: Used when the flag type is notify_user or notify_moderators and we have to create
|
||||
# a separate PM.
|
||||
# - is_warning: Staff can send warnings when using the notify_user flag.
|
||||
# - take_action: Automatically approves the created reviewable and deletes the chat message.
|
||||
# - queue_for_review: Adds a special reason to the reviwable score and creates the reviewable using
|
||||
# the force_review option.
|
||||
|
||||
class Chat::ChatReviewQueue
|
||||
def flag_message(chat_message, guardian, flag_type_id, opts = {})
|
||||
result = { success: false, errors: [] }
|
||||
|
||||
is_notify_type =
|
||||
ReviewableScore.types.slice(:notify_user, :notify_moderators).values.include?(flag_type_id)
|
||||
is_dm = chat_message.chat_channel.direct_message_channel?
|
||||
|
||||
raise Discourse::InvalidParameters.new(:flag_type) if is_dm && is_notify_type
|
||||
|
||||
guardian.ensure_can_flag_chat_message!(chat_message)
|
||||
guardian.ensure_can_flag_message_as!(chat_message, flag_type_id, opts)
|
||||
|
||||
existing_reviewable = Reviewable.includes(:reviewable_scores).find_by(target: chat_message)
|
||||
|
||||
if !can_flag_again?(existing_reviewable, chat_message, guardian.user, flag_type_id)
|
||||
result[:errors] << I18n.t("chat.reviewables.message_already_handled")
|
||||
return result
|
||||
end
|
||||
|
||||
payload = { message_cooked: chat_message.cooked }
|
||||
|
||||
if opts[:message].present? && !is_dm && is_notify_type
|
||||
creator = companion_pm_creator(chat_message, guardian.user, flag_type_id, opts)
|
||||
post = creator.create
|
||||
|
||||
if creator.errors.present?
|
||||
creator.errors.full_messages.each { |msg| result[:errors] << msg }
|
||||
return result
|
||||
end
|
||||
elsif is_dm
|
||||
transcript = find_or_create_transcript(chat_message, guardian.user, existing_reviewable)
|
||||
payload[:transcript_topic_id] = transcript.topic_id if transcript
|
||||
end
|
||||
|
||||
queued_for_review = !!ActiveRecord::Type::Boolean.new.deserialize(opts[:queue_for_review])
|
||||
|
||||
reviewable =
|
||||
ReviewableChatMessage.needs_review!(
|
||||
created_by: guardian.user,
|
||||
target: chat_message,
|
||||
reviewable_by_moderator: true,
|
||||
potential_spam: flag_type_id == ReviewableScore.types[:spam],
|
||||
payload: payload,
|
||||
)
|
||||
reviewable.update(target_created_by: chat_message.user)
|
||||
score =
|
||||
reviewable.add_score(
|
||||
guardian.user,
|
||||
flag_type_id,
|
||||
meta_topic_id: post&.topic_id,
|
||||
take_action: opts[:take_action],
|
||||
reason: queued_for_review ? "chat_message_queued_by_staff" : nil,
|
||||
force_review: queued_for_review,
|
||||
)
|
||||
|
||||
if opts[:take_action]
|
||||
reviewable.perform(guardian.user, :agree_and_delete)
|
||||
ChatPublisher.publish_delete!(chat_message.chat_channel, chat_message)
|
||||
else
|
||||
enforce_auto_silence_threshold(reviewable)
|
||||
ChatPublisher.publish_flag!(chat_message, guardian.user, reviewable, score)
|
||||
end
|
||||
|
||||
result.tap do |r|
|
||||
r[:success] = true
|
||||
r[:reviewable] = reviewable
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enforce_auto_silence_threshold(reviewable)
|
||||
auto_silence_duration = SiteSetting.chat_auto_silence_from_flags_duration
|
||||
return if auto_silence_duration.zero?
|
||||
return if reviewable.score <= ReviewableChatMessage.score_to_silence_user
|
||||
|
||||
user = reviewable.target_created_by
|
||||
return unless user
|
||||
return if user.silenced?
|
||||
|
||||
UserSilencer.silence(
|
||||
user,
|
||||
Discourse.system_user,
|
||||
silenced_till: auto_silence_duration.minutes.from_now,
|
||||
reason: I18n.t("chat.errors.auto_silence_from_flags"),
|
||||
)
|
||||
end
|
||||
|
||||
def companion_pm_creator(chat_message, flagger, flag_type_id, opts)
|
||||
notifying_user = flag_type_id == ReviewableScore.types[:notify_user]
|
||||
|
||||
i18n_key = notifying_user ? "notify_user" : "notify_moderators"
|
||||
|
||||
title =
|
||||
I18n.t(
|
||||
"reviewable_score_types.#{i18n_key}.chat_pm_title",
|
||||
channel_name: chat_message.chat_channel.title(flagger),
|
||||
locale: SiteSetting.default_locale,
|
||||
)
|
||||
|
||||
body =
|
||||
I18n.t(
|
||||
"reviewable_score_types.#{i18n_key}.chat_pm_body",
|
||||
message: opts[:message],
|
||||
link: chat_message.full_url,
|
||||
locale: SiteSetting.default_locale,
|
||||
)
|
||||
|
||||
create_args = {
|
||||
archetype: Archetype.private_message,
|
||||
title: title.truncate(SiteSetting.max_topic_title_length, separator: /\s/),
|
||||
raw: body,
|
||||
}
|
||||
|
||||
if notifying_user
|
||||
create_args[:subtype] = TopicSubtype.notify_user
|
||||
create_args[:target_usernames] = chat_message.user.username
|
||||
|
||||
create_args[:is_warning] = opts[:is_warning] if flagger.staff?
|
||||
else
|
||||
create_args[:subtype] = TopicSubtype.notify_moderators
|
||||
create_args[:target_group_names] = [Group[:moderators].name]
|
||||
end
|
||||
|
||||
PostCreator.new(flagger, create_args)
|
||||
end
|
||||
|
||||
def find_or_create_transcript(chat_message, flagger, existing_reviewable)
|
||||
previous_message_ids =
|
||||
ChatMessage
|
||||
.where(chat_channel: chat_message.chat_channel)
|
||||
.where("id < ?", chat_message.id)
|
||||
.order("created_at DESC")
|
||||
.limit(10)
|
||||
.pluck(:id)
|
||||
.reverse
|
||||
|
||||
return if previous_message_ids.empty?
|
||||
|
||||
service =
|
||||
ChatTranscriptService.new(
|
||||
chat_message.chat_channel,
|
||||
Discourse.system_user,
|
||||
messages_or_ids: previous_message_ids,
|
||||
)
|
||||
|
||||
title =
|
||||
I18n.t(
|
||||
"chat.reviewables.direct_messages.transcript_title",
|
||||
channel_name: chat_message.chat_channel.title(flagger),
|
||||
locale: SiteSetting.default_locale,
|
||||
)
|
||||
|
||||
body =
|
||||
I18n.t(
|
||||
"chat.reviewables.direct_messages.transcript_body",
|
||||
transcript: service.generate_markdown,
|
||||
locale: SiteSetting.default_locale,
|
||||
)
|
||||
|
||||
create_args = {
|
||||
archetype: Archetype.private_message,
|
||||
title: title.truncate(SiteSetting.max_topic_title_length, separator: /\s/),
|
||||
raw: body,
|
||||
subtype: TopicSubtype.notify_moderators,
|
||||
target_group_names: [Group[:moderators].name],
|
||||
}
|
||||
|
||||
PostCreator.new(Discourse.system_user, create_args).create
|
||||
end
|
||||
|
||||
def can_flag_again?(reviewable, message, flagger, flag_type_id)
|
||||
return true if reviewable.blank?
|
||||
|
||||
flagger_has_pending_flags =
|
||||
reviewable.reviewable_scores.any? { |rs| rs.user == flagger && rs.pending? }
|
||||
|
||||
if !flagger_has_pending_flags && flag_type_id == ReviewableScore.types[:notify_moderators]
|
||||
return true
|
||||
end
|
||||
|
||||
flag_used =
|
||||
reviewable.reviewable_scores.any? do |rs|
|
||||
rs.reviewable_score_type == flag_type_id && rs.pending?
|
||||
end
|
||||
handled_recently =
|
||||
!(
|
||||
reviewable.pending? ||
|
||||
reviewable.updated_at < SiteSetting.cooldown_hours_until_reflag.to_i.hours.ago
|
||||
)
|
||||
|
||||
latest_revision = message.revisions.last
|
||||
edited_since_last_review = latest_revision && latest_revision.updated_at > reviewable.updated_at
|
||||
|
||||
!flag_used && !flagger_has_pending_flags && (!handled_recently || edited_since_last_review)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ChatSeeder
|
||||
def execute(args = {})
|
||||
return if !SiteSetting.needs_chat_seeded
|
||||
|
||||
begin
|
||||
create_category_channel_from(SiteSetting.staff_category_id)
|
||||
create_category_channel_from(SiteSetting.general_category_id)
|
||||
rescue => error
|
||||
Rails.logger.warn("Error seeding chat category - #{error.inspect}")
|
||||
ensure
|
||||
SiteSetting.needs_chat_seeded = false
|
||||
end
|
||||
end
|
||||
|
||||
def create_category_channel_from(category_id)
|
||||
category = Category.find_by(id: category_id)
|
||||
return if category.nil?
|
||||
|
||||
chat_channel = category.create_chat_channel!(auto_join_users: true, name: category.name)
|
||||
category.custom_fields[Chat::HAS_CHAT_ENABLED] = true
|
||||
category.save!
|
||||
|
||||
Chat::ChatChannelMembershipManager.new(chat_channel).enforce_automatic_channel_memberships
|
||||
chat_channel
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::Statistics
|
||||
def self.about_messages
|
||||
{
|
||||
:last_day => ChatMessage.where("created_at > ?", 1.days.ago).count,
|
||||
"7_days" => ChatMessage.where("created_at > ?", 7.days.ago).count,
|
||||
"30_days" => ChatMessage.where("created_at > ?", 30.days.ago).count,
|
||||
:previous_30_days =>
|
||||
ChatMessage.where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago).count,
|
||||
:count => ChatMessage.count,
|
||||
}
|
||||
end
|
||||
|
||||
def self.about_channels
|
||||
{
|
||||
:last_day => ChatChannel.where(status: :open).where("created_at > ?", 1.days.ago).count,
|
||||
"7_days" => ChatChannel.where(status: :open).where("created_at > ?", 7.days.ago).count,
|
||||
"30_days" => ChatChannel.where(status: :open).where("created_at > ?", 30.days.ago).count,
|
||||
:previous_30_days =>
|
||||
ChatChannel
|
||||
.where(status: :open)
|
||||
.where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago)
|
||||
.count,
|
||||
:count => ChatChannel.where(status: :open).count,
|
||||
}
|
||||
end
|
||||
|
||||
def self.about_users
|
||||
{
|
||||
:last_day => ChatMessage.where("created_at > ?", 1.days.ago).distinct.count(:user_id),
|
||||
"7_days" => ChatMessage.where("created_at > ?", 7.days.ago).distinct.count(:user_id),
|
||||
"30_days" => ChatMessage.where("created_at > ?", 30.days.ago).distinct.count(:user_id),
|
||||
:previous_30_days =>
|
||||
ChatMessage
|
||||
.where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago)
|
||||
.distinct
|
||||
.count(:user_id),
|
||||
:count => ChatMessage.distinct.count(:user_id),
|
||||
}
|
||||
end
|
||||
|
||||
def self.monthly
|
||||
start_of_month = Time.zone.now.beginning_of_month
|
||||
{
|
||||
messages: ChatMessage.where("created_at > ?", start_of_month).count,
|
||||
channels: ChatChannel.where(status: :open).where("created_at > ?", start_of_month).count,
|
||||
users: ChatMessage.where("created_at > ?", start_of_month).distinct.count(:user_id),
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,177 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Used to generate BBCode [chat] tags for the message IDs provided.
|
||||
#
|
||||
# If there is > 1 message then the channel name will be shown at
|
||||
# the top of the first message, and subsequent messages will have
|
||||
# the chained attribute, which will affect how they are displayed
|
||||
# in the UI.
|
||||
#
|
||||
# Subsequent messages from the same user will be put into the same
|
||||
# tag. Each new user in the chain of messages will have a new [chat]
|
||||
# tag created.
|
||||
#
|
||||
# A single message will have the channel name displayed to the right
|
||||
# of the username and datetime of the message.
|
||||
class ChatTranscriptService
|
||||
CHAINED_ATTR = "chained=\"true\""
|
||||
MULTIQUOTE_ATTR = "multiQuote=\"true\""
|
||||
NO_LINK_ATTR = "noLink=\"true\""
|
||||
|
||||
class ChatTranscriptBBCode
|
||||
attr_reader :channel, :multiquote, :chained, :no_link, :include_reactions
|
||||
|
||||
def initialize(
|
||||
channel: nil,
|
||||
acting_user: nil,
|
||||
multiquote: false,
|
||||
chained: false,
|
||||
no_link: false,
|
||||
include_reactions: false
|
||||
)
|
||||
@channel = channel
|
||||
@acting_user = acting_user
|
||||
@multiquote = multiquote
|
||||
@chained = chained
|
||||
@no_link = no_link
|
||||
@include_reactions = include_reactions
|
||||
@message_data = []
|
||||
end
|
||||
|
||||
def add(message:, reactions: nil)
|
||||
@message_data << { message: message, reactions: reactions }
|
||||
end
|
||||
|
||||
def render
|
||||
attrs = [quote_attr(@message_data.first[:message])]
|
||||
|
||||
if channel
|
||||
attrs << channel_attr
|
||||
attrs << channel_id_attr
|
||||
end
|
||||
|
||||
attrs << MULTIQUOTE_ATTR if multiquote
|
||||
attrs << CHAINED_ATTR if chained
|
||||
attrs << NO_LINK_ATTR if no_link
|
||||
attrs << reactions_attr if include_reactions
|
||||
|
||||
<<~MARKDOWN
|
||||
[chat #{attrs.compact.join(" ")}]
|
||||
#{@message_data.map { |msg| msg[:message].to_markdown }.join("\n\n")}
|
||||
[/chat]
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reactions_attr
|
||||
reaction_data =
|
||||
@message_data.reduce([]) do |array, msg_data|
|
||||
if msg_data[:reactions].any?
|
||||
array << msg_data[:reactions].map { |react| "#{react.emoji}:#{react.usernames}" }
|
||||
end
|
||||
array
|
||||
end
|
||||
return if reaction_data.empty?
|
||||
"reactions=\"#{reaction_data.join(";")}\""
|
||||
end
|
||||
|
||||
def quote_attr(message)
|
||||
"quote=\"#{message.user.username};#{message.id};#{message.created_at.iso8601}\""
|
||||
end
|
||||
|
||||
def channel_attr
|
||||
"channel=\"#{channel.title(@acting_user)}\""
|
||||
end
|
||||
|
||||
def channel_id_attr
|
||||
"channelId=\"#{channel.id}\""
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(channel, acting_user, messages_or_ids: [], opts: {})
|
||||
@channel = channel
|
||||
@acting_user = acting_user
|
||||
|
||||
if messages_or_ids.all? { |m| m.is_a?(Numeric) }
|
||||
@message_ids = messages_or_ids
|
||||
else
|
||||
@messages = messages_or_ids
|
||||
end
|
||||
@opts = opts
|
||||
end
|
||||
|
||||
def generate_markdown
|
||||
previous_message = nil
|
||||
rendered_markdown = []
|
||||
all_messages_same_user = messages.count(:user_id) == 1
|
||||
open_bbcode_tag =
|
||||
ChatTranscriptBBCode.new(
|
||||
channel: @channel,
|
||||
acting_user: @acting_user,
|
||||
multiquote: messages.length > 1,
|
||||
chained: !all_messages_same_user,
|
||||
no_link: @opts[:no_link],
|
||||
include_reactions: @opts[:include_reactions],
|
||||
)
|
||||
|
||||
messages.each.with_index do |message, idx|
|
||||
if previous_message.present? && previous_message.user_id != message.user_id
|
||||
rendered_markdown << open_bbcode_tag.render
|
||||
|
||||
open_bbcode_tag =
|
||||
ChatTranscriptBBCode.new(
|
||||
acting_user: @acting_user,
|
||||
chained: !all_messages_same_user,
|
||||
no_link: @opts[:no_link],
|
||||
include_reactions: @opts[:include_reactions],
|
||||
)
|
||||
end
|
||||
|
||||
if @opts[:include_reactions]
|
||||
open_bbcode_tag.add(message: message, reactions: reactions_for_message(message))
|
||||
else
|
||||
open_bbcode_tag.add(message: message)
|
||||
end
|
||||
previous_message = message
|
||||
end
|
||||
|
||||
# tie off the last open bbcode + render
|
||||
rendered_markdown << open_bbcode_tag.render
|
||||
rendered_markdown.join("\n")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def messages
|
||||
@messages ||=
|
||||
ChatMessage
|
||||
.includes(:user, chat_uploads: :upload)
|
||||
.where(id: @message_ids, chat_channel_id: @channel.id)
|
||||
.order(:created_at)
|
||||
end
|
||||
|
||||
##
|
||||
# Queries reactions and returns them in this format
|
||||
#
|
||||
# emoji | usernames | chat_message_id
|
||||
# ----------------------------------------
|
||||
# +1 | foo,bar,baz | 102
|
||||
# heart | foo | 102
|
||||
# sob | bar,baz | 103
|
||||
def reactions
|
||||
@reactions ||= DB.query(<<~SQL, @messages.map(&:id))
|
||||
SELECT emoji, STRING_AGG(DISTINCT users.username, ',') AS usernames, chat_message_id
|
||||
FROM chat_message_reactions
|
||||
INNER JOIN users on users.id = chat_message_reactions.user_id
|
||||
WHERE chat_message_id IN (?)
|
||||
GROUP BY emoji, chat_message_id
|
||||
ORDER BY chat_message_id, emoji
|
||||
SQL
|
||||
end
|
||||
|
||||
def reactions_for_message(message)
|
||||
reactions.select { |react| react.chat_message_id == message.id }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,111 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat::DirectMessageChannelCreator
|
||||
class NotAllowed < StandardError
|
||||
end
|
||||
|
||||
def self.create!(acting_user:, target_users:)
|
||||
Guardian.new(acting_user).ensure_can_create_direct_message!
|
||||
target_users.uniq!
|
||||
direct_messages_channel = DirectMessageChannel.for_user_ids(target_users.map(&:id))
|
||||
if direct_messages_channel
|
||||
chat_channel = ChatChannel.find_by!(chatable: direct_messages_channel)
|
||||
else
|
||||
ensure_actor_can_communicate!(acting_user, target_users)
|
||||
direct_messages_channel = DirectMessageChannel.create!(user_ids: target_users.map(&:id))
|
||||
chat_channel = direct_messages_channel.create_chat_channel!
|
||||
end
|
||||
|
||||
update_memberships(acting_user, target_users, chat_channel.id)
|
||||
ChatPublisher.publish_new_channel(chat_channel, target_users)
|
||||
|
||||
chat_channel
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.update_memberships(acting_user, target_users, chat_channel_id)
|
||||
sql_params = {
|
||||
acting_user_id: acting_user.id,
|
||||
user_ids: target_users.map(&:id),
|
||||
chat_channel_id: chat_channel_id,
|
||||
always_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always],
|
||||
}
|
||||
|
||||
DB.exec(<<~SQL, sql_params)
|
||||
INSERT INTO user_chat_channel_memberships(
|
||||
user_id,
|
||||
chat_channel_id,
|
||||
muted,
|
||||
following,
|
||||
desktop_notification_level,
|
||||
mobile_notification_level,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES(
|
||||
unnest(array[:user_ids]),
|
||||
:chat_channel_id,
|
||||
false,
|
||||
false,
|
||||
:always_notification_level,
|
||||
:always_notification_level,
|
||||
NOW(),
|
||||
NOW()
|
||||
)
|
||||
ON CONFLICT (user_id, chat_channel_id) DO NOTHING;
|
||||
|
||||
UPDATE user_chat_channel_memberships
|
||||
SET following = true
|
||||
WHERE user_id = :acting_user_id AND chat_channel_id = :chat_channel_id;
|
||||
SQL
|
||||
end
|
||||
|
||||
def self.ensure_actor_can_communicate!(acting_user, target_users)
|
||||
# We never want to prevent the actor from communicating with themself.
|
||||
target_users = target_users.reject { |user| user.id == acting_user.id }
|
||||
|
||||
screener =
|
||||
UserCommScreener.new(acting_user: acting_user, target_user_ids: target_users.map(&:id))
|
||||
|
||||
# People blocking the actor.
|
||||
screener.preventing_actor_communication.each do |user_id|
|
||||
raise NotAllowed.new(
|
||||
I18n.t(
|
||||
"chat.errors.not_accepting_dms",
|
||||
username: target_users.find { |user| user.id == user_id }.username,
|
||||
),
|
||||
)
|
||||
end
|
||||
|
||||
# The actor cannot start DMs with people if they are not allowing anyone
|
||||
# to start DMs with them, that's no fair!
|
||||
if screener.actor_disallowing_all_pms?
|
||||
raise NotAllowed.new(I18n.t("chat.errors.actor_disallowed_dms"))
|
||||
end
|
||||
|
||||
# People the actor is blocking.
|
||||
target_users.each do |target_user|
|
||||
if screener.actor_disallowing_pms?(target_user.id)
|
||||
raise NotAllowed.new(
|
||||
I18n.t(
|
||||
"chat.errors.actor_preventing_target_user_from_dm",
|
||||
username: target_user.username,
|
||||
),
|
||||
)
|
||||
end
|
||||
|
||||
if screener.actor_ignoring?(target_user.id)
|
||||
raise NotAllowed.new(
|
||||
I18n.t("chat.errors.actor_ignoring_target_user", username: target_user.username),
|
||||
)
|
||||
end
|
||||
|
||||
if screener.actor_muting?(target_user.id)
|
||||
raise NotAllowed.new(
|
||||
I18n.t("chat.errors.actor_muting_target_user", username: target_user.username),
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "discourse_dev/record"
|
||||
require "faker"
|
||||
|
||||
module DiscourseDev
|
||||
class DirectChannel < Record
|
||||
def initialize
|
||||
super(::DirectMessageChannel, 5)
|
||||
end
|
||||
|
||||
def data
|
||||
if Faker::Boolean.boolean(true_ratio: 0.5)
|
||||
admin_username =
|
||||
begin
|
||||
DiscourseDev::Config.new.config[:admin][:username]
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
admin_user = ::User.find_by(username: admin_username) if admin_username
|
||||
end
|
||||
|
||||
[User.new.create!, admin_user || User.new.create!]
|
||||
end
|
||||
|
||||
def create!
|
||||
users = data
|
||||
Chat::DirectMessageChannelCreator.create!(acting_user: users[0], target_users: users)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "discourse_dev/record"
|
||||
require "faker"
|
||||
|
||||
module DiscourseDev
|
||||
class Message < Record
|
||||
def initialize
|
||||
super(::ChatMessage, 200)
|
||||
end
|
||||
|
||||
def data
|
||||
if Faker::Boolean.boolean(true_ratio: 0.5)
|
||||
channel = ::ChatChannel.where(chatable_type: "DirectMessageChannel").order("RANDOM()").first
|
||||
channel.user_chat_channel_memberships.update_all(following: true)
|
||||
user = channel.chatable.users.order("RANDOM()").first
|
||||
else
|
||||
membership = ::UserChatChannelMembership.order("RANDOM()").first
|
||||
channel = membership.chat_channel
|
||||
user = membership.user
|
||||
end
|
||||
|
||||
{ user: user, content: Faker::Lorem.paragraph, chat_channel: channel }
|
||||
end
|
||||
|
||||
def create!
|
||||
Chat::ChatMessageCreator.create(data)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "discourse_dev/record"
|
||||
require "faker"
|
||||
|
||||
module DiscourseDev
|
||||
class PublicChannel < Record
|
||||
def initialize
|
||||
super(::CategoryChannel, 5)
|
||||
end
|
||||
|
||||
def data
|
||||
chatable = Category.random
|
||||
|
||||
{
|
||||
chatable: chatable,
|
||||
description: Faker::Lorem.paragraph,
|
||||
user_count: 1,
|
||||
name: Faker::Company.name,
|
||||
created_at: Faker::Time.between(from: DiscourseDev.config.start_date, to: DateTime.now),
|
||||
}
|
||||
end
|
||||
|
||||
def create!
|
||||
super do |channel|
|
||||
Faker::Number
|
||||
.between(from: 5, to: 10)
|
||||
.times do
|
||||
if Faker::Boolean.boolean(true_ratio: 0.5)
|
||||
admin_username =
|
||||
begin
|
||||
DiscourseDev::Config.new.config[:admin][:username]
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
admin_user = ::User.find_by(username: admin_username) if admin_username
|
||||
end
|
||||
|
||||
Chat::ChatChannelMembershipManager.new(channel).follow(admin_user || User.new.create!)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::DuplicateMessageValidator
|
||||
attr_reader :chat_message
|
||||
|
||||
def initialize(chat_message)
|
||||
@chat_message = chat_message
|
||||
end
|
||||
|
||||
def validate
|
||||
return if SiteSetting.chat_duplicate_message_sensitivity.zero?
|
||||
matrix =
|
||||
Chat::DuplicateMessageValidator.sensitivity_matrix(
|
||||
SiteSetting.chat_duplicate_message_sensitivity,
|
||||
)
|
||||
|
||||
# Check if the length of the message is too short to check for a duplicate message
|
||||
return if chat_message.message.length < matrix[:min_message_length]
|
||||
|
||||
# Check if there are enough users in the channel to check for a duplicate message
|
||||
return if (chat_message.chat_channel.user_count || 0) < matrix[:min_user_count]
|
||||
|
||||
# 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?
|
||||
return
|
||||
end
|
||||
|
||||
chat_message.errors.add(:base, I18n.t("chat.errors.duplicate_message"))
|
||||
end
|
||||
|
||||
def self.sensitivity_matrix(sensitivity)
|
||||
{
|
||||
# 0.1 sensitivity = 100 users and 1.0 sensitivity = 5 users.
|
||||
min_user_count: (-1.0 * 105.5 * sensitivity + 110.55).to_i,
|
||||
# 0.1 sensitivity = 30 chars and 1.0 sensitivity = 10 chars.
|
||||
min_message_length: (-1.0 * 22.2 * sensitivity + 32.22).to_i,
|
||||
# 0.1 sensitivity = 10 seconds and 1.0 sensitivity = 60 seconds.
|
||||
min_past_seconds: (55.55 * sensitivity + 4.5).to_i,
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module EmailControllerHelper
|
||||
class ChatSummaryUnsubscriber < BaseEmailUnsubscriber
|
||||
def prepare_unsubscribe_options(controller)
|
||||
super(controller)
|
||||
|
||||
chat_email_frequencies =
|
||||
UserOption.chat_email_frequencies.map do |(freq, _)|
|
||||
[I18n.t("unsubscribe.chat_summary.#{freq}"), freq]
|
||||
end
|
||||
|
||||
controller.instance_variable_set(:@chat_email_frequencies, chat_email_frequencies)
|
||||
controller.instance_variable_set(
|
||||
:@current_chat_email_frequency,
|
||||
key_owner.user_option.chat_email_frequency,
|
||||
)
|
||||
end
|
||||
|
||||
def unsubscribe(params)
|
||||
updated = super(params)
|
||||
|
||||
if params[:chat_email_frequency]
|
||||
key_owner.user_option.update!(chat_email_frequency: params[:chat_email_frequency])
|
||||
updated = true
|
||||
end
|
||||
|
||||
updated
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat::CategoryExtension
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include Chatable
|
||||
|
||||
prepended { has_one :category_channel, as: :chatable }
|
||||
|
||||
def cannot_delete_reason
|
||||
return I18n.t("category.cannot_delete.has_chat_channels") if category_channel
|
||||
super
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat::UserEmailExtension
|
||||
def execute(args)
|
||||
super(args)
|
||||
|
||||
if args[:type] == "chat_summary" && args[:memberships_to_update_data].present?
|
||||
args[:memberships_to_update_data].to_a.each do |membership_id, max_unread_mention_id|
|
||||
UserChatChannelMembership.find_by(user: args[:user_id], id: membership_id.to_i)&.update(
|
||||
last_unread_mention_when_emailed_id: max_unread_mention_id.to_i,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat::UserExtension
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
prepended do
|
||||
has_many :user_chat_channel_memberships, dependent: :destroy
|
||||
has_many :chat_message_reactions, dependent: :destroy
|
||||
has_many :chat_mentions
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,122 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat::UserNotificationsExtension
|
||||
def chat_summary(user, opts)
|
||||
guardian = Guardian.new(user)
|
||||
return unless guardian.can_chat?(user)
|
||||
|
||||
@messages =
|
||||
ChatMessage
|
||||
.joins(:user, :chat_channel)
|
||||
.where.not(user: user)
|
||||
.where("chat_messages.created_at > ?", 1.week.ago)
|
||||
.joins("LEFT OUTER JOIN chat_mentions cm ON cm.chat_message_id = chat_messages.id")
|
||||
.joins(
|
||||
"INNER JOIN user_chat_channel_memberships uccm ON uccm.chat_channel_id = chat_channels.id",
|
||||
)
|
||||
.where(<<~SQL, user_id: user.id)
|
||||
uccm.user_id = :user_id AND
|
||||
(uccm.last_read_message_id IS NULL OR chat_messages.id > uccm.last_read_message_id) AND
|
||||
(uccm.last_unread_mention_when_emailed_id IS NULL OR chat_messages.id > uccm.last_unread_mention_when_emailed_id) AND
|
||||
(
|
||||
(cm.user_id = :user_id AND uccm.following IS true AND chat_channels.chatable_type = 'Category') OR
|
||||
(chat_channels.chatable_type = 'DirectMessageChannel')
|
||||
)
|
||||
SQL
|
||||
.to_a
|
||||
|
||||
return if @messages.empty?
|
||||
@grouped_messages = @messages.group_by { |message| message.chat_channel }
|
||||
@grouped_messages =
|
||||
@grouped_messages.select { |channel, _| guardian.can_see_chat_channel?(channel) }
|
||||
return if @grouped_messages.empty?
|
||||
|
||||
@grouped_messages.each do |chat_channel, messages|
|
||||
@grouped_messages[chat_channel] = messages.sort_by(&:created_at)
|
||||
end
|
||||
@user = user
|
||||
@user_tz = UserOption.user_tzinfo(user.id)
|
||||
@display_usernames = SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names
|
||||
|
||||
build_summary_for(user)
|
||||
@preferences_path = "#{Discourse.base_url}/my/preferences/chat"
|
||||
|
||||
# TODO(roman): Remove after the 2.9 release
|
||||
add_unsubscribe_link = UnsubscribeKey.respond_to?(:get_unsubscribe_strategy_for)
|
||||
|
||||
if add_unsubscribe_link
|
||||
unsubscribe_key = UnsubscribeKey.create_key_for(@user, "chat_summary")
|
||||
@unsubscribe_link = "#{Discourse.base_url}/email/unsubscribe/#{unsubscribe_key}"
|
||||
opts[:unsubscribe_url] = @unsubscribe_link
|
||||
end
|
||||
|
||||
opts = {
|
||||
from_alias: I18n.t("user_notifications.chat_summary.from", site_name: Email.site_title),
|
||||
subject: summary_subject(user, @grouped_messages),
|
||||
add_unsubscribe_link: add_unsubscribe_link,
|
||||
}
|
||||
|
||||
build_email(user.email, opts)
|
||||
end
|
||||
|
||||
def summary_subject(user, grouped_messages)
|
||||
channels = grouped_messages.keys
|
||||
grouped_channels = channels.partition { |c| !c.direct_message_channel? }
|
||||
non_dm_channels = grouped_channels.first
|
||||
dm_users = grouped_channels.last.flat_map { |c| grouped_messages[c].map(&:user) }.uniq
|
||||
|
||||
total_count_for_subject = non_dm_channels.size + dm_users.size
|
||||
first_message_from = non_dm_channels.pop
|
||||
if first_message_from
|
||||
first_message_title = first_message_from.title(user)
|
||||
subject_key = "chat_channel"
|
||||
else
|
||||
subject_key = "direct_message"
|
||||
first_message_from = dm_users.pop
|
||||
first_message_title = first_message_from.username
|
||||
end
|
||||
|
||||
subject_opts = {
|
||||
email_prefix: @email_prefix,
|
||||
count: total_count_for_subject,
|
||||
message_title: first_message_title,
|
||||
others:
|
||||
other_channels_text(
|
||||
user,
|
||||
total_count_for_subject,
|
||||
first_message_from,
|
||||
non_dm_channels,
|
||||
dm_users,
|
||||
),
|
||||
}
|
||||
|
||||
I18n.t(with_subject_prefix(subject_key), **subject_opts)
|
||||
end
|
||||
|
||||
def with_subject_prefix(key)
|
||||
"user_notifications.chat_summary.subject.#{key}"
|
||||
end
|
||||
|
||||
def other_channels_text(
|
||||
user,
|
||||
total_count,
|
||||
first_message_from,
|
||||
other_non_dm_channels,
|
||||
other_dm_users
|
||||
)
|
||||
return if total_count <= 1
|
||||
return I18n.t(with_subject_prefix("others"), count: total_count - 1) if total_count > 2
|
||||
|
||||
if other_non_dm_channels.empty?
|
||||
second_message_from = other_dm_users.first
|
||||
second_message_title = second_message_from.username
|
||||
else
|
||||
second_message_from = other_non_dm_channels.first
|
||||
second_message_title = second_message_from.title(user)
|
||||
end
|
||||
|
||||
return second_message_title if first_message_from.class == second_message_from.class
|
||||
|
||||
I18n.t(with_subject_prefix("other_direct_message"), message_title: second_message_title)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat::UserOptionExtension
|
||||
# TODO: remove last_emailed_for_chat and chat_isolated in 2023
|
||||
def self.prepended(base)
|
||||
if base.ignored_columns
|
||||
base.ignored_columns = base.ignored_columns + %i[last_emailed_for_chat chat_isolated]
|
||||
else
|
||||
base.ignored_columns = %i[last_emailed_for_chat chat_isolated]
|
||||
end
|
||||
|
||||
def base.chat_email_frequencies
|
||||
@chat_email_frequencies ||= { never: 0, when_away: 1 }
|
||||
end
|
||||
|
||||
base.enum :chat_email_frequency, base.chat_email_frequencies, prefix: "send_chat_email"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,182 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat::GuardianExtensions
|
||||
def can_moderate_chat?(chatable)
|
||||
case chatable.class.name
|
||||
when "Category"
|
||||
is_staff? || is_category_group_moderator?(chatable)
|
||||
else
|
||||
is_staff?
|
||||
end
|
||||
end
|
||||
|
||||
def can_chat?(user)
|
||||
return false unless user
|
||||
|
||||
user.staff? || user.in_any_groups?(Chat.allowed_group_ids)
|
||||
end
|
||||
|
||||
def can_create_chat_message?
|
||||
!SpamRule::AutoSilence.prevent_posting?(@user)
|
||||
end
|
||||
|
||||
def can_create_direct_message?
|
||||
is_staff? || @user.in_any_groups?(SiteSetting.direct_message_enabled_groups_map)
|
||||
end
|
||||
|
||||
def hidden_tag_names
|
||||
@hidden_tag_names ||= DiscourseTagging.hidden_tag_names(self)
|
||||
end
|
||||
|
||||
def can_create_chat_channel?
|
||||
is_staff?
|
||||
end
|
||||
|
||||
def can_delete_chat_channel?
|
||||
is_staff?
|
||||
end
|
||||
|
||||
# Channel status intentionally has no bearing on whether the channel
|
||||
# name and description can be edited.
|
||||
def can_edit_chat_channel?
|
||||
is_staff?
|
||||
end
|
||||
|
||||
def can_move_chat_messages?(channel)
|
||||
can_moderate_chat?(channel.chatable)
|
||||
end
|
||||
|
||||
def can_create_channel_message?(chat_channel)
|
||||
valid_statuses = is_staff? ? %w[open closed] : ["open"]
|
||||
valid_statuses.include?(chat_channel.status)
|
||||
end
|
||||
|
||||
# This is intentionally identical to can_create_channel_message, we
|
||||
# may want to have different conditions here in future.
|
||||
def can_modify_channel_message?(chat_channel)
|
||||
return chat_channel.open? || chat_channel.closed? if is_staff?
|
||||
chat_channel.open?
|
||||
end
|
||||
|
||||
def can_change_channel_status?(chat_channel, target_status)
|
||||
return false if chat_channel.status.to_sym == target_status.to_sym
|
||||
return false if !is_staff?
|
||||
|
||||
case target_status
|
||||
when :closed
|
||||
chat_channel.open?
|
||||
when :open
|
||||
chat_channel.closed?
|
||||
when :archived
|
||||
chat_channel.read_only?
|
||||
when :read_only
|
||||
chat_channel.closed? || chat_channel.open?
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def can_rebake_chat_message?(message)
|
||||
return false if !can_modify_channel_message?(message.chat_channel)
|
||||
is_staff? || @user.has_trust_level?(TrustLevel[4])
|
||||
end
|
||||
|
||||
def can_see_chat_channel?(chat_channel)
|
||||
return false unless chat_channel.chatable
|
||||
|
||||
if chat_channel.direct_message_channel?
|
||||
chat_channel.chatable.user_can_access?(@user)
|
||||
elsif chat_channel.category_channel?
|
||||
can_see_category?(chat_channel.chatable)
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def can_flag_chat_messages?
|
||||
return false if @user.silenced?
|
||||
|
||||
@user.in_any_groups?(SiteSetting.chat_message_flag_allowed_groups_map)
|
||||
end
|
||||
|
||||
def can_flag_in_chat_channel?(chat_channel)
|
||||
return false if !can_modify_channel_message?(chat_channel)
|
||||
|
||||
can_see_chat_channel?(chat_channel)
|
||||
end
|
||||
|
||||
def can_flag_chat_message?(chat_message)
|
||||
return false if !authenticated? || !chat_message || chat_message.trashed? || !chat_message.user
|
||||
return false if chat_message.user.staff? && !SiteSetting.allow_flagging_staff
|
||||
return false if chat_message.user_id == @user.id
|
||||
|
||||
can_flag_chat_messages? && can_flag_in_chat_channel?(chat_message.chat_channel)
|
||||
end
|
||||
|
||||
def can_flag_message_as?(chat_message, flag_type_id, opts)
|
||||
return false if !is_staff? && (opts[:take_action] || opts[:queue_for_review])
|
||||
|
||||
if flag_type_id == ReviewableScore.types[:notify_user]
|
||||
is_warning = ActiveRecord::Type::Boolean.new.deserialize(opts[:is_warning])
|
||||
|
||||
return false if is_warning && !is_staff?
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def can_delete_chat?(message, chatable)
|
||||
return false if @user.silenced?
|
||||
return false if !can_modify_channel_message?(message.chat_channel)
|
||||
|
||||
if message.user_id == current_user.id
|
||||
can_delete_own_chats?(chatable)
|
||||
else
|
||||
can_delete_other_chats?(chatable)
|
||||
end
|
||||
end
|
||||
|
||||
def can_delete_own_chats?(chatable)
|
||||
return false if (SiteSetting.max_post_deletions_per_day < 1)
|
||||
return true if can_moderate_chat?(chatable)
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def can_delete_other_chats?(chatable)
|
||||
return true if can_moderate_chat?(chatable)
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def can_restore_chat?(message, chatable)
|
||||
return false if !can_modify_channel_message?(message.chat_channel)
|
||||
|
||||
if message.user_id == current_user.id
|
||||
case chatable.class.name
|
||||
when "Category"
|
||||
return can_see_category?(chatable)
|
||||
when "DirectMessageChannel"
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
can_delete_other_chats?(chatable)
|
||||
end
|
||||
|
||||
def can_restore_other_chats?(chatable)
|
||||
can_moderate_chat?(chatable)
|
||||
end
|
||||
|
||||
def can_edit_chat?(message)
|
||||
message.user_id == @user.id && !@user.silenced?
|
||||
end
|
||||
|
||||
def can_react?
|
||||
can_create_chat_message?
|
||||
end
|
||||
|
||||
def can_delete_category?(category)
|
||||
super && !category.category_channel
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,172 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Used to move chat messages from a chat channel to some other
|
||||
# location.
|
||||
#
|
||||
# Channel -> Channel:
|
||||
# -------------------
|
||||
#
|
||||
# Messages are sometimes misplaced and must be moved to another channel. For
|
||||
# now we only support moving messages between public channels, handling the
|
||||
# permissions and membership around moving things in and out of DMs is a little
|
||||
# much for V1.
|
||||
#
|
||||
# The original messages will be deleted, and then similar to PostMover in core,
|
||||
# all of the references associated to a chat message (e.g. reactions, bookmarks,
|
||||
# notifications, revisions, mentions, uploads) will be updated to the new
|
||||
# message IDs via a moved_chat_messages temporary table.
|
||||
class Chat::MessageMover
|
||||
class NoMessagesFound < StandardError
|
||||
end
|
||||
class InvalidChannel < StandardError
|
||||
end
|
||||
|
||||
def initialize(acting_user:, source_channel:, message_ids:)
|
||||
@source_channel = source_channel
|
||||
@acting_user = acting_user
|
||||
@source_message_ids = message_ids
|
||||
@source_messages = find_messages(@source_message_ids, source_channel)
|
||||
@ordered_source_message_ids = @source_messages.map(&:id)
|
||||
end
|
||||
|
||||
def move_to_channel(destination_channel)
|
||||
if !@source_channel.public_channel? || !destination_channel.public_channel?
|
||||
raise InvalidChannel.new(I18n.t("chat.errors.message_move_invalid_channel"))
|
||||
end
|
||||
|
||||
if @ordered_source_message_ids.empty?
|
||||
raise NoMessagesFound.new(I18n.t("chat.errors.message_move_no_messages_found"))
|
||||
end
|
||||
|
||||
moved_messages = nil
|
||||
|
||||
ChatMessage.transaction do
|
||||
create_temp_table
|
||||
moved_messages =
|
||||
find_messages(
|
||||
create_destination_messages_in_channel(destination_channel),
|
||||
destination_channel,
|
||||
)
|
||||
bulk_insert_movement_metadata
|
||||
update_references
|
||||
delete_source_messages
|
||||
end
|
||||
|
||||
add_moved_placeholder(destination_channel, moved_messages.first)
|
||||
moved_messages
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_messages(message_ids, channel)
|
||||
ChatMessage.where(id: message_ids, chat_channel_id: channel.id).order("created_at ASC, id ASC")
|
||||
end
|
||||
|
||||
def create_temp_table
|
||||
DB.exec("DROP TABLE IF EXISTS moved_chat_messages") if Rails.env.test?
|
||||
|
||||
DB.exec <<~SQL
|
||||
CREATE TEMPORARY TABLE moved_chat_messages (
|
||||
old_chat_message_id INTEGER,
|
||||
new_chat_message_id INTEGER
|
||||
) ON COMMIT DROP;
|
||||
|
||||
CREATE INDEX moved_chat_messages_old_chat_message_id ON moved_chat_messages(old_chat_message_id);
|
||||
SQL
|
||||
end
|
||||
|
||||
def bulk_insert_movement_metadata
|
||||
values_sql = @movement_metadata.map { |mm| "(#{mm[:old_id]}, #{mm[:new_id]})" }.join(",\n")
|
||||
DB.exec(
|
||||
"INSERT INTO moved_chat_messages(old_chat_message_id, new_chat_message_id) VALUES #{values_sql}",
|
||||
)
|
||||
end
|
||||
|
||||
##
|
||||
# We purposefully omit in_reply_to_id when creating the messages in the
|
||||
# new channel, because it could be pointing to a message that has not
|
||||
# been moved.
|
||||
def create_destination_messages_in_channel(destination_channel)
|
||||
query_args = {
|
||||
message_ids: @ordered_source_message_ids,
|
||||
destination_channel_id: destination_channel.id,
|
||||
}
|
||||
moved_message_ids = DB.query_single(<<~SQL, query_args)
|
||||
INSERT INTO chat_messages(chat_channel_id, user_id, message, cooked, cooked_version, created_at, updated_at)
|
||||
SELECT :destination_channel_id,
|
||||
user_id,
|
||||
message,
|
||||
cooked,
|
||||
cooked_version,
|
||||
CLOCK_TIMESTAMP(),
|
||||
CLOCK_TIMESTAMP()
|
||||
FROM chat_messages
|
||||
WHERE id IN (:message_ids)
|
||||
RETURNING id
|
||||
SQL
|
||||
|
||||
@movement_metadata =
|
||||
moved_message_ids.map.with_index do |chat_message_id, idx|
|
||||
{ old_id: @ordered_source_message_ids[idx], new_id: chat_message_id }
|
||||
end
|
||||
moved_message_ids
|
||||
end
|
||||
|
||||
def update_references
|
||||
DB.exec(<<~SQL)
|
||||
UPDATE chat_message_reactions cmr
|
||||
SET chat_message_id = mm.new_chat_message_id
|
||||
FROM moved_chat_messages mm
|
||||
WHERE cmr.chat_message_id = mm.old_chat_message_id
|
||||
SQL
|
||||
|
||||
DB.exec(<<~SQL)
|
||||
UPDATE chat_uploads cu
|
||||
SET chat_message_id = mm.new_chat_message_id
|
||||
FROM moved_chat_messages mm
|
||||
WHERE cu.chat_message_id = mm.old_chat_message_id
|
||||
SQL
|
||||
|
||||
DB.exec(<<~SQL)
|
||||
UPDATE chat_mentions cment
|
||||
SET chat_message_id = mm.new_chat_message_id
|
||||
FROM moved_chat_messages mm
|
||||
WHERE cment.chat_message_id = mm.old_chat_message_id
|
||||
SQL
|
||||
|
||||
DB.exec(<<~SQL)
|
||||
UPDATE chat_message_revisions crev
|
||||
SET chat_message_id = mm.new_chat_message_id
|
||||
FROM moved_chat_messages mm
|
||||
WHERE crev.chat_message_id = mm.old_chat_message_id
|
||||
SQL
|
||||
|
||||
DB.exec(<<~SQL)
|
||||
UPDATE chat_webhook_events cweb
|
||||
SET chat_message_id = mm.new_chat_message_id
|
||||
FROM moved_chat_messages mm
|
||||
WHERE cweb.chat_message_id = mm.old_chat_message_id
|
||||
SQL
|
||||
end
|
||||
|
||||
def delete_source_messages
|
||||
@source_messages.update_all(deleted_at: Time.zone.now, deleted_by_id: @acting_user.id)
|
||||
ChatPublisher.publish_bulk_delete!(@source_channel, @source_message_ids)
|
||||
end
|
||||
|
||||
def add_moved_placeholder(destination_channel, first_moved_message)
|
||||
Chat::ChatMessageCreator.create(
|
||||
chat_channel: @source_channel,
|
||||
user: Discourse.system_user,
|
||||
content:
|
||||
I18n.t(
|
||||
"chat.channel.messages_moved",
|
||||
count: @source_message_ids.length,
|
||||
acting_username: @acting_user.username,
|
||||
channel_name: destination_channel.title(@acting_user),
|
||||
first_moved_message_url: first_moved_message.url,
|
||||
),
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
{{^cooked}}
|
||||
<aside class="onebox chat-onebox">
|
||||
<article class="onebox-body chat-onebox-body">
|
||||
<h3 class="chat-onebox-title">
|
||||
<a href="{{url}}">
|
||||
{{#is_category}}
|
||||
<span class="category-chat-badge" style="color: #{{color}}">
|
||||
<svg class="fa d-icon d-icon-hashtag svg-icon svg-string" xmlns="http://www.w3.org/2000/svg"><use href="#hashtag"></use></svg>
|
||||
</span>
|
||||
{{/is_category}}
|
||||
<span class="clear-badge">{{{channel_name}}}</span>
|
||||
</a>
|
||||
</h3>
|
||||
{{#description}}
|
||||
<div class="chat-onebox-description">{{description}}</div>
|
||||
{{/description}}
|
||||
<div class="chat-onebox-members-count">{{user_count_str}}</div>
|
||||
<div class="chat-onebox-members">
|
||||
{{#users}}
|
||||
<a class="trigger-user-card" data-user-card="{{username}}" aria-hidden="true" tabindex="-1">
|
||||
<img loading="lazy" alt="{{username}}" width="30" height="30" src="{{avatar_url}}" class="avatar">
|
||||
</a>
|
||||
{{/users}}
|
||||
{{remaining_user_count_str}}
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
{{/cooked}}
|
||||
|
||||
{{#cooked}}
|
||||
<div class="chat-transcript" data-message-id="{{message_id}}" data-username="{{username}}" data-datetime="{{created_at_str}}" data-channel-name="{{channel_name}}" data-channel-id="{{channel_id}}">
|
||||
<div class="chat-transcript-user">
|
||||
<div class="chat-transcript-user-avatar">
|
||||
<a class="trigger-user-card" data-user-card="{{username}}" aria-hidden="true" tabindex="-1">
|
||||
<img loading="lazy" alt="{{username}}" width="20" height="20" src="{{avatar_url}}" class="avatar">
|
||||
</a>
|
||||
</div>
|
||||
<div class="chat-transcript-username">{{username}}</div>
|
||||
<div class="chat-transcript-datetime">
|
||||
<a href="{{url}}" title="{{created_at}}">{{created_at}}</a>
|
||||
</div>
|
||||
<a class="chat-transcript-channel" href="/chat/channel/{{channel_id}}/-">
|
||||
{{#is_category}}
|
||||
<span class="category-chat-badge" style="color: #{{color}}">
|
||||
<svg class="fa d-icon d-icon-hashtag svg-icon svg-string" xmlns="http://www.w3.org/2000/svg"><use href="#hashtag"></use></svg>
|
||||
</span>
|
||||
{{/is_category}}
|
||||
{{#is_topic}}
|
||||
<span class="topic-chat-icon">
|
||||
<svg class="fa d-icon d-icon-far-comments svg-icon svg-string" xmlns="http://www.w3.org/2000/svg"><use href="#far-comments"></use></svg>
|
||||
</span>
|
||||
{{/is_topic}}
|
||||
{{channel_name}}
|
||||
</a>
|
||||
</div>
|
||||
<div class="chat-transcript-messages">{{{cooked}}}</div>
|
||||
</div>
|
||||
{{/cooked}}
|
||||
@@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Handles :post_alerter_after_save_post events from
|
||||
# core. Used for notifying users that their chat message
|
||||
# has been quoted in a post.
|
||||
class Chat::PostNotificationHandler
|
||||
attr_reader :post
|
||||
|
||||
def initialize(post, notified_users)
|
||||
@post = post
|
||||
@notified_users = notified_users
|
||||
end
|
||||
|
||||
def handle
|
||||
return false if post.post_type == Post.types[:whisper]
|
||||
return false if post.topic.blank?
|
||||
return false if post.topic.private_message?
|
||||
|
||||
quoted_users = extract_quoted_users(post)
|
||||
if @notified_users.present?
|
||||
quoted_users = quoted_users.where("users.id NOT IN (?)", @notified_users)
|
||||
end
|
||||
|
||||
opts = { user_id: post.user.id, display_username: post.user.username }
|
||||
quoted_users.each do |user|
|
||||
# PostAlerter.create_notification handles many edge cases, such as
|
||||
# muting, ignoring, double notifications etc.
|
||||
PostAlerter.new.create_notification(user, Notification.types[:chat_quoted], post, opts)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_quoted_users(post)
|
||||
usernames =
|
||||
post.raw.scan(/\[chat quote=\"([^;]+);.+\"\]/).uniq.map { |q| q.first.strip.downcase }
|
||||
User.where.not(id: post.user_id).where(username_lower: usernames)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::SecureUploadsCompatibility
|
||||
##
|
||||
# At this point in time, secure uploads is not compatible with chat,
|
||||
# so if it is enabled then chat uploads must be disabled to avoid undesirable
|
||||
# behaviour.
|
||||
#
|
||||
# The env var DISCOURSE_ALLOW_UNSECURE_CHAT_UPLOADS can be set to keep
|
||||
# it enabled, but this is strongly advised against.
|
||||
def self.update_settings
|
||||
if SiteSetting.secure_uploads && SiteSetting.chat_allow_uploads &&
|
||||
!GlobalSetting.allow_unsecure_chat_uploads
|
||||
SiteSetting.chat_allow_uploads = false
|
||||
StaffActionLogger.new(Discourse.system_user).log_site_setting_change(
|
||||
"chat_allow_uploads",
|
||||
true,
|
||||
false,
|
||||
context: "Disabled because secure_uploads is enabled",
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,60 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
##
|
||||
# Processes slack-formatted text messages, as Mattermost does with
|
||||
# Slack incoming webhook interoperability, for example links in the
|
||||
# format <LINK> and <LINK|TEXT>, <!here> and <!all> mentions.
|
||||
#
|
||||
# See https://api.slack.com/reference/surfaces/formatting for all of
|
||||
# the different formatting slack supports with mrkdwn which is mostly
|
||||
# identical to Markdown.
|
||||
#
|
||||
# Mattermost docs for translating the slack format:
|
||||
#
|
||||
# https://docs.mattermost.com/developer/webhooks-incoming.html?highlight=translate%20slack%20data%20format%20mattermost#translate-slack-s-data-format-to-mattermost
|
||||
#
|
||||
# We may want to process attachments and blocks from slack in future, and
|
||||
# convert user IDs into user mentions.
|
||||
class Chat::SlackCompatibility
|
||||
MRKDWN_LINK_REGEX = Regexp.new(/(<[^\n<\|>]+>|<[^\n<\>]+>)/).freeze
|
||||
|
||||
class << self
|
||||
def process_text(text)
|
||||
text = text.gsub("<!here>", "@here")
|
||||
text = text.gsub("<!all>", "@all")
|
||||
|
||||
text.scan(MRKDWN_LINK_REGEX) do |match|
|
||||
match = match.first
|
||||
|
||||
if match.include?("|")
|
||||
link, title = match.split("|")[0..1]
|
||||
else
|
||||
link = match
|
||||
end
|
||||
|
||||
title = title&.gsub(/<|>/, "")
|
||||
link = link&.gsub(/<|>/, "")
|
||||
|
||||
if title
|
||||
text = text.gsub(match, "[#{title}](#{link})")
|
||||
else
|
||||
text = text.gsub(match, "#{link}")
|
||||
end
|
||||
end
|
||||
|
||||
text
|
||||
end
|
||||
|
||||
# TODO: This is quite hacky and is only here to support a single
|
||||
# attachment for our OpsGenie integration. In future we would
|
||||
# want to iterate through this attachments array and extract
|
||||
# things properly.
|
||||
#
|
||||
# See https://api.slack.com/reference/messaging/attachments for
|
||||
# more details on what fields are here.
|
||||
def process_legacy_attachments(attachments)
|
||||
text = CGI.unescape(attachments[0][:fallback])
|
||||
process_text(text)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
if Discourse.allow_dev_populate?
|
||||
chat_task = Rake::Task["dev:populate"]
|
||||
chat_task.enhance do
|
||||
SiteSetting.chat_enabled = true
|
||||
DiscourseDev::PublicChannel.populate!
|
||||
DiscourseDev::DirectChannel.populate!
|
||||
DiscourseDev::Message.populate!
|
||||
end
|
||||
|
||||
desc "Generates sample content for chat"
|
||||
task "chat:populate" => ["db:load_config"] do |_, args|
|
||||
DiscourseDev::PublicChannel.new.populate!(ignore_current_count: true)
|
||||
DiscourseDev::DirectChannel.new.populate!(ignore_current_count: true)
|
||||
DiscourseDev::Message.new.populate!(ignore_current_count: true)
|
||||
end
|
||||
|
||||
desc "Generates sample messages in channels"
|
||||
task "chat:message:populate" => ["db:load_config"] do |_, args|
|
||||
DiscourseDev::Message.new.populate!(ignore_current_count: true)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,143 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
task "chat_messages:rebake_uncooked_chat_messages" => :environment do
|
||||
# rebaking uncooked chat_messages can very quickly saturate sidekiq
|
||||
# this provides an insurance policy so you can safely run and stop
|
||||
# this rake task without worrying about your sidekiq imploding
|
||||
Jobs.run_immediately!
|
||||
|
||||
ENV["RAILS_DB"] ? rebake_uncooked_chat_messages : rebake_uncooked_chat_messages_all_sites
|
||||
end
|
||||
|
||||
def rebake_uncooked_chat_messages_all_sites
|
||||
RailsMultisite::ConnectionManagement.each_connection { |db| rebake_uncooked_chat_messages }
|
||||
end
|
||||
|
||||
def rebake_uncooked_chat_messages
|
||||
puts "Rebaking uncooked chat messages on #{RailsMultisite::ConnectionManagement.current_db}"
|
||||
uncooked = ChatMessage.uncooked
|
||||
|
||||
rebaked = 0
|
||||
total = uncooked.count
|
||||
|
||||
ids = uncooked.pluck(:id)
|
||||
# work randomly so you can run this job from lots of consoles if needed
|
||||
ids.shuffle!
|
||||
|
||||
ids.each do |id|
|
||||
# may have been cooked in interim
|
||||
chat_message = uncooked.where(id: id).first
|
||||
|
||||
rebake_chat_message(chat_message) if chat_message
|
||||
|
||||
print_status(rebaked += 1, total)
|
||||
end
|
||||
|
||||
puts "", "#{rebaked} chat messages done!", ""
|
||||
end
|
||||
|
||||
def rebake_chat_message(chat_message, opts = {})
|
||||
opts[:priority] = :ultra_low if !opts[:priority]
|
||||
chat_message.rebake!(**opts)
|
||||
rescue => e
|
||||
puts "",
|
||||
"Failed to rebake chat message (chat_message_id: #{chat_message.id})",
|
||||
e,
|
||||
e.backtrace.join("\n")
|
||||
end
|
||||
|
||||
task "chat:make_channel_to_test_archiving", [:user_for_membership] => :environment do |t, args|
|
||||
user_for_membership = args[:user_for_membership]
|
||||
|
||||
# do not want this running in production!
|
||||
return if !Rails.env.development?
|
||||
|
||||
require "fabrication"
|
||||
Dir[Rails.root.join("spec/fabricators/*.rb")].each { |f| require f }
|
||||
|
||||
messages = [
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
"Cras sit **amet** metus eget nisl accumsan ullamcorper.",
|
||||
"Vestibulum commodo justo _quis_ fringilla fringilla.",
|
||||
"Etiam malesuada erat eget aliquam interdum.",
|
||||
"Praesent mattis lacus nec ~~orci~~ [spoiler]semper[/spoiler], et fermentum augue tincidunt.",
|
||||
"Duis vel tortor suscipit justo fringilla faucibus id tempus purus.",
|
||||
"Phasellus *tempus erat* sit amet pharetra facilisis.",
|
||||
"Fusce egestas urna ut nisi ornare, ut malesuada est fermentum.",
|
||||
"Aenean ornare arcu vitae pulvinar dictum.",
|
||||
"Nam at turpis eu magna sollicitudin fringilla sed sed diam.",
|
||||
"Proin non [enim](https://discourse.org/team) nec mauris efficitur convallis.",
|
||||
"Nullam cursus lacus non libero vulputate ornare.",
|
||||
"In eleifend ante ut ullamcorper ultrices.",
|
||||
"In placerat diam sit amet nibh feugiat, in posuere metus feugiat.",
|
||||
"Nullam porttitor leo a leo `cursus`, id hendrerit dui ultrices.",
|
||||
"Pellentesque ut @#{user_for_membership} ut ex pulvinar pharetra sit amet ac leo.",
|
||||
"Vestibulum sit amet enim et lectus tincidunt rhoncus hendrerit in enim.",
|
||||
<<~MSG,
|
||||
some bigger message
|
||||
|
||||
```ruby
|
||||
beep = \"wow\"
|
||||
puts beep
|
||||
```
|
||||
MSG
|
||||
]
|
||||
|
||||
topic = nil
|
||||
chat_channel = nil
|
||||
|
||||
Topic.transaction do
|
||||
topic =
|
||||
Fabricate(
|
||||
:topic,
|
||||
user: make_test_user,
|
||||
title: "Testing topic for chat archiving #{SecureRandom.hex(4)}",
|
||||
)
|
||||
Fabricate(
|
||||
:post,
|
||||
topic: topic,
|
||||
user: topic.user,
|
||||
raw: "This is some cool first post for archive stuff",
|
||||
)
|
||||
chat_channel =
|
||||
ChatChannel.create(
|
||||
chatable: topic,
|
||||
chatable_type: "Topic",
|
||||
name: "testing channel for archiving #{SecureRandom.hex(4)}",
|
||||
)
|
||||
end
|
||||
|
||||
puts "topic: #{topic.id}, #{topic.title}"
|
||||
puts "channel: #{chat_channel.id}, #{chat_channel.name}"
|
||||
|
||||
users = [make_test_user, make_test_user, make_test_user]
|
||||
|
||||
ChatChannel.transaction do
|
||||
start_time = Time.now
|
||||
|
||||
puts "creating 1039 messages for the channel"
|
||||
1039.times do
|
||||
cm = ChatMessage.new(message: messages.sample, user: users.sample, chat_channel: chat_channel)
|
||||
cm.cook
|
||||
cm.save!
|
||||
end
|
||||
|
||||
puts "message creation done"
|
||||
puts "took #{Time.now - start_time} seconds"
|
||||
|
||||
UserChatChannelMembership.create(
|
||||
chat_channel: chat_channel,
|
||||
last_read_message_id: 0,
|
||||
user: User.find_by(username: user_for_membership),
|
||||
following: true,
|
||||
)
|
||||
end
|
||||
|
||||
puts "channel is located at #{chat_channel.url}"
|
||||
end
|
||||
|
||||
def make_test_user
|
||||
return if !Rails.env.development?
|
||||
unique_prefix = "archiveuser#{SecureRandom.hex(4)}"
|
||||
Fabricate(:user, username: unique_prefix, email: "#{unique_prefix}@testemail.com")
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ChatAllowUploadsValidator
|
||||
def initialize(opts = {})
|
||||
@opts = opts
|
||||
end
|
||||
|
||||
def valid_value?(value)
|
||||
return false if value == "t" && prevent_enabling_chat_uploads?
|
||||
true
|
||||
end
|
||||
|
||||
def error_message
|
||||
if prevent_enabling_chat_uploads?
|
||||
I18n.t("site_settings.errors.chat_upload_not_allowed_secure_uploads")
|
||||
end
|
||||
end
|
||||
|
||||
def prevent_enabling_chat_uploads?
|
||||
SiteSetting.secure_uploads && !GlobalSetting.allow_unsecure_chat_uploads
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ChatDefaultChannelValidator
|
||||
def initialize(opts = {})
|
||||
@opts = opts
|
||||
end
|
||||
|
||||
def valid_value?(value)
|
||||
!!(value == "" || ChatChannel.find_by(id: value.to_i)&.public_channel?)
|
||||
end
|
||||
|
||||
def error_message
|
||||
I18n.t("site_settings.errors.chat_default_channel")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class DirectMessageEnabledGroupsValidator
|
||||
def initialize(opts = {})
|
||||
@opts = opts
|
||||
end
|
||||
|
||||
def valid_value?(val)
|
||||
val.present? && val != ""
|
||||
end
|
||||
|
||||
def error_message
|
||||
I18n.t("site_settings.errors.direct_message_enabled_groups_invalid")
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user