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:
Roman Rizzi
2022-11-02 10:41:30 -03:00
committed by GitHub
parent e7e24843dc
commit 0a5f548635
697 changed files with 61642 additions and 40 deletions
@@ -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
+221
View File
@@ -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
+58
View File
@@ -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
+101
View File
@@ -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
+85
View File
@@ -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
+91
View File
@@ -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
+335
View File
@@ -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
+208
View File
@@ -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
+28
View File
@@ -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
+51
View File
@@ -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
+177
View File
@@ -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
+30
View File
@@ -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
+182
View File
@@ -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
+172
View File
@@ -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
+60
View File
@@ -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
+23
View File
@@ -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
+143
View File
@@ -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