diff --git a/app/models/reviewable.rb b/app/models/reviewable.rb index 02b1b47421b..759b8e6e4aa 100644 --- a/app/models/reviewable.rb +++ b/app/models/reviewable.rb @@ -129,7 +129,7 @@ class Reviewable < ActiveRecord::Base update_args = { status: statuses[:pending], id: target.id, - type: target.class.name, + type: target.class.sti_name, potential_spam: potential_spam == true ? true : nil, } diff --git a/plugins/chat/app/controllers/admin/admin_incoming_chat_webhooks_controller.rb b/plugins/chat/app/controllers/admin/admin_incoming_chat_webhooks_controller.rb deleted file mode 100644 index 24bcd25abda..00000000000 --- a/plugins/chat/app/controllers/admin/admin_incoming_chat_webhooks_controller.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -class Chat::AdminIncomingChatWebhooksController < Admin::AdminController - requires_plugin Chat::PLUGIN_NAME - - def index - render_serialized( - { - chat_channels: ChatChannel.public_channels, - incoming_chat_webhooks: IncomingChatWebhook.includes(:chat_channel).all, - }, - AdminChatIndexSerializer, - root: false, - ) - end - - def create - params.require(%i[name chat_channel_id]) - - chat_channel = ChatChannel.find_by(id: params[:chat_channel_id]) - raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel? - - webhook = IncomingChatWebhook.new(name: params[:name], chat_channel: chat_channel) - if webhook.save - render_serialized(webhook, IncomingChatWebhookSerializer, root: false) - else - render_json_error(webhook) - end - end - - def update - params.require(%i[incoming_chat_webhook_id name chat_channel_id]) - - webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id]) - raise Discourse::NotFound unless webhook - - chat_channel = ChatChannel.find_by(id: params[:chat_channel_id]) - raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel? - - if webhook.update( - name: params[:name], - description: params[:description], - emoji: params[:emoji], - username: params[:username], - chat_channel: chat_channel, - ) - render json: success_json - else - render_json_error(webhook) - end - end - - def destroy - params.require(:incoming_chat_webhook_id) - - webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id]) - webhook.destroy if webhook - render json: success_json - end -end diff --git a/plugins/chat/app/controllers/api/chat_channels_status_controller.rb b/plugins/chat/app/controllers/api/chat_channels_status_controller.rb deleted file mode 100644 index 863ee6f4f33..00000000000 --- a/plugins/chat/app/controllers/api/chat_channels_status_controller.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class Chat::Api::ChatChannelsStatusController < Chat::Api::ChatChannelsController - def update - with_service(Chat::Service::UpdateChannelStatus) do - on_success { render_serialized(result.channel, ChatChannelSerializer, root: "channel") } - on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound } - on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess } - end - end -end diff --git a/plugins/chat/app/controllers/api/chat_current_user_channels_controller.rb b/plugins/chat/app/controllers/api/chat_current_user_channels_controller.rb deleted file mode 100644 index ecc01163606..00000000000 --- a/plugins/chat/app/controllers/api/chat_current_user_channels_controller.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class Chat::Api::ChatCurrentUserChannelsController < Chat::Api - def index - structured = Chat::ChatChannelFetcher.structured(guardian) - render_serialized(structured, ChatChannelIndexSerializer, root: false) - end -end diff --git a/plugins/chat/app/controllers/api_controller.rb b/plugins/chat/app/controllers/api_controller.rb deleted file mode 100644 index 70bf35dc60c..00000000000 --- a/plugins/chat/app/controllers/api_controller.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -class Chat::Api < Chat::ChatBaseController - before_action :ensure_logged_in - before_action :ensure_can_chat - - include Chat::WithServiceHelper - - private - - def ensure_can_chat - raise Discourse::NotFound unless SiteSetting.chat_enabled - guardian.ensure_can_chat! - end - - def default_actions_for_service - proc do - on_success { render(json: success_json) } - on_failure { render(json: failed_json, status: 422) } - on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess } - on_failed_contract do - render( - json: failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages), - status: 400, - ) - end - end - end -end diff --git a/plugins/chat/app/controllers/chat/admin/incoming_webhooks_controller.rb b/plugins/chat/app/controllers/chat/admin/incoming_webhooks_controller.rb new file mode 100644 index 00000000000..14932e9212f --- /dev/null +++ b/plugins/chat/app/controllers/chat/admin/incoming_webhooks_controller.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Chat + module Admin + class IncomingWebhooksController < ::Admin::AdminController + requires_plugin Chat::PLUGIN_NAME + + def index + render_serialized( + { + chat_channels: Chat::Channel.public_channels, + incoming_chat_webhooks: Chat::IncomingWebhook.includes(:chat_channel).all, + }, + Chat::AdminChatIndexSerializer, + root: false, + ) + end + + def create + params.require(%i[name chat_channel_id]) + + chat_channel = Chat::Channel.find_by(id: params[:chat_channel_id]) + raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel? + + webhook = Chat::IncomingWebhook.new(name: params[:name], chat_channel: chat_channel) + if webhook.save + render_serialized(webhook, Chat::IncomingWebhookSerializer, root: false) + else + render_json_error(webhook) + end + end + + def update + params.require(%i[incoming_chat_webhook_id name chat_channel_id]) + + webhook = Chat::IncomingWebhook.find_by(id: params[:incoming_chat_webhook_id]) + raise Discourse::NotFound unless webhook + + chat_channel = Chat::Channel.find_by(id: params[:chat_channel_id]) + raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel? + + if webhook.update( + name: params[:name], + description: params[:description], + emoji: params[:emoji], + username: params[:username], + chat_channel: chat_channel, + ) + render json: success_json + else + render_json_error(webhook) + end + end + + def destroy + params.require(:incoming_chat_webhook_id) + + webhook = Chat::IncomingWebhook.find_by(id: params[:incoming_chat_webhook_id]) + webhook.destroy if webhook + render json: success_json + end + end + end +end diff --git a/plugins/chat/app/controllers/api/category_chatables_controller.rb b/plugins/chat/app/controllers/chat/api/category_chatables_controller.rb similarity index 100% rename from plugins/chat/app/controllers/api/category_chatables_controller.rb rename to plugins/chat/app/controllers/chat/api/category_chatables_controller.rb diff --git a/plugins/chat/app/controllers/api/chat_channel_threads_controller.rb b/plugins/chat/app/controllers/chat/api/channel_threads_controller.rb similarity index 59% rename from plugins/chat/app/controllers/api/chat_channel_threads_controller.rb rename to plugins/chat/app/controllers/chat/api/channel_threads_controller.rb index 58baa9bb8a4..62a3525e749 100644 --- a/plugins/chat/app/controllers/api/chat_channel_threads_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channel_threads_controller.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -class Chat::Api::ChatChannelThreadsController < Chat::Api +class Chat::Api::ChannelThreadsController < Chat::ApiController def show - with_service(Chat::Service::LookupThread) do - on_success { render_serialized(result.thread, ChatThreadSerializer, root: "thread") } + with_service(::Chat::LookupThread) do + on_success { render_serialized(result.thread, ::Chat::ThreadSerializer, root: "thread") } on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound } on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound } on_model_not_found(:thread) { raise Discourse::NotFound } diff --git a/plugins/chat/app/controllers/api/chat_channels_archives_controller.rb b/plugins/chat/app/controllers/chat/api/channels_archives_controller.rb similarity index 82% rename from plugins/chat/app/controllers/api/chat_channels_archives_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_archives_controller.rb index ca5640e9925..51ac0be0fdb 100644 --- a/plugins/chat/app/controllers/api/chat_channels_archives_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_archives_controller.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -class Chat::Api::ChatChannelsArchivesController < Chat::Api::ChatChannelsController +class Chat::Api::ChannelsArchivesController < Chat::Api::ChannelsController def create existing_archive = channel_from_params.chat_channel_archive if existing_archive.present? guardian.ensure_can_change_channel_status!(channel_from_params, :archived) raise Discourse::InvalidAccess if !existing_archive.failed? - Chat::ChatChannelArchiveService.retry_archive_process(chat_channel: channel_from_params) + Chat::ChannelArchiveService.retry_archive_process(chat_channel: channel_from_params) return render json: success_json end @@ -20,12 +20,12 @@ class Chat::Api::ChatChannelsArchivesController < Chat::Api::ChatChannelsControl end begin - Chat::ChatChannelArchiveService.create_archive_process( + Chat::ChannelArchiveService.create_archive_process( chat_channel: channel_from_params, acting_user: current_user, topic_params: topic_params, ) - rescue Chat::ChatChannelArchiveService::ArchiveValidationError => err + rescue Chat::ChannelArchiveService::ArchiveValidationError => err return render json: failed_json.merge(errors: err.errors), status: 400 end diff --git a/plugins/chat/app/controllers/api/chat_channels_controller.rb b/plugins/chat/app/controllers/chat/api/channels_controller.rb similarity index 82% rename from plugins/chat/app/controllers/api/chat_channels_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_controller.rb index 992c58cd6e9..0cd2c03f55d 100644 --- a/plugins/chat/app/controllers/api/chat_channels_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_controller.rb @@ -3,19 +3,19 @@ CHANNEL_EDITABLE_PARAMS = %i[name description slug] CATEGORY_CHANNEL_EDITABLE_PARAMS = %i[auto_join_users allow_channel_wide_mentions] -class Chat::Api::ChatChannelsController < Chat::Api +class Chat::Api::ChannelsController < Chat::ApiController def index permitted = params.permit(:filter, :limit, :offset, :status) options = { filter: permitted[:filter], limit: (permitted[:limit] || 25).to_i } options[:offset] = permitted[:offset].to_i - options[:status] = ChatChannel.statuses[permitted[:status]] ? permitted[:status] : nil + options[:status] = Chat::Channel.statuses[permitted[:status]] ? permitted[:status] : nil - memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user) - channels = Chat::ChatChannelFetcher.secured_public_channels(guardian, memberships, options) + memberships = Chat::ChannelMembershipManager.all_for_user(current_user) + channels = Chat::ChannelFetcher.secured_public_channels(guardian, memberships, options) serialized_channels = channels.map do |channel| - ChatChannelSerializer.new( + Chat::ChannelSerializer.new( channel, scope: Guardian.new(current_user), membership: memberships.find { |membership| membership.chat_channel_id == channel.id }, @@ -29,7 +29,7 @@ class Chat::Api::ChatChannelsController < Chat::Api end def destroy - with_service Chat::Service::TrashChannel do + with_service Chat::TrashChannel do on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound } end end @@ -43,7 +43,7 @@ class Chat::Api::ChatChannelsController < Chat::Api raise Discourse::InvalidParameters.new(:name) end - if ChatChannel.exists?( + if Chat::Channel.exists?( chatable_type: "Category", chatable_id: channel_params[:chatable_id], name: channel_params[:name], @@ -69,12 +69,12 @@ class Chat::Api::ChatChannelsController < Chat::Api channel.user_chat_channel_memberships.create!(user: current_user, following: true) if channel.auto_join_users - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships + Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships end render_serialized( channel, - ChatChannelSerializer, + Chat::ChannelSerializer, membership: channel.membership_for(current_user), root: "channel", ) @@ -83,7 +83,7 @@ class Chat::Api::ChatChannelsController < Chat::Api def show render_serialized( channel_from_params, - ChatChannelSerializer, + Chat::ChannelSerializer, membership: channel_from_params.membership_for(current_user), root: "channel", ) @@ -96,11 +96,11 @@ class Chat::Api::ChatChannelsController < Chat::Api auto_join_limiter(channel_from_params).performed! end - with_service(Chat::Service::UpdateChannel, **params_to_edit) do + with_service(Chat::UpdateChannel, **params_to_edit) do on_success do render_serialized( result.channel, - ChatChannelSerializer, + Chat::ChannelSerializer, root: "channel", membership: result.channel.membership_for(current_user), ) @@ -116,7 +116,7 @@ class Chat::Api::ChatChannelsController < Chat::Api def channel_from_params @channel ||= begin - channel = ChatChannel.find(params.require(:channel_id)) + channel = Chat::Channel.find(params.require(:channel_id)) guardian.ensure_can_preview_chat_channel!(channel) channel end @@ -126,7 +126,7 @@ class Chat::Api::ChatChannelsController < Chat::Api @membership ||= begin membership = - Chat::ChatChannelMembershipManager.new(channel_from_params).find_for_user(current_user) + Chat::ChannelMembershipManager.new(channel_from_params).find_for_user(current_user) raise Discourse::NotFound if membership.blank? membership end diff --git a/plugins/chat/app/controllers/api/chat_channels_current_user_membership_controller.rb b/plugins/chat/app/controllers/chat/api/channels_current_user_membership_controller.rb similarity index 65% rename from plugins/chat/app/controllers/api/chat_channels_current_user_membership_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_current_user_membership_controller.rb index 91422f9d673..5f1e4b2af14 100644 --- a/plugins/chat/app/controllers/api/chat_channels_current_user_membership_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_current_user_membership_controller.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true -class Chat::Api::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatChannelsController +class Chat::Api::ChannelsCurrentUserMembershipController < Chat::Api::ChannelsController def create guardian.ensure_can_join_chat_channel!(channel_from_params) render_serialized( channel_from_params.add(current_user), - UserChatChannelMembershipSerializer, + Chat::UserChannelMembershipSerializer, root: "membership", ) end @@ -14,7 +14,7 @@ class Chat::Api::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatCh def destroy render_serialized( channel_from_params.remove(current_user), - UserChatChannelMembershipSerializer, + Chat::UserChannelMembershipSerializer, root: "membership", ) end diff --git a/plugins/chat/app/controllers/api/chat_channels_current_user_notifications_settings_controller.rb b/plugins/chat/app/controllers/chat/api/channels_current_user_notifications_settings_controller.rb similarity index 71% rename from plugins/chat/app/controllers/api/chat_channels_current_user_notifications_settings_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_current_user_notifications_settings_controller.rb index d9a8f4ac57e..6c39585d3ec 100644 --- a/plugins/chat/app/controllers/api/chat_channels_current_user_notifications_settings_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_current_user_notifications_settings_controller.rb @@ -2,13 +2,13 @@ MEMBERSHIP_EDITABLE_PARAMS = %i[muted desktop_notification_level mobile_notification_level] -class Chat::Api::ChatChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChatChannelsController +class Chat::Api::ChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChannelsController def update settings_params = params.require(:notifications_settings).permit(MEMBERSHIP_EDITABLE_PARAMS) membership_from_params.update!(settings_params.to_h) render_serialized( membership_from_params, - UserChatChannelMembershipSerializer, + Chat::UserChannelMembershipSerializer, root: "membership", ) end diff --git a/plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb b/plugins/chat/app/controllers/chat/api/channels_memberships_controller.rb similarity index 79% rename from plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_memberships_controller.rb index d6fe2fd4ad9..0392b3bb177 100644 --- a/plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_memberships_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsController +class Chat::Api::ChannelsMembershipsController < Chat::Api::ChannelsController def index params.permit(:username, :offset, :limit) @@ -8,7 +8,7 @@ class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsCont limit = (params[:limit] || 50).to_i.clamp(1, 50) memberships = - ChatChannelMembershipsQuery.call( + Chat::ChannelMembershipsQuery.call( channel: channel_from_params, offset: offset, limit: limit, @@ -17,7 +17,7 @@ class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsCont render_serialized( memberships, - UserChatChannelMembershipSerializer, + Chat::UserChannelMembershipSerializer, root: "memberships", meta: { total_rows: channel_from_params.user_count, diff --git a/plugins/chat/app/controllers/api/chat_channels_messages_moves_controller.rb b/plugins/chat/app/controllers/chat/api/channels_messages_moves_controller.rb similarity index 82% rename from plugins/chat/app/controllers/api/chat_channels_messages_moves_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_messages_moves_controller.rb index d0d3ff1777f..100b9330492 100644 --- a/plugins/chat/app/controllers/api/chat_channels_messages_moves_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_messages_moves_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Chat::Api::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsController +class Chat::Api::ChannelsMessagesMovesController < Chat::Api::ChannelsController def create move_params = params.require(:move) move_params.require(:message_ids) @@ -8,10 +8,7 @@ class Chat::Api::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsCo raise Discourse::InvalidAccess if !guardian.can_move_chat_messages?(channel_from_params) destination_channel = - Chat::ChatChannelFetcher.find_with_access_check( - move_params[:destination_channel_id], - guardian, - ) + Chat::ChannelFetcher.find_with_access_check(move_params[:destination_channel_id], guardian) begin message_ids = move_params[:message_ids].map(&:to_i) diff --git a/plugins/chat/app/controllers/chat/api/channels_status_controller.rb b/plugins/chat/app/controllers/chat/api/channels_status_controller.rb new file mode 100644 index 00000000000..38e83f23104 --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/channels_status_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Chat::Api::ChannelsStatusController < Chat::Api::ChannelsController + def update + with_service(Chat::UpdateChannelStatus) do + on_success { render_serialized(result.channel, Chat::ChannelSerializer, root: "channel") } + on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound } + on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess } + end + end +end diff --git a/plugins/chat/app/controllers/api/chat_chatables_controller.rb b/plugins/chat/app/controllers/chat/api/chatables_controller.rb similarity index 89% rename from plugins/chat/app/controllers/api/chat_chatables_controller.rb rename to plugins/chat/app/controllers/chat/api/chatables_controller.rb index 9eaec32b89b..c58a4796456 100644 --- a/plugins/chat/app/controllers/api/chat_chatables_controller.rb +++ b/plugins/chat/app/controllers/chat/api/chatables_controller.rb @@ -1,13 +1,14 @@ # frozen_string_literal: true -class Chat::Api::ChatChatablesController < Chat::Api +class Chat::Api::ChatablesController < Chat::ApiController def index params.require(:filter) filter = params[:filter].downcase - memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user) + memberships = Chat::ChannelMembershipManager.all_for_user(current_user) + public_channels = - Chat::ChatChannelFetcher.secured_public_channels( + Chat::ChannelFetcher.secured_public_channels( guardian, memberships, filter: filter, @@ -41,7 +42,7 @@ class Chat::Api::ChatChatablesController < Chat::Api direct_message_channels = if users.count > 0 # FIXME: investigate the cost of this query - ChatChannel + Chat::Channel .includes(chatable: :users) .joins(direct_message: :direct_message_users) .group(1) @@ -75,7 +76,7 @@ class Chat::Api::ChatChatablesController < Chat::Api users: users_without_channel, memberships: memberships, }, - ChatChannelSearchSerializer, + Chat::ChannelSearchSerializer, root: false, ) end diff --git a/plugins/chat/app/controllers/chat/api/current_user_channels_controller.rb b/plugins/chat/app/controllers/chat/api/current_user_channels_controller.rb new file mode 100644 index 00000000000..613af229090 --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/current_user_channels_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Chat::Api::CurrentUserChannelsController < Chat::ApiController + def index + structured = Chat::ChannelFetcher.structured(guardian) + render_serialized(structured, Chat::ChannelIndexSerializer, root: false) + end +end diff --git a/plugins/chat/app/controllers/api/hints_controller.rb b/plugins/chat/app/controllers/chat/api/hints_controller.rb similarity index 100% rename from plugins/chat/app/controllers/api/hints_controller.rb rename to plugins/chat/app/controllers/chat/api/hints_controller.rb diff --git a/plugins/chat/app/controllers/chat/api_controller.rb b/plugins/chat/app/controllers/chat/api_controller.rb new file mode 100644 index 00000000000..b95446c6cd8 --- /dev/null +++ b/plugins/chat/app/controllers/chat/api_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Chat + class ApiController < ::Chat::BaseController + before_action :ensure_logged_in + before_action :ensure_can_chat + + include Chat::WithServiceHelper + + private + + def ensure_can_chat + raise Discourse::NotFound unless SiteSetting.chat_enabled + guardian.ensure_can_chat! + end + + def default_actions_for_service + proc do + on_success { render(json: success_json) } + on_failure { render(json: failed_json, status: 422) } + on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess } + on_failed_contract do + render( + json: + failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages), + status: 400, + ) + end + end + end + end +end diff --git a/plugins/chat/app/controllers/chat/base_controller.rb b/plugins/chat/app/controllers/chat/base_controller.rb new file mode 100644 index 00000000000..3f7e2691c29 --- /dev/null +++ b/plugins/chat/app/controllers/chat/base_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Chat + class BaseController < ::ApplicationController + before_action :ensure_logged_in + before_action :ensure_can_chat + + private + + def ensure_can_chat + raise Discourse::NotFound unless SiteSetting.chat_enabled + guardian.ensure_can_chat! + end + + def set_channel_and_chatable_with_access_check(chat_channel_id: nil) + params.require(:chat_channel_id) if chat_channel_id.blank? + id_or_name = chat_channel_id || params[:chat_channel_id] + @chat_channel = Chat::ChannelFetcher.find_with_access_check(id_or_name, guardian) + @chatable = @chat_channel.chatable + end + end +end diff --git a/plugins/chat/app/controllers/chat/chat_controller.rb b/plugins/chat/app/controllers/chat/chat_controller.rb new file mode 100644 index 00000000000..fcb918f1e43 --- /dev/null +++ b/plugins/chat/app/controllers/chat/chat_controller.rb @@ -0,0 +1,481 @@ +# frozen_string_literal: true + +module Chat + class ChatController < ::Chat::BaseController + PAST_MESSAGE_LIMIT = 40 + FUTURE_MESSAGE_LIMIT = 40 + PAST = "past" + FUTURE = "future" + CHAT_DIRECTIONS = [PAST, FUTURE] + + # Other endpoints use set_channel_and_chatable_with_access_check, but + # these endpoints require a standalone find because they need to be + # able to get deleted channels and recover them. + before_action :find_chatable, only: %i[enable_chat disable_chat] + before_action :find_chat_message, + only: %i[delete restore lookup_message edit_message rebake message_link] + before_action :set_channel_and_chatable_with_access_check, + except: %i[ + respond + enable_chat + disable_chat + message_link + lookup_message + set_user_chat_status + dismiss_retention_reminder + flag + ] + + def respond + render + end + + def enable_chat + chat_channel = Chat::Channel.with_deleted.find_by(chatable_id: @chatable) + + guardian.ensure_can_join_chat_channel!(chat_channel) if chat_channel + + if chat_channel && chat_channel.trashed? + chat_channel.recover! + elsif chat_channel + return render_json_error I18n.t("chat.already_enabled") + else + chat_channel = @chatable.chat_channel + guardian.ensure_can_join_chat_channel!(chat_channel) + end + + success = chat_channel.save + if success && chat_channel.chatable_has_custom_fields? + @chatable.custom_fields[Chat::HAS_CHAT_ENABLED] = true + @chatable.save! + end + + if success + membership = Chat::ChannelMembershipManager.new(channel).follow(user) + render_serialized(chat_channel, Chat::ChannelSerializer, membership: membership) + else + render_json_error(chat_channel) + end + + Chat::ChannelMembershipManager.new(channel).follow(user) + end + + def disable_chat + chat_channel = Chat::Channel.with_deleted.find_by(chatable_id: @chatable) + guardian.ensure_can_join_chat_channel!(chat_channel) + return render json: success_json if chat_channel.trashed? + chat_channel.trash!(current_user) + + success = chat_channel.save + if success + if chat_channel.chatable_has_custom_fields? + @chatable.custom_fields.delete(Chat::HAS_CHAT_ENABLED) + @chatable.save! + end + + render json: success_json + else + render_json_error(chat_channel) + end + end + + def create_message + raise Discourse::InvalidAccess if current_user.silenced? + + Chat::MessageRateLimiter.run!(current_user) + + @user_chat_channel_membership = + Chat::ChannelMembershipManager.new(@chat_channel).find_for_user( + current_user, + following: true, + ) + raise Discourse::InvalidAccess unless @user_chat_channel_membership + + reply_to_msg_id = params[:in_reply_to_id] + if reply_to_msg_id + rm = Chat::Message.find(reply_to_msg_id) + raise Discourse::NotFound if rm.chat_channel_id != @chat_channel.id + end + + content = params[:message] + + chat_message_creator = + Chat::MessageCreator.create( + chat_channel: @chat_channel, + user: current_user, + in_reply_to_id: reply_to_msg_id, + content: content, + staged_id: params[:staged_id], + upload_ids: params[:upload_ids], + ) + + return render_json_error(chat_message_creator.error) if chat_message_creator.failed? + + @user_chat_channel_membership.update!( + last_read_message_id: chat_message_creator.chat_message.id, + ) + + if @chat_channel.direct_message_channel? + # If any of the channel users is ignoring, muting, or preventing DMs from + # the current user then we shold not auto-follow the channel once again or + # publish the new channel. + user_ids_allowing_communication = + UserCommScreener.new( + acting_user: current_user, + target_user_ids: @chat_channel.user_chat_channel_memberships.pluck(:user_id), + ).allowing_actor_communication + + if user_ids_allowing_communication.any? + Chat::Publisher.publish_new_channel( + @chat_channel, + @chat_channel.chatable.users.where(id: user_ids_allowing_communication), + ) + + @chat_channel + .user_chat_channel_memberships + .where(user_id: user_ids_allowing_communication) + .update_all(following: true) + end + end + + Chat::Publisher.publish_user_tracking_state( + current_user, + @chat_channel.id, + chat_message_creator.chat_message.id, + ) + render json: success_json + end + + def edit_message + chat_message_updater = + Chat::MessageUpdater.update( + guardian: guardian, + chat_message: @message, + new_content: params[:new_message], + upload_ids: params[:upload_ids] || [], + ) + + return render_json_error(chat_message_updater.error) if chat_message_updater.failed? + + render json: success_json + end + + def update_user_last_read + membership = + Chat::ChannelMembershipManager.new(@chat_channel).find_for_user( + current_user, + following: true, + ) + raise Discourse::NotFound if membership.nil? + + if membership.last_read_message_id && + params[:message_id].to_i < membership.last_read_message_id + raise Discourse::InvalidParameters.new(:message_id) + end + + unless Chat::Message.with_deleted.exists?( + chat_channel_id: @chat_channel.id, + id: params[:message_id], + ) + raise Discourse::NotFound + end + + membership.update!(last_read_message_id: params[:message_id]) + + Notification + .where(notification_type: Notification.types[:chat_mention]) + .where(user: current_user) + .where(read: false) + .joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id") + .joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id") + .where("chat_messages.id <= ?", params[:message_id].to_i) + .where("chat_messages.chat_channel_id = ?", @chat_channel.id) + .update_all(read: true) + + Chat::Publisher.publish_user_tracking_state( + current_user, + @chat_channel.id, + params[:message_id], + ) + + render json: success_json + end + + def messages + page_size = params[:page_size]&.to_i || 1000 + direction = params[:direction].to_s + message_id = params[:message_id] + if page_size > 50 || + ( + message_id.blank? ^ direction.blank? && + (direction.present? && !CHAT_DIRECTIONS.include?(direction)) + ) + raise Discourse::InvalidParameters + end + + messages = preloaded_chat_message_query.where(chat_channel: @chat_channel) + messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable) + + if message_id.present? + condition = direction == PAST ? "<" : ">" + messages = messages.where("id #{condition} ?", message_id.to_i) + end + + # NOTE: This order is reversed when we return the Chat::View below if the direction + # is not FUTURE. + order = direction == FUTURE ? "ASC" : "DESC" + messages = messages.order("created_at #{order}, id #{order}").limit(page_size).to_a + + can_load_more_past = nil + can_load_more_future = nil + + if direction == FUTURE + can_load_more_future = messages.size == page_size + elsif direction == PAST + can_load_more_past = messages.size == page_size + else + # When direction is blank, we'll return the latest messages. + can_load_more_future = false + can_load_more_past = messages.size == page_size + end + + chat_view = + Chat::View.new( + chat_channel: @chat_channel, + chat_messages: direction == FUTURE ? messages : messages.reverse, + user: current_user, + can_load_more_past: can_load_more_past, + can_load_more_future: can_load_more_future, + ) + render_serialized(chat_view, Chat::ViewSerializer, root: false) + end + + def react + params.require(%i[message_id emoji react_action]) + guardian.ensure_can_react! + + Chat::MessageReactor.new(current_user, @chat_channel).react!( + message_id: params[:message_id], + react_action: params[:react_action].to_sym, + emoji: params[:emoji], + ) + + render json: success_json + end + + def delete + guardian.ensure_can_delete_chat!(@message, @chatable) + + Chat::MessageDestroyer.new.trash_message(@message, current_user) + + head :ok + end + + def restore + chat_channel = @message.chat_channel + guardian.ensure_can_restore_chat!(@message, chat_channel.chatable) + updated = @message.recover! + if updated + Chat::Publisher.publish_restore!(chat_channel, @message) + render json: success_json + else + render_json_error(@message) + end + end + + def rebake + guardian.ensure_can_rebake_chat_message!(@message) + @message.rebake!(invalidate_oneboxes: true) + render json: success_json + end + + def message_link + raise Discourse::NotFound if @message.blank? || @message.deleted_at.present? + raise Discourse::NotFound if @message.chat_channel.blank? + set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id) + render json: + success_json.merge( + chat_channel_id: @chat_channel.id, + chat_channel_title: @chat_channel.title(current_user), + ) + end + + def lookup_message + set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id) + + messages = preloaded_chat_message_query.where(chat_channel: @chat_channel) + messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable) + + past_messages = + messages + .where("created_at < ?", @message.created_at) + .order(created_at: :desc) + .limit(PAST_MESSAGE_LIMIT) + + future_messages = + messages + .where("created_at > ?", @message.created_at) + .order(created_at: :asc) + .limit(FUTURE_MESSAGE_LIMIT) + + can_load_more_past = past_messages.count == PAST_MESSAGE_LIMIT + can_load_more_future = future_messages.count == FUTURE_MESSAGE_LIMIT + messages = [past_messages.reverse, [@message], future_messages].reduce([], :concat) + chat_view = + Chat::View.new( + chat_channel: @chat_channel, + chat_messages: messages, + user: current_user, + can_load_more_past: can_load_more_past, + can_load_more_future: can_load_more_future, + ) + render_serialized(chat_view, Chat::ViewSerializer, root: false) + end + + def set_user_chat_status + params.require(:chat_enabled) + + current_user.user_option.update(chat_enabled: params[:chat_enabled]) + render json: { chat_enabled: current_user.user_option.chat_enabled } + end + + def invite_users + params.require(:user_ids) + + users = + User + .includes(:groups) + .joins(:user_option) + .where(user_options: { chat_enabled: true }) + .not_suspended + .where(id: params[:user_ids]) + users.each do |user| + guardian = Guardian.new(user) + if guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel) + data = { + message: "chat.invitation_notification", + chat_channel_id: @chat_channel.id, + chat_channel_title: @chat_channel.title(user), + chat_channel_slug: @chat_channel.slug, + invited_by_username: current_user.username, + } + data[:chat_message_id] = params[:chat_message_id] if params[:chat_message_id] + user.notifications.create( + notification_type: Notification.types[:chat_invitation], + high_priority: true, + data: data.to_json, + ) + end + end + + render json: success_json + end + + def dismiss_retention_reminder + params.require(:chatable_type) + guardian.ensure_can_chat! + unless Chat::Channel.chatable_types.include?(params[:chatable_type]) + raise Discourse::InvalidParameters + end + + field = + ( + if Chat::Channel.public_channel_chatable_types.include?(params[:chatable_type]) + :dismissed_channel_retention_reminder + else + :dismissed_dm_retention_reminder + end + ) + current_user.user_option.update(field => true) + render json: success_json + end + + def quote_messages + params.require(:message_ids) + + message_ids = params[:message_ids].map(&:to_i) + markdown = + Chat::TranscriptService.new( + @chat_channel, + current_user, + messages_or_ids: message_ids, + ).generate_markdown + render json: success_json.merge(markdown: markdown) + end + + def flag + RateLimiter.new(current_user, "flag_chat_message", 4, 1.minutes).performed! + + permitted_params = + params.permit( + %i[chat_message_id flag_type_id message is_warning take_action queue_for_review], + ) + + chat_message = + Chat::Message.includes(:chat_channel, :revisions).find(permitted_params[:chat_message_id]) + + flag_type_id = permitted_params[:flag_type_id].to_i + + if !ReviewableScore.types.values.include?(flag_type_id) + raise Discourse::InvalidParameters.new(:flag_type_id) + end + + set_channel_and_chatable_with_access_check(chat_channel_id: chat_message.chat_channel_id) + + result = + Chat::ReviewQueue.new.flag_message(chat_message, guardian, flag_type_id, permitted_params) + + if result[:success] + render json: success_json + else + render_json_error(result[:errors]) + end + end + + def set_draft + if params[:data].present? + Chat::Draft.find_or_initialize_by( + user: current_user, + chat_channel_id: @chat_channel.id, + ).update!(data: params[:data]) + else + Chat::Draft.where(user: current_user, chat_channel_id: @chat_channel.id).destroy_all + end + + render json: success_json + end + + private + + def preloaded_chat_message_query + query = + Chat::Message + .includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]]) + .includes(:revisions) + .includes(user: :primary_group) + .includes(chat_webhook_event: :incoming_chat_webhook) + .includes(reactions: :user) + .includes(:bookmarks) + .includes(:uploads) + .includes(chat_channel: :chatable) + + query = query.includes(user: :user_status) if SiteSetting.enable_user_status + + query + end + + def find_chatable + @chatable = Category.find_by(id: params[:chatable_id]) + guardian.ensure_can_moderate_chat!(@chatable) + end + + def find_chat_message + @message = preloaded_chat_message_query.with_deleted + @message = @message.where(chat_channel_id: params[:chat_channel_id]) if params[ + :chat_channel_id + ] + @message = @message.find_by(id: params[:message_id]) + raise Discourse::NotFound unless @message + end + end +end diff --git a/plugins/chat/app/controllers/chat/direct_messages_controller.rb b/plugins/chat/app/controllers/chat/direct_messages_controller.rb new file mode 100644 index 00000000000..cb7d6981005 --- /dev/null +++ b/plugins/chat/app/controllers/chat/direct_messages_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Chat + class DirectMessagesController < ::Chat::BaseController + # NOTE: For V1 of chat channel archiving and deleting we are not doing + # anything for DM channels, their behaviour will stay as is. + def create + guardian.ensure_can_chat! + users = users_from_usernames(current_user, params) + + begin + chat_channel = + Chat::DirectMessageChannelCreator.create!(acting_user: current_user, target_users: users) + render_serialized( + chat_channel, + Chat::ChannelSerializer, + root: "channel", + membership: chat_channel.membership_for(current_user), + ) + rescue Chat::DirectMessageChannelCreator::NotAllowed => err + render_json_error(err.message) + end + end + + def index + guardian.ensure_can_chat! + users = users_from_usernames(current_user, params) + + direct_message = Chat::DirectMessage.for_user_ids(users.map(&:id).uniq) + if direct_message + chat_channel = Chat::Channel.find_by(chatable_id: direct_message) + render_serialized( + chat_channel, + Chat::ChannelSerializer, + root: "channel", + membership: chat_channel.membership_for(current_user), + ) + else + render body: nil, status: 404 + end + end + + private + + def users_from_usernames(current_user, params) + params.require(:usernames) + + usernames = + (params[:usernames].is_a?(String) ? params[:usernames].split(",") : params[:usernames]) + + users = [current_user] + other_usernames = usernames - [current_user.username] + users.concat(User.where(username: other_usernames).to_a) if other_usernames.any? + users + end + end +end diff --git a/plugins/chat/app/controllers/chat/emojis_controller.rb b/plugins/chat/app/controllers/chat/emojis_controller.rb new file mode 100644 index 00000000000..6d70cc4af96 --- /dev/null +++ b/plugins/chat/app/controllers/chat/emojis_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Chat + class EmojisController < ::Chat::BaseController + def index + emojis = Emoji.all.group_by(&:group) + render json: MultiJson.dump(emojis) + end + end +end diff --git a/plugins/chat/app/controllers/chat/incoming_webhooks_controller.rb b/plugins/chat/app/controllers/chat/incoming_webhooks_controller.rb new file mode 100644 index 00000000000..a9f57bc7062 --- /dev/null +++ b/plugins/chat/app/controllers/chat/incoming_webhooks_controller.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Chat + class IncomingWebhooksController < ::ApplicationController + WEBHOOK_MESSAGES_PER_MINUTE_LIMIT = 10 + + skip_before_action :verify_authenticity_token, :redirect_to_login_if_required + + before_action :validate_payload + + def create_message + debug_payload + + process_webhook_payload(text: params[:text], key: params[:key]) + end + + # See https://api.slack.com/reference/messaging/payload for the + # slack message payload format. For now we only support the + # text param, which we preprocess lightly to remove the slack-isms + # in the formatting. + def create_message_slack_compatible + debug_payload + + # See note in validate_payload on why this is needed + attachments = + if params[:payload].present? + payload = params[:payload] + if String === payload + payload = JSON.parse(payload) + payload.deep_symbolize_keys! + end + payload[:attachments] + else + params[:attachments] + end + + if params[:text].present? + text = Chat::SlackCompatibility.process_text(params[:text]) + else + text = Chat::SlackCompatibility.process_legacy_attachments(attachments) + end + + process_webhook_payload(text: text, key: params[:key]) + rescue JSON::ParserError + raise Discourse::InvalidParameters + end + + private + + def process_webhook_payload(text:, key:) + validate_message_length(text) + webhook = find_and_rate_limit_webhook(key) + + chat_message_creator = + Chat::MessageCreator.create( + chat_channel: webhook.chat_channel, + user: Discourse.system_user, + content: text, + incoming_chat_webhook: webhook, + ) + if chat_message_creator.failed? + render_json_error(chat_message_creator.error) + else + render json: success_json + end + end + + def find_and_rate_limit_webhook(key) + webhook = Chat::IncomingWebhook.includes(:chat_channel).find_by(key: key) + raise Discourse::NotFound unless webhook + + # Rate limit to 10 messages per-minute. We can move to a site setting in the future if needed. + RateLimiter.new( + nil, + "incoming_chat_webhook_#{webhook.id}", + WEBHOOK_MESSAGES_PER_MINUTE_LIMIT, + 1.minute, + ).performed! + webhook + end + + def validate_message_length(message) + return if message.length <= SiteSetting.chat_maximum_message_length + raise Discourse::InvalidParameters.new( + "Body cannot be over #{SiteSetting.chat_maximum_message_length} characters", + ) + end + + # The webhook POST body can be in 3 different formats: + # + # * { text: "message text" }, which is the most basic method, and also mirrors Slack payloads + # * { attachments: [ text: "message text" ] }, which is a variant of Slack payloads using legacy attachments + # * { payload: "", attachments: null, text: null }, where JSON STRING can look + # like the `attachments` example above (along with other attributes), which is fired by OpsGenie + def validate_payload + params.require(:key) + + if !params[:text] && !params[:payload] && !params[:attachments] + raise Discourse::InvalidParameters + end + end + + def debug_payload + return if !SiteSetting.chat_debug_webhook_payloads + Rails.logger.warn( + "Debugging chat webhook payload for endpoint #{params[:key]}: " + + JSON.dump( + { payload: params[:payload], attachments: params[:attachments], text: params[:text] }, + ), + ) + end + end +end diff --git a/plugins/chat/app/controllers/chat_base_controller.rb b/plugins/chat/app/controllers/chat_base_controller.rb deleted file mode 100644 index 6e014502ddb..00000000000 --- a/plugins/chat/app/controllers/chat_base_controller.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatBaseController < ::ApplicationController - before_action :ensure_logged_in - before_action :ensure_can_chat - - private - - def ensure_can_chat - raise Discourse::NotFound unless SiteSetting.chat_enabled - guardian.ensure_can_chat! - end - - def set_channel_and_chatable_with_access_check(chat_channel_id: nil) - params.require(:chat_channel_id) if chat_channel_id.blank? - id_or_name = chat_channel_id || params[:chat_channel_id] - @chat_channel = Chat::ChatChannelFetcher.find_with_access_check(id_or_name, guardian) - @chatable = @chat_channel.chatable - end -end diff --git a/plugins/chat/app/controllers/chat_controller.rb b/plugins/chat/app/controllers/chat_controller.rb deleted file mode 100644 index 6b6f8f57a6b..00000000000 --- a/plugins/chat/app/controllers/chat_controller.rb +++ /dev/null @@ -1,472 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatController < Chat::ChatBaseController - PAST_MESSAGE_LIMIT = 40 - FUTURE_MESSAGE_LIMIT = 40 - PAST = "past" - FUTURE = "future" - CHAT_DIRECTIONS = [PAST, FUTURE] - - # Other endpoints use set_channel_and_chatable_with_access_check, but - # these endpoints require a standalone find because they need to be - # able to get deleted channels and recover them. - before_action :find_chatable, only: %i[enable_chat disable_chat] - before_action :find_chat_message, - only: %i[delete restore lookup_message edit_message rebake message_link] - before_action :set_channel_and_chatable_with_access_check, - except: %i[ - respond - enable_chat - disable_chat - message_link - lookup_message - set_user_chat_status - dismiss_retention_reminder - flag - ] - - def respond - render - end - - def enable_chat - chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable) - - guardian.ensure_can_join_chat_channel!(chat_channel) if chat_channel - - if chat_channel && chat_channel.trashed? - chat_channel.recover! - elsif chat_channel - return render_json_error I18n.t("chat.already_enabled") - else - chat_channel = @chatable.chat_channel - guardian.ensure_can_join_chat_channel!(chat_channel) - end - - success = chat_channel.save - if success && chat_channel.chatable_has_custom_fields? - @chatable.custom_fields[Chat::HAS_CHAT_ENABLED] = true - @chatable.save! - end - - if success - membership = Chat::ChatChannelMembershipManager.new(channel).follow(user) - render_serialized(chat_channel, ChatChannelSerializer, membership: membership) - else - render_json_error(chat_channel) - end - - Chat::ChatChannelMembershipManager.new(channel).follow(user) - end - - def disable_chat - chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable) - guardian.ensure_can_join_chat_channel!(chat_channel) - return render json: success_json if chat_channel.trashed? - chat_channel.trash!(current_user) - - success = chat_channel.save - if success - if chat_channel.chatable_has_custom_fields? - @chatable.custom_fields.delete(Chat::HAS_CHAT_ENABLED) - @chatable.save! - end - - render json: success_json - else - render_json_error(chat_channel) - end - end - - def create_message - raise Discourse::InvalidAccess if current_user.silenced? - - Chat::ChatMessageRateLimiter.run!(current_user) - - @user_chat_channel_membership = - Chat::ChatChannelMembershipManager.new(@chat_channel).find_for_user( - current_user, - following: true, - ) - raise Discourse::InvalidAccess unless @user_chat_channel_membership - - reply_to_msg_id = params[:in_reply_to_id] - if reply_to_msg_id - rm = ChatMessage.find(reply_to_msg_id) - raise Discourse::NotFound if rm.chat_channel_id != @chat_channel.id - end - - content = params[:message] - - chat_message_creator = - Chat::ChatMessageCreator.create( - chat_channel: @chat_channel, - user: current_user, - in_reply_to_id: reply_to_msg_id, - content: content, - staged_id: params[:staged_id], - upload_ids: params[:upload_ids], - ) - - return render_json_error(chat_message_creator.error) if chat_message_creator.failed? - - @user_chat_channel_membership.update!( - last_read_message_id: chat_message_creator.chat_message.id, - ) - - if @chat_channel.direct_message_channel? - # If any of the channel users is ignoring, muting, or preventing DMs from - # the current user then we shold not auto-follow the channel once again or - # publish the new channel. - user_ids_allowing_communication = - UserCommScreener.new( - acting_user: current_user, - target_user_ids: @chat_channel.user_chat_channel_memberships.pluck(:user_id), - ).allowing_actor_communication - - if user_ids_allowing_communication.any? - ChatPublisher.publish_new_channel( - @chat_channel, - @chat_channel.chatable.users.where(id: user_ids_allowing_communication), - ) - - @chat_channel - .user_chat_channel_memberships - .where(user_id: user_ids_allowing_communication) - .update_all(following: true) - end - end - - ChatPublisher.publish_user_tracking_state( - current_user, - @chat_channel.id, - chat_message_creator.chat_message.id, - ) - render json: success_json - end - - def edit_message - chat_message_updater = - Chat::ChatMessageUpdater.update( - guardian: guardian, - chat_message: @message, - new_content: params[:new_message], - upload_ids: params[:upload_ids] || [], - ) - - return render_json_error(chat_message_updater.error) if chat_message_updater.failed? - - render json: success_json - end - - def update_user_last_read - membership = - Chat::ChatChannelMembershipManager.new(@chat_channel).find_for_user( - current_user, - following: true, - ) - raise Discourse::NotFound if membership.nil? - - if membership.last_read_message_id && params[:message_id].to_i < membership.last_read_message_id - raise Discourse::InvalidParameters.new(:message_id) - end - - unless ChatMessage.with_deleted.exists?( - chat_channel_id: @chat_channel.id, - id: params[:message_id], - ) - raise Discourse::NotFound - end - - membership.update!(last_read_message_id: params[:message_id]) - - Notification - .where(notification_type: Notification.types[:chat_mention]) - .where(user: current_user) - .where(read: false) - .joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id") - .joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id") - .where("chat_messages.id <= ?", params[:message_id].to_i) - .where("chat_messages.chat_channel_id = ?", @chat_channel.id) - .update_all(read: true) - - ChatPublisher.publish_user_tracking_state(current_user, @chat_channel.id, params[:message_id]) - - render json: success_json - end - - def messages - page_size = params[:page_size]&.to_i || 1000 - direction = params[:direction].to_s - message_id = params[:message_id] - if page_size > 50 || - ( - message_id.blank? ^ direction.blank? && - (direction.present? && !CHAT_DIRECTIONS.include?(direction)) - ) - raise Discourse::InvalidParameters - end - - messages = preloaded_chat_message_query.where(chat_channel: @chat_channel) - messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable) - - if message_id.present? - condition = direction == PAST ? "<" : ">" - messages = messages.where("id #{condition} ?", message_id.to_i) - end - - # NOTE: This order is reversed when we return the ChatView below if the direction - # is not FUTURE. - order = direction == FUTURE ? "ASC" : "DESC" - messages = messages.order("created_at #{order}, id #{order}").limit(page_size).to_a - - can_load_more_past = nil - can_load_more_future = nil - - if direction == FUTURE - can_load_more_future = messages.size == page_size - elsif direction == PAST - can_load_more_past = messages.size == page_size - else - # When direction is blank, we'll return the latest messages. - can_load_more_future = false - can_load_more_past = messages.size == page_size - end - - chat_view = - ChatView.new( - chat_channel: @chat_channel, - chat_messages: direction == FUTURE ? messages : messages.reverse, - user: current_user, - can_load_more_past: can_load_more_past, - can_load_more_future: can_load_more_future, - ) - render_serialized(chat_view, ChatViewSerializer, root: false) - end - - def react - params.require(%i[message_id emoji react_action]) - guardian.ensure_can_react! - - Chat::ChatMessageReactor.new(current_user, @chat_channel).react!( - message_id: params[:message_id], - react_action: params[:react_action].to_sym, - emoji: params[:emoji], - ) - - render json: success_json - end - - def delete - guardian.ensure_can_delete_chat!(@message, @chatable) - - ChatMessageDestroyer.new.trash_message(@message, current_user) - - head :ok - end - - def restore - chat_channel = @message.chat_channel - guardian.ensure_can_restore_chat!(@message, chat_channel.chatable) - updated = @message.recover! - if updated - ChatPublisher.publish_restore!(chat_channel, @message) - render json: success_json - else - render_json_error(@message) - end - end - - def rebake - guardian.ensure_can_rebake_chat_message!(@message) - @message.rebake!(invalidate_oneboxes: true) - render json: success_json - end - - def message_link - raise Discourse::NotFound if @message.blank? || @message.deleted_at.present? - raise Discourse::NotFound if @message.chat_channel.blank? - set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id) - render json: - success_json.merge( - chat_channel_id: @chat_channel.id, - chat_channel_title: @chat_channel.title(current_user), - ) - end - - def lookup_message - set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id) - - messages = preloaded_chat_message_query.where(chat_channel: @chat_channel) - messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable) - - past_messages = - messages - .where("created_at < ?", @message.created_at) - .order(created_at: :desc) - .limit(PAST_MESSAGE_LIMIT) - - future_messages = - messages - .where("created_at > ?", @message.created_at) - .order(created_at: :asc) - .limit(FUTURE_MESSAGE_LIMIT) - - can_load_more_past = past_messages.count == PAST_MESSAGE_LIMIT - can_load_more_future = future_messages.count == FUTURE_MESSAGE_LIMIT - messages = [past_messages.reverse, [@message], future_messages].reduce([], :concat) - chat_view = - ChatView.new( - chat_channel: @chat_channel, - chat_messages: messages, - user: current_user, - can_load_more_past: can_load_more_past, - can_load_more_future: can_load_more_future, - ) - render_serialized(chat_view, ChatViewSerializer, root: false) - end - - def set_user_chat_status - params.require(:chat_enabled) - - current_user.user_option.update(chat_enabled: params[:chat_enabled]) - render json: { chat_enabled: current_user.user_option.chat_enabled } - end - - def invite_users - params.require(:user_ids) - - users = - User - .includes(:groups) - .joins(:user_option) - .where(user_options: { chat_enabled: true }) - .not_suspended - .where(id: params[:user_ids]) - users.each do |user| - guardian = Guardian.new(user) - if guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel) - data = { - message: "chat.invitation_notification", - chat_channel_id: @chat_channel.id, - chat_channel_title: @chat_channel.title(user), - chat_channel_slug: @chat_channel.slug, - invited_by_username: current_user.username, - } - data[:chat_message_id] = params[:chat_message_id] if params[:chat_message_id] - user.notifications.create( - notification_type: Notification.types[:chat_invitation], - high_priority: true, - data: data.to_json, - ) - end - end - - render json: success_json - end - - def dismiss_retention_reminder - params.require(:chatable_type) - guardian.ensure_can_chat! - unless ChatChannel.chatable_types.include?(params[:chatable_type]) - raise Discourse::InvalidParameters - end - - field = - ( - if ChatChannel.public_channel_chatable_types.include?(params[:chatable_type]) - :dismissed_channel_retention_reminder - else - :dismissed_dm_retention_reminder - end - ) - current_user.user_option.update(field => true) - render json: success_json - end - - def quote_messages - params.require(:message_ids) - - message_ids = params[:message_ids].map(&:to_i) - markdown = - ChatTranscriptService.new( - @chat_channel, - current_user, - messages_or_ids: message_ids, - ).generate_markdown - render json: success_json.merge(markdown: markdown) - end - - def flag - RateLimiter.new(current_user, "flag_chat_message", 4, 1.minutes).performed! - - permitted_params = - params.permit( - %i[chat_message_id flag_type_id message is_warning take_action queue_for_review], - ) - - chat_message = - ChatMessage.includes(:chat_channel, :revisions).find(permitted_params[:chat_message_id]) - - flag_type_id = permitted_params[:flag_type_id].to_i - - if !ReviewableScore.types.values.include?(flag_type_id) - raise Discourse::InvalidParameters.new(:flag_type_id) - end - - set_channel_and_chatable_with_access_check(chat_channel_id: chat_message.chat_channel_id) - - result = - Chat::ChatReviewQueue.new.flag_message(chat_message, guardian, flag_type_id, permitted_params) - - if result[:success] - render json: success_json - else - render_json_error(result[:errors]) - end - end - - def set_draft - if params[:data].present? - ChatDraft.find_or_initialize_by( - user: current_user, - chat_channel_id: @chat_channel.id, - ).update!(data: params[:data]) - else - ChatDraft.where(user: current_user, chat_channel_id: @chat_channel.id).destroy_all - end - - render json: success_json - end - - private - - def preloaded_chat_message_query - query = - ChatMessage - .includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]]) - .includes(:revisions) - .includes(user: :primary_group) - .includes(chat_webhook_event: :incoming_chat_webhook) - .includes(reactions: :user) - .includes(:bookmarks) - .includes(:uploads) - .includes(chat_channel: :chatable) - - query = query.includes(user: :user_status) if SiteSetting.enable_user_status - - query - end - - def find_chatable - @chatable = Category.find_by(id: params[:chatable_id]) - guardian.ensure_can_moderate_chat!(@chatable) - end - - def find_chat_message - @message = preloaded_chat_message_query.with_deleted - @message = @message.where(chat_channel_id: params[:chat_channel_id]) if params[:chat_channel_id] - @message = @message.find_by(id: params[:message_id]) - raise Discourse::NotFound unless @message - end -end diff --git a/plugins/chat/app/controllers/direct_messages_controller.rb b/plugins/chat/app/controllers/direct_messages_controller.rb deleted file mode 100644 index b0100a95a89..00000000000 --- a/plugins/chat/app/controllers/direct_messages_controller.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -class Chat::DirectMessagesController < Chat::ChatBaseController - # NOTE: For V1 of chat channel archiving and deleting we are not doing - # anything for DM channels, their behaviour will stay as is. - def create - guardian.ensure_can_chat! - users = users_from_usernames(current_user, params) - - begin - chat_channel = - Chat::DirectMessageChannelCreator.create!(acting_user: current_user, target_users: users) - render_serialized( - chat_channel, - ChatChannelSerializer, - root: "channel", - membership: chat_channel.membership_for(current_user), - ) - rescue Chat::DirectMessageChannelCreator::NotAllowed => err - render_json_error(err.message) - end - end - - def index - guardian.ensure_can_chat! - users = users_from_usernames(current_user, params) - - direct_message = DirectMessage.for_user_ids(users.map(&:id).uniq) - if direct_message - chat_channel = ChatChannel.find_by(chatable: direct_message) - render_serialized( - chat_channel, - ChatChannelSerializer, - root: "channel", - membership: chat_channel.membership_for(current_user), - ) - else - render body: nil, status: 404 - end - end - - private - - def users_from_usernames(current_user, params) - params.require(:usernames) - - usernames = - (params[:usernames].is_a?(String) ? params[:usernames].split(",") : params[:usernames]) - - users = [current_user] - other_usernames = usernames - [current_user.username] - users.concat(User.where(username: other_usernames).to_a) if other_usernames.any? - users - end -end diff --git a/plugins/chat/app/controllers/emojis_controller.rb b/plugins/chat/app/controllers/emojis_controller.rb deleted file mode 100644 index 8d895e2bd70..00000000000 --- a/plugins/chat/app/controllers/emojis_controller.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class Chat::EmojisController < Chat::ChatBaseController - def index - emojis = Emoji.all.group_by(&:group) - render json: MultiJson.dump(emojis) - end -end diff --git a/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb b/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb deleted file mode 100644 index 58d730cbc65..00000000000 --- a/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true - -class Chat::IncomingChatWebhooksController < ApplicationController - WEBHOOK_MESSAGES_PER_MINUTE_LIMIT = 10 - - skip_before_action :verify_authenticity_token, :redirect_to_login_if_required - - before_action :validate_payload - - def create_message - debug_payload - - process_webhook_payload(text: params[:text], key: params[:key]) - end - - # See https://api.slack.com/reference/messaging/payload for the - # slack message payload format. For now we only support the - # text param, which we preprocess lightly to remove the slack-isms - # in the formatting. - def create_message_slack_compatible - debug_payload - - # See note in validate_payload on why this is needed - attachments = - if params[:payload].present? - payload = params[:payload] - if String === payload - payload = JSON.parse(payload) - payload.deep_symbolize_keys! - end - payload[:attachments] - else - params[:attachments] - end - - if params[:text].present? - text = Chat::SlackCompatibility.process_text(params[:text]) - else - text = Chat::SlackCompatibility.process_legacy_attachments(attachments) - end - - process_webhook_payload(text: text, key: params[:key]) - rescue JSON::ParserError - raise Discourse::InvalidParameters - end - - private - - def process_webhook_payload(text:, key:) - validate_message_length(text) - webhook = find_and_rate_limit_webhook(key) - - chat_message_creator = - Chat::ChatMessageCreator.create( - chat_channel: webhook.chat_channel, - user: Discourse.system_user, - content: text, - incoming_chat_webhook: webhook, - ) - if chat_message_creator.failed? - render_json_error(chat_message_creator.error) - else - render json: success_json - end - end - - def find_and_rate_limit_webhook(key) - webhook = IncomingChatWebhook.includes(:chat_channel).find_by(key: key) - raise Discourse::NotFound unless webhook - - # Rate limit to 10 messages per-minute. We can move to a site setting in the future if needed. - RateLimiter.new( - nil, - "incoming_chat_webhook_#{webhook.id}", - WEBHOOK_MESSAGES_PER_MINUTE_LIMIT, - 1.minute, - ).performed! - webhook - end - - def validate_message_length(message) - return if message.length <= SiteSetting.chat_maximum_message_length - raise Discourse::InvalidParameters.new( - "Body cannot be over #{SiteSetting.chat_maximum_message_length} characters", - ) - end - - # The webhook POST body can be in 3 different formats: - # - # * { text: "message text" }, which is the most basic method, and also mirrors Slack payloads - # * { attachments: [ text: "message text" ] }, which is a variant of Slack payloads using legacy attachments - # * { payload: "", attachments: null, text: null }, where JSON STRING can look - # like the `attachments` example above (along with other attributes), which is fired by OpsGenie - def validate_payload - params.require(:key) - - if !params[:text] && !params[:payload] && !params[:attachments] - raise Discourse::InvalidParameters - end - end - - def debug_payload - return if !SiteSetting.chat_debug_webhook_payloads - Rails.logger.warn( - "Debugging chat webhook payload for endpoint #{params[:key]}: " + - JSON.dump( - { payload: params[:payload], attachments: params[:attachments], text: params[:text] }, - ), - ) - end -end diff --git a/plugins/chat/app/core_ext/plugin_instance.rb b/plugins/chat/app/core_ext/plugin_instance.rb deleted file mode 100644 index 9e38199f2ed..00000000000 --- a/plugins/chat/app/core_ext/plugin_instance.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -DiscoursePluginRegistry.define_register(:chat_markdown_features, Set) - -class Plugin::Instance - def chat - ChatPluginApiExtensions - end - - module ChatPluginApiExtensions - def self.enable_markdown_feature(name) - DiscoursePluginRegistry.chat_markdown_features << name - end - end -end diff --git a/plugins/chat/app/helpers/with_service_helper.rb b/plugins/chat/app/helpers/chat/with_service_helper.rb similarity index 88% rename from plugins/chat/app/helpers/with_service_helper.rb rename to plugins/chat/app/helpers/chat/with_service_helper.rb index 78b4d923ba7..c8e820cc2c3 100644 --- a/plugins/chat/app/helpers/with_service_helper.rb +++ b/plugins/chat/app/helpers/chat/with_service_helper.rb @@ -12,7 +12,7 @@ module Chat instance_exec(&object.method(:default_actions_for_service).call) if default_actions instance_exec(&(block || proc {})) end - Chat::ServiceRunner.call(service, object, **dependencies, &merged_block) + ServiceRunner.call(service, object, **dependencies, &merged_block) end def run_service(service, dependencies) diff --git a/plugins/chat/app/jobs/regular/auto_join_channel_batch.rb b/plugins/chat/app/jobs/regular/auto_join_channel_batch.rb deleted file mode 100644 index 16d01e96a94..00000000000 --- a/plugins/chat/app/jobs/regular/auto_join_channel_batch.rb +++ /dev/null @@ -1,81 +0,0 @@ -# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well. -# frozen_string_literal: true - -module Jobs - class AutoJoinChannelBatch < ::Jobs::Base - def execute(args) - return "starts_at or ends_at missing" if args[:starts_at].blank? || args[:ends_at].blank? - start_user_id = args[:starts_at].to_i - end_user_id = args[:ends_at].to_i - - return "End is higher than start" if end_user_id < start_user_id - - channel = - ChatChannel.find_by( - id: args[:chat_channel_id], - auto_join_users: true, - chatable_type: "Category", - ) - - return if !channel - - category = channel.chatable - return if !category - - query_args = { - chat_channel_id: channel.id, - start: start_user_id, - end: end_user_id, - suspended_until: Time.zone.now, - last_seen_at: 3.months.ago, - channel_category: channel.chatable_id, - mode: UserChatChannelMembership.join_modes[:automatic], - } - - new_member_ids = DB.query_single(create_memberships_query(category), query_args) - - # Only do this if we are running auto-join for a single user, if we - # are doing it for many then we should do it after all batches are - # complete for the channel in Jobs::AutoManageChannelMemberships - if start_user_id == end_user_id - Chat::ChatChannelMembershipManager.new(channel).recalculate_user_count - end - - ChatPublisher.publish_new_channel(channel.reload, User.where(id: new_member_ids)) - end - - private - - def create_memberships_query(category) - query = <<~SQL - INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode) - SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode - FROM users - INNER JOIN user_options uo ON uo.user_id = users.id - LEFT OUTER JOIN user_chat_channel_memberships uccm ON - uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id - SQL - - query += <<~SQL if category.read_restricted? - INNER JOIN group_users gu ON gu.user_id = users.id - LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id - SQL - - query += <<~SQL - WHERE (users.id >= :start AND users.id <= :end) AND - users.staged IS FALSE AND users.active AND - NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND - (suspended_till IS NULL OR suspended_till <= :suspended_until) AND - (last_seen_at > :last_seen_at) AND - uo.chat_enabled AND - uccm.id IS NULL - SQL - - query += <<~SQL if category.read_restricted? - AND cg.category_id = :channel_category - SQL - - query += "RETURNING user_chat_channel_memberships.user_id" - end - end -end diff --git a/plugins/chat/app/jobs/regular/auto_manage_channel_memberships.rb b/plugins/chat/app/jobs/regular/auto_manage_channel_memberships.rb deleted file mode 100644 index 9785db5c920..00000000000 --- a/plugins/chat/app/jobs/regular/auto_manage_channel_memberships.rb +++ /dev/null @@ -1,79 +0,0 @@ -# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well. -# frozen_string_literal: true - -module Jobs - class AutoManageChannelMemberships < ::Jobs::Base - def execute(args) - channel = - ChatChannel.includes(:chatable).find_by( - id: args[:chat_channel_id], - auto_join_users: true, - chatable_type: "Category", - ) - - return if !channel&.chatable - - processed = - UserChatChannelMembership.where( - chat_channel: channel, - following: true, - join_mode: UserChatChannelMembership.join_modes[:automatic], - ).count - - auto_join_query(channel).find_in_batches do |batch| - break if processed >= SiteSetting.max_chat_auto_joined_users - - starts_at = batch.first.query_user_id - ends_at = batch.last.query_user_id - - Jobs.enqueue( - :auto_join_channel_batch, - chat_channel_id: channel.id, - starts_at: starts_at, - ends_at: ends_at, - ) - - processed += batch.size - end - - # The Jobs::AutoJoinChannelBatch job will only do this recalculation - # if it's operating on one user, so we need to make sure we do it for - # the channel here once this job is complete. - Chat::ChatChannelMembershipManager.new(channel).recalculate_user_count - end - - private - - def auto_join_query(channel) - category = channel.chatable - - users = - User - .real - .activated - .not_suspended - .not_staged - .distinct - .select(:id, "users.id AS query_user_id") - .where("last_seen_at > ?", 3.months.ago) - .joins(:user_option) - .where(user_options: { chat_enabled: true }) - .joins(<<~SQL) - LEFT OUTER JOIN user_chat_channel_memberships uccm - ON uccm.chat_channel_id = #{channel.id} AND - uccm.user_id = users.id - SQL - .where("uccm.id IS NULL") - - if category.read_restricted? - users = - users - .joins(:group_users) - .joins("INNER JOIN category_groups cg ON cg.group_id = group_users.group_id") - .where("cg.category_id = ?", channel.chatable_id) - end - - users - end - end -end diff --git a/plugins/chat/app/jobs/regular/chat/auto_join_channel_batch.rb b/plugins/chat/app/jobs/regular/chat/auto_join_channel_batch.rb new file mode 100644 index 00000000000..19ca7e5b03a --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/auto_join_channel_batch.rb @@ -0,0 +1,83 @@ +# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well. +# frozen_string_literal: true + +module Jobs + module Chat + class AutoJoinChannelBatch < ::Jobs::Base + def execute(args) + return "starts_at or ends_at missing" if args[:starts_at].blank? || args[:ends_at].blank? + start_user_id = args[:starts_at].to_i + end_user_id = args[:ends_at].to_i + + return "End is higher than start" if end_user_id < start_user_id + + channel = + ::Chat::Channel.find_by( + id: args[:chat_channel_id], + auto_join_users: true, + chatable_type: "Category", + ) + + return if !channel + + category = channel.chatable + return if !category + + query_args = { + chat_channel_id: channel.id, + start: start_user_id, + end: end_user_id, + suspended_until: Time.zone.now, + last_seen_at: 3.months.ago, + channel_category: channel.chatable_id, + mode: ::Chat::UserChatChannelMembership.join_modes[:automatic], + } + + new_member_ids = DB.query_single(create_memberships_query(category), query_args) + + # Only do this if we are running auto-join for a single user, if we + # are doing it for many then we should do it after all batches are + # complete for the channel in Jobs::Chat::AutoManageChannelMemberships + if start_user_id == end_user_id + ::Chat::ChannelMembershipManager.new(channel).recalculate_user_count + end + + ::Chat::Publisher.publish_new_channel(channel.reload, User.where(id: new_member_ids)) + end + + private + + def create_memberships_query(category) + query = <<~SQL + INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode) + SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode + FROM users + INNER JOIN user_options uo ON uo.user_id = users.id + LEFT OUTER JOIN user_chat_channel_memberships uccm ON + uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id + SQL + + query += <<~SQL if category.read_restricted? + INNER JOIN group_users gu ON gu.user_id = users.id + LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id + SQL + + query += <<~SQL + WHERE (users.id >= :start AND users.id <= :end) AND + users.staged IS FALSE AND users.active AND + NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND + (suspended_till IS NULL OR suspended_till <= :suspended_until) AND + (last_seen_at > :last_seen_at) AND + uo.chat_enabled AND + uccm.id IS NULL + SQL + + query += <<~SQL if category.read_restricted? + AND cg.category_id = :channel_category + SQL + + query += "RETURNING user_chat_channel_memberships.user_id" + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/auto_manage_channel_memberships.rb b/plugins/chat/app/jobs/regular/chat/auto_manage_channel_memberships.rb new file mode 100644 index 00000000000..8fe8fbe4826 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/auto_manage_channel_memberships.rb @@ -0,0 +1,81 @@ +# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well. +# frozen_string_literal: true + +module Jobs + module Chat + class AutoManageChannelMemberships < ::Jobs::Base + def execute(args) + channel = + ::Chat::Channel.includes(:chatable).find_by( + id: args[:chat_channel_id], + auto_join_users: true, + chatable_type: "Category", + ) + + return if !channel&.chatable + + processed = + ::Chat::UserChatChannelMembership.where( + chat_channel: channel, + following: true, + join_mode: ::Chat::UserChatChannelMembership.join_modes[:automatic], + ).count + + auto_join_query(channel).find_in_batches do |batch| + break if processed >= ::SiteSetting.max_chat_auto_joined_users + + starts_at = batch.first.query_user_id + ends_at = batch.last.query_user_id + + ::Jobs.enqueue( + ::Jobs::Chat::AutoJoinChannelBatch, + chat_channel_id: channel.id, + starts_at: starts_at, + ends_at: ends_at, + ) + + processed += batch.size + end + + # The Jobs::Chat::AutoJoinChannelBatch job will only do this recalculation + # if it's operating on one user, so we need to make sure we do it for + # the channel here once this job is complete. + ::Chat::ChannelMembershipManager.new(channel).recalculate_user_count + end + + private + + def auto_join_query(channel) + category = channel.chatable + + users = + ::User + .real + .activated + .not_suspended + .not_staged + .distinct + .select(:id, "users.id AS query_user_id") + .where("last_seen_at > ?", 3.months.ago) + .joins(:user_option) + .where(user_options: { chat_enabled: true }) + .joins(<<~SQL) + LEFT OUTER JOIN user_chat_channel_memberships uccm + ON uccm.chat_channel_id = #{channel.id} AND + uccm.user_id = users.id + SQL + .where("uccm.id IS NULL") + + if category.read_restricted? + users = + users + .joins(:group_users) + .joins("INNER JOIN category_groups cg ON cg.group_id = group_users.group_id") + .where("cg.category_id = ?", channel.chatable_id) + end + + users + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/channel_archive.rb b/plugins/chat/app/jobs/regular/chat/channel_archive.rb new file mode 100644 index 00000000000..49594fb87dc --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/channel_archive.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class ChannelArchive < ::Jobs::Base + sidekiq_options retry: false + + def execute(args = {}) + channel_archive = ::Chat::ChannelArchive.find_by(id: args[:chat_channel_archive_id]) + + # this should not really happen, but better to do this than throw an error + if channel_archive.blank? + ::Rails.logger.warn( + "Chat channel archive #{args[:chat_channel_archive_id]} could not be found, aborting archive job.", + ) + return + end + + if channel_archive.complete? + channel_archive.chat_channel.update!(status: :archived) + + ::Chat::Publisher.publish_archive_status( + channel_archive.chat_channel, + archive_status: :success, + archived_messages: channel_archive.archived_messages, + archive_topic_id: channel_archive.destination_topic_id, + total_messages: channel_archive.total_messages, + ) + + return + end + + ::DistributedMutex.synchronize( + "archive_chat_channel_#{channel_archive.chat_channel_id}", + validity: 20.minutes, + ) { ::Chat::ChannelArchiveService.new(channel_archive).execute } + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/channel_delete.rb b/plugins/chat/app/jobs/regular/chat/channel_delete.rb new file mode 100644 index 00000000000..894fb95bdb5 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/channel_delete.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class ChannelDelete < ::Jobs::Base + def execute(args = {}) + chat_channel = ::Chat::Channel.with_deleted.find_by(id: args[:chat_channel_id]) + + # this should not really happen, but better to do this than throw an error + if chat_channel.blank? + ::Rails.logger.warn( + "Chat channel #{args[:chat_channel_id]} could not be found, aborting delete job.", + ) + return + end + + ::DistributedMutex.synchronize("delete_chat_channel_#{chat_channel.id}") do + ::Rails.logger.debug("Deleting webhooks and events for channel #{chat_channel.id}") + ::Chat::Message.transaction do + webhooks = ::Chat::IncomingWebhook.where(chat_channel: chat_channel) + ::Chat::WebhookEvent.where(incoming_chat_webhook_id: webhooks.select(:id)).delete_all + webhooks.delete_all + end + + ::Rails.logger.debug("Deleting drafts and memberships for channel #{chat_channel.id}") + ::Chat::Draft.where(chat_channel: chat_channel).delete_all + ::Chat::UserChatChannelMembership.where(chat_channel: chat_channel).delete_all + + ::Rails.logger.debug( + "Deleting chat messages, mentions, revisions, and uploads for channel #{chat_channel.id}", + ) + chat_messages = ::Chat::Message.where(chat_channel: chat_channel) + delete_messages_and_related_records(chat_channel, chat_messages) if chat_messages.any? + end + end + + def delete_messages_and_related_records(chat_channel, chat_messages) + message_ids = chat_messages.pluck(:id) + + ::Chat::Message.transaction do + ::Chat::Mention.where(chat_message_id: message_ids).delete_all + ::Chat::MessageRevision.where(chat_message_id: message_ids).delete_all + ::Chat::MessageReaction.where(chat_message_id: message_ids).delete_all + + # if the uploads are not used anywhere else they will be deleted + # by the CleanUpUploads job in core + ::DB.exec("DELETE FROM chat_uploads WHERE chat_message_id IN (#{message_ids.join(",")})") + ::UploadReference.where( + target_id: message_ids, + target_type: ::Chat::Message.sti_name, + ).delete_all + + # only the messages and the channel are Trashable, everything else gets + # permanently destroyed + chat_messages.update_all( + deleted_by_id: chat_channel.deleted_by_id, + deleted_at: Time.zone.now, + ) + end + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/delete_user_messages.rb b/plugins/chat/app/jobs/regular/chat/delete_user_messages.rb new file mode 100644 index 00000000000..a97d1d55c38 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/delete_user_messages.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class DeleteUserMessages < ::Jobs::Base + def execute(args) + return if args[:user_id].nil? + + ::Chat::MessageDestroyer.new.destroy_in_batches( + ::Chat::Message.with_deleted.where(user_id: args[:user_id]), + ) + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/notify_mentioned.rb b/plugins/chat/app/jobs/regular/chat/notify_mentioned.rb new file mode 100644 index 00000000000..e88798a74c0 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/notify_mentioned.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class NotifyMentioned < ::Jobs::Base + def execute(args = {}) + @chat_message = + ::Chat::Message.includes(:user, :revisions, chat_channel: :chatable).find_by( + id: args[:chat_message_id], + ) + if @chat_message.nil? || + @chat_message.revisions.where("created_at > ?", args[:timestamp]).any? + return + end + + @creator = @chat_message.user + @chat_channel = @chat_message.chat_channel + @already_notified_user_ids = args[:already_notified_user_ids] || [] + user_ids_to_notify = args[:to_notify_ids_map] || {} + user_ids_to_notify.each { |mention_type, ids| process_mentions(ids, mention_type.to_sym) } + end + + private + + def get_memberships(user_ids) + query = + ::Chat::UserChatChannelMembership.includes(:user).where( + user_id: (user_ids - @already_notified_user_ids), + chat_channel_id: @chat_message.chat_channel_id, + ) + query = query.where(following: true) if @chat_channel.public_channel? + query + end + + def build_data_for(membership, identifier_type:) + data = { + chat_message_id: @chat_message.id, + chat_channel_id: @chat_channel.id, + mentioned_by_username: @creator.username, + is_direct_message_channel: @chat_channel.direct_message_channel?, + } + + if !@is_direct_message_channel + data[:chat_channel_title] = @chat_channel.title(membership.user) + data[:chat_channel_slug] = @chat_channel.slug + end + + return data if identifier_type == :direct_mentions + + case identifier_type + when :here_mentions + data[:identifier] = "here" + when :global_mentions + data[:identifier] = "all" + else + data[:identifier] = identifier_type if identifier_type + data[:is_group_mention] = true + end + + data + end + + def build_payload_for(membership, identifier_type:) + payload = { + notification_type: ::Notification.types[:chat_mention], + username: @creator.username, + tag: ::Chat::Notifier.push_notification_tag(:mention, @chat_channel.id), + excerpt: @chat_message.push_notification_excerpt, + post_url: "#{@chat_channel.relative_url}/#{@chat_message.id}", + } + + translation_prefix = + ( + if @chat_channel.direct_message_channel? + "discourse_push_notifications.popup.direct_message_chat_mention" + else + "discourse_push_notifications.popup.chat_mention" + end + ) + + translation_suffix = identifier_type == :direct_mentions ? "direct" : "other_type" + identifier_text = + case identifier_type + when :here_mentions + "@here" + when :global_mentions + "@all" + when :direct_mentions + "" + else + "@#{identifier_type}" + end + + payload[:translated_title] = ::I18n.t( + "#{translation_prefix}.#{translation_suffix}", + username: @creator.username, + identifier: identifier_text, + channel: @chat_channel.title(membership.user), + ) + + payload + end + + def create_notification!(membership, mention, mention_type) + notification_data = build_data_for(membership, identifier_type: mention_type) + is_read = ::Chat::Notifier.user_has_seen_message?(membership, @chat_message.id) + notification = + ::Notification.create!( + notification_type: ::Notification.types[:chat_mention], + user_id: membership.user_id, + high_priority: true, + data: notification_data.to_json, + read: is_read, + ) + + mention.update!(notification: notification) + end + + def send_notifications(membership, mention_type) + payload = build_payload_for(membership, identifier_type: mention_type) + + if !membership.desktop_notifications_never? && !membership.muted? + ::MessageBus.publish( + "/chat/notification-alert/#{membership.user_id}", + payload, + user_ids: [membership.user_id], + ) + end + + if !membership.mobile_notifications_never? && !membership.muted? + ::PostAlerter.push_notification(membership.user, payload) + end + end + + def process_mentions(user_ids, mention_type) + memberships = get_memberships(user_ids) + + memberships.each do |membership| + mention = ::Chat::Mention.find_by(user: membership.user, chat_message: @chat_message) + if mention.present? + create_notification!(membership, mention, mention_type) + send_notifications(membership, mention_type) + end + end + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/notify_watching.rb b/plugins/chat/app/jobs/regular/chat/notify_watching.rb new file mode 100644 index 00000000000..3bdae6f0fe6 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/notify_watching.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class NotifyWatching < ::Jobs::Base + def execute(args = {}) + @chat_message = + ::Chat::Message.includes(:user, chat_channel: :chatable).find_by( + id: args[:chat_message_id], + ) + return if @chat_message.nil? + + @creator = @chat_message.user + @chat_channel = @chat_message.chat_channel + @is_direct_message_channel = @chat_channel.direct_message_channel? + + always_notification_level = ::Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always] + + members = + ::Chat::UserChatChannelMembership + .includes(user: :groups) + .joins(user: :user_option) + .where(user_option: { chat_enabled: true }) + .where.not(user_id: args[:except_user_ids]) + .where(chat_channel_id: @chat_channel.id) + .where(following: true) + .where( + "desktop_notification_level = ? OR mobile_notification_level = ?", + always_notification_level, + always_notification_level, + ) + .merge(User.not_suspended) + + if @is_direct_message_channel + ::UserCommScreener + .new(acting_user: @creator, target_user_ids: members.map(&:user_id)) + .allowing_actor_communication + .each do |user_id| + send_notifications(members.find { |member| member.user_id == user_id }) + end + else + members.each { |member| send_notifications(member) } + end + end + + def send_notifications(membership) + user = membership.user + guardian = ::Guardian.new(user) + return unless guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel) + return if ::Chat::Notifier.user_has_seen_message?(membership, @chat_message.id) + return if online_user_ids.include?(user.id) + + translation_key = + ( + if @is_direct_message_channel + "discourse_push_notifications.popup.new_direct_chat_message" + else + "discourse_push_notifications.popup.new_chat_message" + end + ) + + translation_args = { username: @creator.username } + translation_args[:channel] = @chat_channel.title(user) unless @is_direct_message_channel + + payload = { + username: @creator.username, + notification_type: ::Notification.types[:chat_message], + post_url: @chat_channel.relative_url, + translated_title: ::I18n.t(translation_key, translation_args), + tag: ::Chat::Notifier.push_notification_tag(:message, @chat_channel.id), + excerpt: @chat_message.push_notification_excerpt, + } + + if membership.desktop_notifications_always? && !membership.muted? + ::MessageBus.publish("/chat/notification-alert/#{user.id}", payload, user_ids: [user.id]) + end + + if membership.mobile_notifications_always? && !membership.muted? + ::PostAlerter.push_notification(user, payload) + end + end + + def online_user_ids + @online_user_ids ||= ::PresenceChannel.new("/chat/online").user_ids + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/process_message.rb b/plugins/chat/app/jobs/regular/chat/process_message.rb new file mode 100644 index 00000000000..33fcc43b565 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/process_message.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class ProcessMessage < ::Jobs::Base + def execute(args = {}) + ::DistributedMutex.synchronize( + "jobs_chat_process_message_#{args[:chat_message_id]}", + validity: 10.minutes, + ) do + chat_message = ::Chat::Message.find_by(id: args[:chat_message_id]) + return if !chat_message + processor = ::Chat::MessageProcessor.new(chat_message) + processor.run! + + if args[:is_dirty] || processor.dirty? + chat_message.update( + cooked: processor.html, + cooked_version: ::Chat::Message::BAKED_VERSION, + ) + ::Chat::Publisher.publish_processed!(chat_message) + end + end + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/send_message_notifications.rb b/plugins/chat/app/jobs/regular/chat/send_message_notifications.rb new file mode 100644 index 00000000000..4724f5a6f94 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/send_message_notifications.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class SendMessageNotifications < ::Jobs::Base + def execute(args) + reason = args[:reason] + valid_reasons = %w[new edit] + return unless valid_reasons.include?(reason) + + return if (timestamp = args[:timestamp]).blank? + + return if (message = ::Chat::Message.find_by(id: args[:chat_message_id])).nil? + + if reason == "new" + ::Chat::Notifier.new(message, timestamp).notify_new + elsif reason == "edit" + ::Chat::Notifier.new(message, timestamp).notify_edit + end + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/update_channel_user_count.rb b/plugins/chat/app/jobs/regular/chat/update_channel_user_count.rb new file mode 100644 index 00000000000..8608fd305fb --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/update_channel_user_count.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class UpdateChannelUserCount < Jobs::Base + def execute(args = {}) + channel = ::Chat::Channel.find_by(id: args[:chat_channel_id]) + return if channel.blank? + return if !channel.user_count_stale + + channel.update!( + user_count: ::Chat::ChannelMembershipsQuery.count(channel), + user_count_stale: false, + ) + + ::Chat::Publisher.publish_chat_channel_metadata(channel) + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat_channel_archive.rb b/plugins/chat/app/jobs/regular/chat_channel_archive.rb deleted file mode 100644 index 33e270dd220..00000000000 --- a/plugins/chat/app/jobs/regular/chat_channel_archive.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class ChatChannelArchive < ::Jobs::Base - sidekiq_options retry: false - - def execute(args = {}) - channel_archive = ::ChatChannelArchive.find_by(id: args[:chat_channel_archive_id]) - - # this should not really happen, but better to do this than throw an error - if channel_archive.blank? - Rails.logger.warn( - "Chat channel archive #{args[:chat_channel_archive_id]} could not be found, aborting archive job.", - ) - return - end - - if channel_archive.complete? - channel_archive.chat_channel.update!(status: :archived) - - ChatPublisher.publish_archive_status( - channel_archive.chat_channel, - archive_status: :success, - archived_messages: channel_archive.archived_messages, - archive_topic_id: channel_archive.destination_topic_id, - total_messages: channel_archive.total_messages, - ) - - return - end - - DistributedMutex.synchronize( - "archive_chat_channel_#{channel_archive.chat_channel_id}", - validity: 20.minutes, - ) { Chat::ChatChannelArchiveService.new(channel_archive).execute } - end - end -end diff --git a/plugins/chat/app/jobs/regular/chat_channel_delete.rb b/plugins/chat/app/jobs/regular/chat_channel_delete.rb deleted file mode 100644 index 3d407caf9cd..00000000000 --- a/plugins/chat/app/jobs/regular/chat_channel_delete.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class ChatChannelDelete < ::Jobs::Base - def execute(args = {}) - chat_channel = ::ChatChannel.with_deleted.find_by(id: args[:chat_channel_id]) - - # this should not really happen, but better to do this than throw an error - if chat_channel.blank? - Rails.logger.warn( - "Chat channel #{args[:chat_channel_id]} could not be found, aborting delete job.", - ) - return - end - - DistributedMutex.synchronize("delete_chat_channel_#{chat_channel.id}") do - Rails.logger.debug("Deleting webhooks and events for channel #{chat_channel.id}") - ChatMessage.transaction do - webhooks = IncomingChatWebhook.where(chat_channel: chat_channel) - ChatWebhookEvent.where(incoming_chat_webhook_id: webhooks.select(:id)).delete_all - webhooks.delete_all - end - - Rails.logger.debug("Deleting drafts and memberships for channel #{chat_channel.id}") - ChatDraft.where(chat_channel: chat_channel).delete_all - UserChatChannelMembership.where(chat_channel: chat_channel).delete_all - - Rails.logger.debug( - "Deleting chat messages, mentions, revisions, and uploads for channel #{chat_channel.id}", - ) - chat_messages = ChatMessage.where(chat_channel: chat_channel) - delete_messages_and_related_records(chat_channel, chat_messages) if chat_messages.any? - end - end - - def delete_messages_and_related_records(chat_channel, chat_messages) - message_ids = chat_messages.pluck(:id) - - ChatMessage.transaction do - ChatMention.where(chat_message_id: message_ids).delete_all - ChatMessageRevision.where(chat_message_id: message_ids).delete_all - ChatMessageReaction.where(chat_message_id: message_ids).delete_all - - # if the uploads are not used anywhere else they will be deleted - # by the CleanUpUploads job in core - DB.exec("DELETE FROM chat_uploads WHERE chat_message_id IN (#{message_ids.join(",")})") - UploadReference.where(target_id: message_ids, target_type: "ChatMessage").delete_all - - # only the messages and the channel are Trashable, everything else gets - # permanently destroyed - chat_messages.update_all( - deleted_by_id: chat_channel.deleted_by_id, - deleted_at: Time.zone.now, - ) - end - end - end -end diff --git a/plugins/chat/app/jobs/regular/chat_notify_mentioned.rb b/plugins/chat/app/jobs/regular/chat_notify_mentioned.rb deleted file mode 100644 index f0102aa7a68..00000000000 --- a/plugins/chat/app/jobs/regular/chat_notify_mentioned.rb +++ /dev/null @@ -1,146 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class ChatNotifyMentioned < ::Jobs::Base - def execute(args = {}) - @chat_message = - ChatMessage.includes(:user, :revisions, chat_channel: :chatable).find_by( - id: args[:chat_message_id], - ) - if @chat_message.nil? || - @chat_message.revisions.where("created_at > ?", args[:timestamp]).any? - return - end - - @creator = @chat_message.user - @chat_channel = @chat_message.chat_channel - @already_notified_user_ids = args[:already_notified_user_ids] || [] - user_ids_to_notify = args[:to_notify_ids_map] || {} - user_ids_to_notify.each { |mention_type, ids| process_mentions(ids, mention_type.to_sym) } - end - - private - - def get_memberships(user_ids) - query = - UserChatChannelMembership.includes(:user).where( - user_id: (user_ids - @already_notified_user_ids), - chat_channel_id: @chat_message.chat_channel_id, - ) - query = query.where(following: true) if @chat_channel.public_channel? - query - end - - def build_data_for(membership, identifier_type:) - data = { - chat_message_id: @chat_message.id, - chat_channel_id: @chat_channel.id, - mentioned_by_username: @creator.username, - is_direct_message_channel: @chat_channel.direct_message_channel?, - } - - if !@is_direct_message_channel - data[:chat_channel_title] = @chat_channel.title(membership.user) - data[:chat_channel_slug] = @chat_channel.slug - end - - return data if identifier_type == :direct_mentions - - case identifier_type - when :here_mentions - data[:identifier] = "here" - when :global_mentions - data[:identifier] = "all" - else - data[:identifier] = identifier_type if identifier_type - data[:is_group_mention] = true - end - - data - end - - def build_payload_for(membership, identifier_type:) - payload = { - notification_type: Notification.types[:chat_mention], - username: @creator.username, - tag: Chat::ChatNotifier.push_notification_tag(:mention, @chat_channel.id), - excerpt: @chat_message.push_notification_excerpt, - post_url: "#{@chat_channel.relative_url}/#{@chat_message.id}", - } - - translation_prefix = - ( - if @chat_channel.direct_message_channel? - "discourse_push_notifications.popup.direct_message_chat_mention" - else - "discourse_push_notifications.popup.chat_mention" - end - ) - - translation_suffix = identifier_type == :direct_mentions ? "direct" : "other_type" - identifier_text = - case identifier_type - when :here_mentions - "@here" - when :global_mentions - "@all" - when :direct_mentions - "" - else - "@#{identifier_type}" - end - - payload[:translated_title] = I18n.t( - "#{translation_prefix}.#{translation_suffix}", - username: @creator.username, - identifier: identifier_text, - channel: @chat_channel.title(membership.user), - ) - - payload - end - - def create_notification!(membership, mention, mention_type) - notification_data = build_data_for(membership, identifier_type: mention_type) - is_read = Chat::ChatNotifier.user_has_seen_message?(membership, @chat_message.id) - notification = - Notification.create!( - notification_type: Notification.types[:chat_mention], - user_id: membership.user_id, - high_priority: true, - data: notification_data.to_json, - read: is_read, - ) - - mention.update!(notification: notification) - end - - def send_notifications(membership, mention_type) - payload = build_payload_for(membership, identifier_type: mention_type) - - if !membership.desktop_notifications_never? && !membership.muted? - MessageBus.publish( - "/chat/notification-alert/#{membership.user_id}", - payload, - user_ids: [membership.user_id], - ) - end - - if !membership.mobile_notifications_never? && !membership.muted? - PostAlerter.push_notification(membership.user, payload) - end - end - - def process_mentions(user_ids, mention_type) - memberships = get_memberships(user_ids) - - memberships.each do |membership| - mention = ChatMention.find_by(user: membership.user, chat_message: @chat_message) - if mention.present? - create_notification!(membership, mention, mention_type) - send_notifications(membership, mention_type) - end - end - end - end -end diff --git a/plugins/chat/app/jobs/regular/chat_notify_watching.rb b/plugins/chat/app/jobs/regular/chat_notify_watching.rb deleted file mode 100644 index 4ac3fca4fcf..00000000000 --- a/plugins/chat/app/jobs/regular/chat_notify_watching.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class ChatNotifyWatching < ::Jobs::Base - def execute(args = {}) - @chat_message = - ChatMessage.includes(:user, chat_channel: :chatable).find_by(id: args[:chat_message_id]) - return if @chat_message.nil? - - @creator = @chat_message.user - @chat_channel = @chat_message.chat_channel - @is_direct_message_channel = @chat_channel.direct_message_channel? - - always_notification_level = UserChatChannelMembership::NOTIFICATION_LEVELS[:always] - - members = - UserChatChannelMembership - .includes(user: :groups) - .joins(user: :user_option) - .where(user_option: { chat_enabled: true }) - .where.not(user_id: args[:except_user_ids]) - .where(chat_channel_id: @chat_channel.id) - .where(following: true) - .where( - "desktop_notification_level = ? OR mobile_notification_level = ?", - always_notification_level, - always_notification_level, - ) - .merge(User.not_suspended) - - if @is_direct_message_channel - UserCommScreener - .new(acting_user: @creator, target_user_ids: members.map(&:user_id)) - .allowing_actor_communication - .each do |user_id| - send_notifications(members.find { |member| member.user_id == user_id }) - end - else - members.each { |member| send_notifications(member) } - end - end - - def send_notifications(membership) - user = membership.user - guardian = Guardian.new(user) - return unless guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel) - return if Chat::ChatNotifier.user_has_seen_message?(membership, @chat_message.id) - return if online_user_ids.include?(user.id) - - translation_key = - ( - if @is_direct_message_channel - "discourse_push_notifications.popup.new_direct_chat_message" - else - "discourse_push_notifications.popup.new_chat_message" - end - ) - - translation_args = { username: @creator.username } - translation_args[:channel] = @chat_channel.title(user) unless @is_direct_message_channel - - payload = { - username: @creator.username, - notification_type: Notification.types[:chat_message], - post_url: @chat_channel.relative_url, - translated_title: I18n.t(translation_key, translation_args), - tag: Chat::ChatNotifier.push_notification_tag(:message, @chat_channel.id), - excerpt: @chat_message.push_notification_excerpt, - } - - if membership.desktop_notifications_always? && !membership.muted? - MessageBus.publish("/chat/notification-alert/#{user.id}", payload, user_ids: [user.id]) - end - - if membership.mobile_notifications_always? && !membership.muted? - PostAlerter.push_notification(user, payload) - end - end - - def online_user_ids - @online_user_ids ||= PresenceChannel.new("/chat/online").user_ids - end - end -end diff --git a/plugins/chat/app/jobs/regular/delete_user_messages.rb b/plugins/chat/app/jobs/regular/delete_user_messages.rb deleted file mode 100644 index 22c35624ef9..00000000000 --- a/plugins/chat/app/jobs/regular/delete_user_messages.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class DeleteUserMessages < ::Jobs::Base - def execute(args) - return if args[:user_id].nil? - - ChatMessageDestroyer.new.destroy_in_batches( - ChatMessage.with_deleted.where(user_id: args[:user_id]), - ) - end - end -end diff --git a/plugins/chat/app/jobs/regular/process_chat_message.rb b/plugins/chat/app/jobs/regular/process_chat_message.rb deleted file mode 100644 index 612978bb23f..00000000000 --- a/plugins/chat/app/jobs/regular/process_chat_message.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class ProcessChatMessage < ::Jobs::Base - def execute(args = {}) - DistributedMutex.synchronize( - "process_chat_message_#{args[:chat_message_id]}", - validity: 10.minutes, - ) do - chat_message = ChatMessage.find_by(id: args[:chat_message_id]) - return if !chat_message - processor = Chat::ChatMessageProcessor.new(chat_message) - processor.run! - - if args[:is_dirty] || processor.dirty? - chat_message.update(cooked: processor.html, cooked_version: ChatMessage::BAKED_VERSION) - ChatPublisher.publish_processed!(chat_message) - end - end - end - end -end diff --git a/plugins/chat/app/jobs/regular/send_message_notifications.rb b/plugins/chat/app/jobs/regular/send_message_notifications.rb deleted file mode 100644 index 5fa778467e4..00000000000 --- a/plugins/chat/app/jobs/regular/send_message_notifications.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class SendMessageNotifications < ::Jobs::Base - def execute(args) - reason = args[:reason] - valid_reasons = %w[new edit] - return unless valid_reasons.include?(reason) - - return if (timestamp = args[:timestamp]).blank? - - return if (message = ChatMessage.find_by(id: args[:chat_message_id])).nil? - - if reason == "new" - Chat::ChatNotifier.new(message, timestamp).notify_new - elsif reason == "edit" - Chat::ChatNotifier.new(message, timestamp).notify_edit - end - end - end -end diff --git a/plugins/chat/app/jobs/regular/update_channel_user_count.rb b/plugins/chat/app/jobs/regular/update_channel_user_count.rb deleted file mode 100644 index 0790a52e167..00000000000 --- a/plugins/chat/app/jobs/regular/update_channel_user_count.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class UpdateChannelUserCount < Jobs::Base - def execute(args = {}) - channel = ChatChannel.find_by(id: args[:chat_channel_id]) - return if channel.blank? - return if !channel.user_count_stale - - channel.update!( - user_count: ChatChannelMembershipsQuery.count(channel), - user_count_stale: false, - ) - - ChatPublisher.publish_chat_channel_metadata(channel) - end - end -end diff --git a/plugins/chat/app/jobs/scheduled/auto_join_users.rb b/plugins/chat/app/jobs/scheduled/auto_join_users.rb deleted file mode 100644 index 061a3dce8db..00000000000 --- a/plugins/chat/app/jobs/scheduled/auto_join_users.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class AutoJoinUsers < ::Jobs::Scheduled - every 1.hour - - def execute(_args) - ChatChannel - .where(auto_join_users: true) - .each do |channel| - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships - end - end - end -end diff --git a/plugins/chat/app/jobs/scheduled/chat/auto_join_users.rb b/plugins/chat/app/jobs/scheduled/chat/auto_join_users.rb new file mode 100644 index 00000000000..c22ee543fec --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/chat/auto_join_users.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class AutoJoinUsers < ::Jobs::Scheduled + every 1.hour + + def execute(_args) + ::Chat::Channel + .where(auto_join_users: true) + .each do |channel| + ::Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships + end + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/chat/delete_old_messages.rb b/plugins/chat/app/jobs/scheduled/chat/delete_old_messages.rb new file mode 100644 index 00000000000..8c0f065b025 --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/chat/delete_old_messages.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class DeleteOldMessages < ::Jobs::Scheduled + daily at: 0.hours + + def execute(args = {}) + delete_public_channel_messages + delete_dm_channel_messages + end + + private + + def delete_public_channel_messages + return unless valid_day_value?(:chat_channel_retention_days) + + ::Chat::MessageDestroyer.new.destroy_in_batches( + ::Chat::Message.in_public_channel.with_deleted.created_before( + ::SiteSetting.chat_channel_retention_days.days.ago, + ), + ) + end + + def delete_dm_channel_messages + return unless valid_day_value?(:chat_dm_retention_days) + + ::Chat::MessageDestroyer.new.destroy_in_batches( + ::Chat::Message.in_dm_channel.with_deleted.created_before( + ::SiteSetting.chat_dm_retention_days.days.ago, + ), + ) + end + + def valid_day_value?(setting_name) + (::SiteSetting.public_send(setting_name) || 0).positive? + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/chat/email_notifications.rb b/plugins/chat/app/jobs/scheduled/chat/email_notifications.rb new file mode 100644 index 00000000000..a5adac40c0c --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/chat/email_notifications.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class EmailNotifications < ::Jobs::Scheduled + every 5.minutes + + def execute(args = {}) + return unless ::SiteSetting.chat_enabled + + ::Chat::Mailer.send_unread_mentions_summary + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/chat/periodical_updates.rb b/plugins/chat/app/jobs/scheduled/chat/periodical_updates.rb new file mode 100644 index 00000000000..7b6c1f3318e --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/chat/periodical_updates.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class PeriodicalUpdates < ::Jobs::Scheduled + every 15.minutes + + def execute(args = nil) + # TODO: Add rebaking of old messages (baked_version < + # Chat::Message::BAKED_VERSION or baked_version IS NULL) + ::Chat::Channel.ensure_consistency! + nil + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/chat/update_user_counts_for_channels.rb b/plugins/chat/app/jobs/scheduled/chat/update_user_counts_for_channels.rb new file mode 100644 index 00000000000..4478bd4f910 --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/chat/update_user_counts_for_channels.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Jobs + # TODO (martin) Move into Chat::Channel.ensure_consistency! so it + # is run with Jobs::Chat::PeriodicalUpdates + module Chat + class UpdateUserCountsForChannels < ::Jobs::Scheduled + every 1.hour + + # FIXME: This could become huge as the amount of channels grows, we + # need a different approach here. Perhaps we should only bother for + # channels updated or with new messages in the past N days? Perhaps + # we could update all the counts in a single query as well? + def execute(args = {}) + ::Chat::Channel + .where(status: %i[open closed]) + .find_each { |chat_channel| set_user_count(chat_channel) } + end + + def set_user_count(chat_channel) + current_count = chat_channel.user_count || 0 + new_count = ::Chat::ChannelMembershipsQuery.count(chat_channel) + return if current_count == new_count + + chat_channel.update(user_count: new_count, user_count_stale: false) + ::Chat::Publisher.publish_chat_channel_metadata(chat_channel) + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/chat_periodical_updates.rb b/plugins/chat/app/jobs/scheduled/chat_periodical_updates.rb deleted file mode 100644 index c7ca56fcb15..00000000000 --- a/plugins/chat/app/jobs/scheduled/chat_periodical_updates.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class ChatPeriodicalUpdates < ::Jobs::Scheduled - every 15.minutes - - def execute(args = nil) - # TODO: Add rebaking of old messages (baked_version < - # ChatMessage::BAKED_VERSION or baked_version IS NULL) - ChatChannel.ensure_consistency! - nil - end - end -end diff --git a/plugins/chat/app/jobs/scheduled/delete_old_chat_messages.rb b/plugins/chat/app/jobs/scheduled/delete_old_chat_messages.rb deleted file mode 100644 index 0fbc06141be..00000000000 --- a/plugins/chat/app/jobs/scheduled/delete_old_chat_messages.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class DeleteOldChatMessages < ::Jobs::Scheduled - daily at: 0.hours - - def execute(args = {}) - delete_public_channel_messages - delete_dm_channel_messages - end - - private - - def delete_public_channel_messages - return unless valid_day_value?(:chat_channel_retention_days) - - ChatMessageDestroyer.new.destroy_in_batches( - ChatMessage.in_public_channel.with_deleted.created_before( - SiteSetting.chat_channel_retention_days.days.ago, - ), - ) - end - - def delete_dm_channel_messages - return unless valid_day_value?(:chat_dm_retention_days) - - ChatMessageDestroyer.new.destroy_in_batches( - ChatMessage.in_dm_channel.with_deleted.created_before( - SiteSetting.chat_dm_retention_days.days.ago, - ), - ) - end - - def valid_day_value?(setting_name) - (SiteSetting.public_send(setting_name) || 0).positive? - end - end -end diff --git a/plugins/chat/app/jobs/scheduled/email_chat_notifications.rb b/plugins/chat/app/jobs/scheduled/email_chat_notifications.rb deleted file mode 100644 index 470c6aa2152..00000000000 --- a/plugins/chat/app/jobs/scheduled/email_chat_notifications.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class EmailChatNotifications < ::Jobs::Scheduled - every 5.minutes - - def execute(args = {}) - return unless SiteSetting.chat_enabled - - Chat::ChatMailer.send_unread_mentions_summary - end - end -end diff --git a/plugins/chat/app/jobs/scheduled/update_user_counts_for_chat_channels.rb b/plugins/chat/app/jobs/scheduled/update_user_counts_for_chat_channels.rb deleted file mode 100644 index 8880732b8e5..00000000000 --- a/plugins/chat/app/jobs/scheduled/update_user_counts_for_chat_channels.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Jobs - # TODO (martin) Move into ChatChannel.ensure_consistency! so it - # is run with ChatPeriodicalUpdates - class UpdateUserCountsForChatChannels < ::Jobs::Scheduled - every 1.hour - - # FIXME: This could become huge as the amount of channels grows, we - # need a different approach here. Perhaps we should only bother for - # channels updated or with new messages in the past N days? Perhaps - # we could update all the counts in a single query as well? - def execute(args = {}) - ChatChannel - .where(status: %i[open closed]) - .find_each { |chat_channel| set_user_count(chat_channel) } - end - - def set_user_count(chat_channel) - current_count = chat_channel.user_count || 0 - new_count = ChatChannelMembershipsQuery.count(chat_channel) - return if current_count == new_count - - chat_channel.update(user_count: new_count, user_count_stale: false) - ChatPublisher.publish_chat_channel_metadata(chat_channel) - end - end -end diff --git a/plugins/chat/app/models/category_channel.rb b/plugins/chat/app/models/category_channel.rb deleted file mode 100644 index b205e82b4ac..00000000000 --- a/plugins/chat/app/models/category_channel.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -class CategoryChannel < ChatChannel - alias_attribute :category, :chatable - - delegate :read_restricted?, to: :category - delegate :url, to: :chatable, prefix: true - - %i[category_channel? public_channel? chatable_has_custom_fields?].each do |name| - define_method(name) { true } - end - - def allowed_group_ids - return if !read_restricted? - - staff_groups = Group::AUTO_GROUPS.slice(:staff, :moderators, :admins).values - category.secure_group_ids.to_a.concat(staff_groups) - end - - def title(_ = nil) - name.presence || category.name - end - - def generate_auto_slug - return if self.slug.present? - self.slug = Slug.for(self.title.strip, "") - self.slug = "" if duplicate_slug? - end - - def ensure_slug_ok - if self.slug.present? - # if we don't unescape it first we strip the % from the encoded version - slug = SiteSetting.slug_generation_method == "encoded" ? CGI.unescape(self.slug) : self.slug - self.slug = Slug.for(slug, "", method: :encoded) - - if self.slug.blank? - errors.add(:slug, :invalid) - elsif SiteSetting.slug_generation_method == "ascii" && !CGI.unescape(self.slug).ascii_only? - errors.add(:slug, I18n.t("chat.category_channel.errors.slug_contains_non_ascii_chars")) - elsif duplicate_slug? - errors.add(:slug, I18n.t("chat.category_channel.errors.is_already_in_use")) - end - end - end -end diff --git a/plugins/chat/app/models/chat/category_channel.rb b/plugins/chat/app/models/chat/category_channel.rb new file mode 100644 index 00000000000..aa546265e0f --- /dev/null +++ b/plugins/chat/app/models/chat/category_channel.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Chat + class CategoryChannel < Channel + alias_attribute :category, :chatable + + delegate :read_restricted?, to: :category + delegate :url, to: :chatable, prefix: true + + def self.polymorphic_class_for(name) + Chat::Chatable.polymorphic_class_for(name) || super(name) + end + + %i[category_channel? public_channel? chatable_has_custom_fields?].each do |name| + define_method(name) { true } + end + + def allowed_group_ids + return if !read_restricted? + + staff_groups = Group::AUTO_GROUPS.slice(:staff, :moderators, :admins).values + category.secure_group_ids.to_a.concat(staff_groups) + end + + def title(_ = nil) + name.presence || category.name + end + + def generate_auto_slug + return if self.slug.present? + self.slug = Slug.for(self.title.strip, "") + self.slug = "" if duplicate_slug? + end + + def ensure_slug_ok + if self.slug.present? + # if we don't unescape it first we strip the % from the encoded version + slug = SiteSetting.slug_generation_method == "encoded" ? CGI.unescape(self.slug) : self.slug + self.slug = Slug.for(slug, "", method: :encoded) + + if self.slug.blank? + errors.add(:slug, :invalid) + elsif SiteSetting.slug_generation_method == "ascii" && !CGI.unescape(self.slug).ascii_only? + errors.add(:slug, I18n.t("chat.category_channel.errors.slug_contains_non_ascii_chars")) + elsif duplicate_slug? + errors.add(:slug, I18n.t("chat.category_channel.errors.is_already_in_use")) + end + end + end + end +end diff --git a/plugins/chat/app/models/chat/channel.rb b/plugins/chat/app/models/chat/channel.rb new file mode 100644 index 00000000000..c5c67d23d95 --- /dev/null +++ b/plugins/chat/app/models/chat/channel.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +module Chat + class Channel < ActiveRecord::Base + include Trashable + + self.table_name = "chat_channels" + + belongs_to :chatable, polymorphic: true + + def self.sti_class_for(type_name) + Chat::Chatable.sti_class_for(type_name) || super(type_name) + end + + def self.sti_name + Chat::Chatable.sti_name_for(self) || super + end + + belongs_to :direct_message, + class_name: "Chat::DirectMessage", + foreign_key: :chatable_id, + inverse_of: :direct_message_channel, + optional: true + + has_many :chat_messages, class_name: "Chat::Message", foreign_key: :chat_channel_id + has_many :user_chat_channel_memberships, + class_name: "Chat::UserChatChannelMembership", + foreign_key: :chat_channel_id + has_one :chat_channel_archive, class_name: "Chat::ChannelArchive", foreign_key: :chat_channel_id + + enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false + + validates :name, + length: { + maximum: Proc.new { SiteSetting.max_topic_title_length }, + }, + presence: true, + allow_nil: true + validate :ensure_slug_ok, if: :slug_changed? + before_validation :generate_auto_slug + + scope :public_channels, + -> { + where(chatable_type: public_channel_chatable_types).where( + "categories.id IS NOT NULL", + ).joins( + "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + } + + delegate :empty?, to: :chat_messages, prefix: true + + class << self + def editable_statuses + statuses.filter { |k, _| !%w[read_only archived].include?(k) } + end + + def public_channel_chatable_types + %w[Category] + end + + def direct_channel_chatable_types + %w[DirectMessage] + end + + def chatable_types + public_channel_chatable_types + direct_channel_chatable_types + end + end + + statuses.keys.each do |status| + define_method("#{status}!") { |acting_user| change_status(acting_user, status.to_sym) } + end + + %i[ + category_channel? + direct_message_channel? + public_channel? + chatable_has_custom_fields? + read_restricted? + ].each { |name| define_method(name) { false } } + + %i[allowed_user_ids allowed_group_ids chatable_url].each { |name| define_method(name) { nil } } + + def membership_for(user) + user_chat_channel_memberships.find_by(user: user) + end + + def add(user) + Chat::ChannelMembershipManager.new(self).follow(user) + end + + def remove(user) + Chat::ChannelMembershipManager.new(self).unfollow(user) + end + + def url + "#{Discourse.base_url}/chat/c/#{self.slug || "-"}/#{self.id}" + end + + def relative_url + "#{Discourse.base_path}/chat/c/#{self.slug || "-"}/#{self.id}" + end + + def self.ensure_consistency! + update_counts + end + + # TODO (martin) Move Jobs::Chat::UpdateUserCountsForChannels into here + def self.update_counts + # NOTE: Chat::Channel#messages_count is not updated every time + # a message is created or deleted in a channel, so it should not + # be displayed in the UI. It is updated eventually via Jobs::Chat::PeriodicalUpdates + DB.exec <<~SQL + UPDATE chat_channels channels + SET messages_count = subquery.messages_count + FROM ( + SELECT COUNT(*) AS messages_count, chat_channel_id + FROM chat_messages + WHERE chat_messages.deleted_at IS NULL + GROUP BY chat_channel_id + ) subquery + WHERE channels.id = subquery.chat_channel_id + AND channels.deleted_at IS NULL + AND subquery.messages_count != channels.messages_count + SQL + end + + private + + def change_status(acting_user, target_status) + return if !Guardian.new(acting_user).can_change_channel_status?(self, target_status) + self.update!(status: target_status) + log_channel_status_change(acting_user: acting_user) + end + + def log_channel_status_change(acting_user:) + DiscourseEvent.trigger( + :chat_channel_status_change, + channel: self, + old_status: status_previously_was, + new_status: status, + ) + + StaffActionLogger.new(acting_user).log_custom( + "chat_channel_status_change", + { + chat_channel_id: self.id, + chat_channel_name: self.name, + previous_value: status_previously_was, + new_value: status, + }, + ) + + Chat::Publisher.publish_channel_status(self) + end + + def duplicate_slug? + Chat::Channel.where(slug: self.slug).where.not(id: self.id).any? + end + end +end + +# == Schema Information +# +# Table name: chat_channels +# +# id :bigint not null, primary key +# chatable_id :integer not null +# deleted_at :datetime +# deleted_by_id :integer +# featured_in_category_id :integer +# delete_after_seconds :integer +# chatable_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# name :string +# description :text +# status :integer default("open"), not null +# user_count :integer default(0), not null +# last_message_sent_at :datetime not null +# auto_join_users :boolean default(FALSE), not null +# allow_channel_wide_mentions :boolean default(TRUE), not null +# user_count_stale :boolean default(FALSE), not null +# slug :string +# type :string +# threading_enabled :boolean default(FALSE), not null +# +# Indexes +# +# index_chat_channels_on_messages_count (messages_count) +# index_chat_channels_on_chatable_id (chatable_id) +# index_chat_channels_on_chatable_id_and_chatable_type (chatable_id,chatable_type) +# index_chat_channels_on_slug (slug) UNIQUE +# index_chat_channels_on_status (status) +# diff --git a/plugins/chat/app/models/chat_channel_archive.rb b/plugins/chat/app/models/chat/channel_archive.rb similarity index 60% rename from plugins/chat/app/models/chat_channel_archive.rb rename to plugins/chat/app/models/chat/channel_archive.rb index 057af4e5bf9..e8c88b4b932 100644 --- a/plugins/chat/app/models/chat_channel_archive.rb +++ b/plugins/chat/app/models/chat/channel_archive.rb @@ -1,21 +1,24 @@ # frozen_string_literal: true -class ChatChannelArchive < ActiveRecord::Base - belongs_to :chat_channel - belongs_to :archived_by, class_name: "User" +module Chat + class ChannelArchive < ActiveRecord::Base + belongs_to :chat_channel, class_name: "Chat::Channel" + belongs_to :archived_by, class_name: "User" + belongs_to :destination_topic, class_name: "Topic" - belongs_to :destination_topic, class_name: "Topic" + self.table_name = "chat_channel_archives" - def complete? - self.archived_messages >= self.total_messages && self.chat_channel.chat_messages.count.zero? - end + def complete? + self.archived_messages >= self.total_messages && self.chat_channel.chat_messages.count.zero? + end - def failed? - !complete? && self.archive_error.present? - end + def failed? + !complete? && self.archive_error.present? + end - def new_topic? - self.destination_topic_title.present? + def new_topic? + self.destination_topic_title.present? + end end end diff --git a/plugins/chat/app/models/chat/deleted_user.rb b/plugins/chat/app/models/chat/deleted_user.rb new file mode 100644 index 00000000000..b97d775500d --- /dev/null +++ b/plugins/chat/app/models/chat/deleted_user.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Chat + class DeletedUser < User + def username + I18n.t("chat.deleted_chat_username") + end + + def avatar_template + "/plugins/chat/images/deleted-chat-user-avatar.png" + end + + def bot? + false + end + end +end diff --git a/plugins/chat/app/models/chat/direct_message.rb b/plugins/chat/app/models/chat/direct_message.rb new file mode 100644 index 00000000000..5780a879760 --- /dev/null +++ b/plugins/chat/app/models/chat/direct_message.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Chat + class DirectMessage < ActiveRecord::Base + self.table_name = "direct_message_channels" + + include Chatable + + def self.polymorphic_name + Chat::Chatable.polymorphic_name_for(self) || super + end + + has_many :direct_message_users, + class_name: "Chat::DirectMessageUser", + foreign_key: :direct_message_channel_id + has_many :users, through: :direct_message_users + + has_one :direct_message_channel, as: :chatable, class_name: "Chat::DirectMessageChannel" + + def self.for_user_ids(user_ids) + joins(:users) + .group("direct_message_channels.id") + .having("ARRAY[?] = ARRAY_AGG(users.id ORDER BY users.id)", user_ids.sort) + &.first + end + + def user_can_access?(user) + users.include?(user) + end + + def chat_channel_title_for_user(chat_channel, acting_user) + users = + (direct_message_users.map(&:user) - [acting_user]).map do |user| + user || Chat::DeletedUser.new + end + + # direct message to self + if users.empty? + return I18n.t("chat.channel.dm_title.single_user", username: "@#{acting_user.username}") + end + + # all users deleted + return chat_channel.id if !users.first + + usernames_formatted = users.sort_by(&:username).map { |u| "@#{u.username}" } + if usernames_formatted.size > 5 + return( + I18n.t( + "chat.channel.dm_title.multi_user_truncated", + comma_separated_usernames: + usernames_formatted[0..4].join(I18n.t("word_connector.comma")), + count: usernames_formatted.length - 5, + ) + ) + end + + I18n.t( + "chat.channel.dm_title.multi_user", + comma_separated_usernames: usernames_formatted.join(I18n.t("word_connector.comma")), + ) + end + end +end + +# == Schema Information +# +# Table name: direct_message_channels +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/plugins/chat/app/models/chat/direct_message_channel.rb b/plugins/chat/app/models/chat/direct_message_channel.rb new file mode 100644 index 00000000000..a63b0af7376 --- /dev/null +++ b/plugins/chat/app/models/chat/direct_message_channel.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Chat + class DirectMessageChannel < Channel + alias_attribute :direct_message, :chatable + + def self.polymorphic_class_for(name) + Chat::Chatable.polymorphic_class_for(name) || super(name) + end + + def direct_message_channel? + true + end + + def allowed_user_ids + direct_message.user_ids + end + + def read_restricted? + true + end + + def title(user) + direct_message.chat_channel_title_for_user(self, user) + end + + def ensure_slug_ok + true + end + + def generate_auto_slug + self.slug = nil + end + end +end diff --git a/plugins/chat/app/models/direct_message_user.rb b/plugins/chat/app/models/chat/direct_message_user.rb similarity index 64% rename from plugins/chat/app/models/direct_message_user.rb rename to plugins/chat/app/models/chat/direct_message_user.rb index f8cfc6664ff..aae155f6210 100644 --- a/plugins/chat/app/models/direct_message_user.rb +++ b/plugins/chat/app/models/chat/direct_message_user.rb @@ -1,8 +1,14 @@ # frozen_string_literal: true -class DirectMessageUser < ActiveRecord::Base - belongs_to :direct_message, foreign_key: :direct_message_channel_id - belongs_to :user +module Chat + class DirectMessageUser < ActiveRecord::Base + self.table_name = "direct_message_users" + + belongs_to :direct_message, + class_name: "Chat::DirectMessage", + foreign_key: :direct_message_channel_id + belongs_to :user + end end # == Schema Information diff --git a/plugins/chat/app/models/chat_draft.rb b/plugins/chat/app/models/chat/draft.rb similarity index 52% rename from plugins/chat/app/models/chat_draft.rb rename to plugins/chat/app/models/chat/draft.rb index 7dc1b7feeb0..6b1dc2d59e7 100644 --- a/plugins/chat/app/models/chat_draft.rb +++ b/plugins/chat/app/models/chat/draft.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true -class ChatDraft < ActiveRecord::Base - belongs_to :user - belongs_to :chat_channel +module Chat + class Draft < ActiveRecord::Base + belongs_to :user + belongs_to :chat_channel, class_name: "Chat::Channel" - validate :data_length - def data_length - if self.data && self.data.length > SiteSetting.max_chat_draft_length - self.errors.add(:base, I18n.t("chat.errors.draft_too_long")) + self.table_name = "chat_drafts" + + validate :data_length + def data_length + if self.data && self.data.length > SiteSetting.max_chat_draft_length + self.errors.add(:base, I18n.t("chat.errors.draft_too_long")) + end end end end diff --git a/plugins/chat/app/models/incoming_chat_webhook.rb b/plugins/chat/app/models/chat/incoming_webhook.rb similarity index 61% rename from plugins/chat/app/models/incoming_chat_webhook.rb rename to plugins/chat/app/models/chat/incoming_webhook.rb index e71b539a037..cb76ffebc66 100644 --- a/plugins/chat/app/models/incoming_chat_webhook.rb +++ b/plugins/chat/app/models/chat/incoming_webhook.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true -class IncomingChatWebhook < ActiveRecord::Base - belongs_to :chat_channel - has_many :chat_webhook_events +module Chat + class IncomingWebhook < ActiveRecord::Base + self.table_name = "incoming_chat_webhooks" - before_create { self.key = SecureRandom.hex(12) } + belongs_to :chat_channel, class_name: "Chat::Channel" + has_many :chat_webhook_events, class_name: "Chat::WebhookEvent" - def url - "#{Discourse.base_url}/chat/hooks/#{key}.json" + before_create { self.key = SecureRandom.hex(12) } + + def url + "#{Discourse.base_url}/chat/hooks/#{key}.json" + end end end diff --git a/plugins/chat/app/models/chat_mention.rb b/plugins/chat/app/models/chat/mention.rb similarity index 67% rename from plugins/chat/app/models/chat_mention.rb rename to plugins/chat/app/models/chat/mention.rb index 649303ca0a9..ab3bbee9925 100644 --- a/plugins/chat/app/models/chat_mention.rb +++ b/plugins/chat/app/models/chat/mention.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true -class ChatMention < ActiveRecord::Base - belongs_to :user - belongs_to :chat_message - belongs_to :notification, dependent: :destroy +module Chat + class Mention < ActiveRecord::Base + self.table_name = "chat_mentions" + + belongs_to :user + belongs_to :chat_message, class_name: "Chat::Message" + belongs_to :notification, dependent: :destroy + end end # == Schema Information diff --git a/plugins/chat/app/models/chat/message.rb b/plugins/chat/app/models/chat/message.rb new file mode 100644 index 00000000000..b2ca8cc0c20 --- /dev/null +++ b/plugins/chat/app/models/chat/message.rb @@ -0,0 +1,360 @@ +# frozen_string_literal: true + +module Chat + class Message < ActiveRecord::Base + include Trashable + + self.table_name = "chat_messages" + + attribute :has_oneboxes, default: false + + BAKED_VERSION = 2 + + belongs_to :chat_channel, class_name: "Chat::Channel" + belongs_to :user + belongs_to :in_reply_to, class_name: "Chat::Message" + belongs_to :last_editor, class_name: "User" + belongs_to :thread, class_name: "Chat::Thread" + + has_many :replies, + class_name: "Chat::Message", + foreign_key: "in_reply_to_id", + dependent: :nullify + has_many :revisions, + class_name: "Chat::MessageRevision", + dependent: :destroy, + foreign_key: :chat_message_id + has_many :reactions, + class_name: "Chat::MessageReaction", + dependent: :destroy, + foreign_key: :chat_message_id + has_many :bookmarks, + -> { + unscope(where: :bookmarkable_type).where(bookmarkable_type: Chat::Message.sti_name) + }, + as: :bookmarkable, + dependent: :destroy + has_many :upload_references, + -> { unscope(where: :target_type).where(target_type: Chat::Message.sti_name) }, + dependent: :destroy, + foreign_key: :target_id + has_many :uploads, through: :upload_references, class_name: "::Upload" + + CLASS_MAPPING = { "ChatMessage" => Chat::Message } + + # the model used when loading type column + def self.sti_class_for(name) + CLASS_MAPPING[name] if CLASS_MAPPING.key?(name) + end + # the type column value + def self.sti_name + CLASS_MAPPING.invert.fetch(self) + end + + # the model used when loading chatable_type column + def self.polymorphic_class_for(name) + CLASS_MAPPING[name] if CLASS_MAPPING.key?(name) + end + # the type stored in *_type column of polymorphic associations + def self.polymorphic_name + CLASS_MAPPING.invert.fetch(self) || super + end + + # TODO (martin) Remove this when we drop the ChatUpload table + has_many :chat_uploads, + dependent: :destroy, + class_name: "Chat::Upload", + foreign_key: :chat_message_id + has_one :chat_webhook_event, + dependent: :destroy, + class_name: "Chat::WebhookEvent", + foreign_key: :chat_message_id + has_many :chat_mentions, + dependent: :destroy, + class_name: "Chat::Mention", + foreign_key: :chat_message_id + + scope :in_public_channel, + -> { + joins(:chat_channel).where( + chat_channel: { + chatable_type: Chat::Channel.public_channel_chatable_types, + }, + ) + } + + scope :in_dm_channel, + -> { + joins(:chat_channel).where( + chat_channel: { + chatable_type: Chat::Channel.direct_channel_chatable_types, + }, + ) + } + + scope :created_before, ->(date) { where("chat_messages.created_at < ?", date) } + + before_save { ensure_last_editor_id } + + def validate_message(has_uploads:) + WatchedWordsValidator.new(attributes: [:message]).validate(self) + + if self.new_record? || self.changed.include?("message") + Chat::DuplicateMessageValidator.new(self).validate + end + + if !has_uploads && message_too_short? + self.errors.add( + :base, + I18n.t( + "chat.errors.minimum_length_not_met", + count: SiteSetting.chat_minimum_message_length, + ), + ) + end + + if message_too_long? + self.errors.add( + :base, + I18n.t("chat.errors.message_too_long", count: SiteSetting.chat_maximum_message_length), + ) + end + end + + def attach_uploads(uploads) + return if uploads.blank? || self.new_record? + + now = Time.now + ref_record_attrs = + uploads.map do |upload| + { + upload_id: upload.id, + target_id: self.id, + target_type: self.class.sti_name, + created_at: now, + updated_at: now, + } + end + UploadReference.insert_all!(ref_record_attrs) + end + + def excerpt(max_length: 50) + # just show the URL if the whole message is a URL, because we cannot excerpt oneboxes + return message if UrlHelper.relaxed_parse(message).is_a?(URI) + + # upload-only messages are better represented as the filename + return uploads.first.original_filename if cooked.blank? && uploads.present? + + # this may return blank for some complex things like quotes, that is acceptable + PrettyText.excerpt(message, max_length, { text_entities: true }) + end + + def cooked_for_excerpt + (cooked.blank? && uploads.present?) ? "

#{uploads.first.original_filename}

" : cooked + end + + def push_notification_excerpt + Emoji.gsub_emoji_to_unicode(message).truncate(400) + end + + def to_markdown + upload_markdown = + self + .upload_references + .includes(:upload) + .order(:created_at) + .map(&:to_markdown) + .reject(&:empty?) + + return self.message if upload_markdown.empty? + + return ["#{self.message}\n"].concat(upload_markdown).join("\n") if self.message.present? + + upload_markdown.join("\n") + end + + def cook + ensure_last_editor_id + + self.cooked = self.class.cook(self.message, user_id: self.last_editor_id) + self.cooked_version = BAKED_VERSION + end + + def rebake!(invalidate_oneboxes: false, priority: nil) + ensure_last_editor_id + + previous_cooked = self.cooked + new_cooked = + self.class.cook( + message, + invalidate_oneboxes: invalidate_oneboxes, + user_id: self.last_editor_id, + ) + update_columns(cooked: new_cooked, cooked_version: BAKED_VERSION) + args = { chat_message_id: self.id } + args[:queue] = priority.to_s if priority && priority != :normal + args[:is_dirty] = true if previous_cooked != new_cooked + + Jobs.enqueue(Jobs::Chat::ProcessMessage, args) + end + + def self.uncooked + where("cooked_version <> ? or cooked_version IS NULL", BAKED_VERSION) + end + + MARKDOWN_FEATURES = %w[ + anchor + bbcode-block + bbcode-inline + code + category-hashtag + censored + chat-transcript + discourse-local-dates + emoji + emojiShortcuts + inlineEmoji + html-img + hashtag-autocomplete + mentions + unicodeUsernames + onebox + quotes + spoiler-alert + table + text-post-process + upload-protocol + watched-words + ] + + MARKDOWN_IT_RULES = %w[ + autolink + list + backticks + newline + code + fence + image + table + linkify + link + strikethrough + blockquote + emphasis + ] + + def self.cook(message, opts = {}) + # A rule in our Markdown pipeline may have Guardian checks that require a + # user to be present. The last editing user of the message will be more + # generally up to date than the creating user. For example, we use + # this when cooking #hashtags to determine whether we should render + # the found hashtag based on whether the user can access the channel it + # is referencing. + cooked = + PrettyText.cook( + message, + features_override: + MARKDOWN_FEATURES + DiscoursePluginRegistry.chat_markdown_features.to_a, + markdown_it_rules: MARKDOWN_IT_RULES, + force_quote_link: true, + user_id: opts[:user_id], + hashtag_context: "chat-composer", + ) + + result = + Oneboxer.apply(cooked) do |url| + if opts[:invalidate_oneboxes] + Oneboxer.invalidate(url) + InlineOneboxer.invalidate(url) + end + onebox = Oneboxer.cached_onebox(url) + onebox + end + + cooked = result.to_html if result.changed? + cooked + end + + def full_url + "#{Discourse.base_url}#{url}" + end + + def url + "/chat/c/-/#{self.chat_channel_id}/#{self.id}" + end + + def create_mentions(user_ids) + return if user_ids.empty? + + now = Time.zone.now + mentions = [] + User + .where(id: user_ids) + .find_each do |user| + mentions << { + chat_message_id: self.id, + user_id: user.id, + created_at: now, + updated_at: now, + } + end + + Chat::Mention.insert_all(mentions) + end + + def update_mentions(mentioned_user_ids) + old_mentions = chat_mentions.pluck(:user_id) + updated_mentions = mentioned_user_ids + mentioned_user_ids_to_drop = old_mentions - updated_mentions + mentioned_user_ids_to_add = updated_mentions - old_mentions + + delete_mentions(mentioned_user_ids_to_drop) + create_mentions(mentioned_user_ids_to_add) + end + + private + + def delete_mentions(user_ids) + chat_mentions.where(user_id: user_ids).destroy_all + end + + def message_too_short? + message.length < SiteSetting.chat_minimum_message_length + end + + def message_too_long? + message.length > SiteSetting.chat_maximum_message_length + end + + def ensure_last_editor_id + self.last_editor_id ||= self.user_id + end + end +end + +# == Schema Information +# +# Table name: chat_messages +# +# id :bigint not null, primary key +# chat_channel_id :integer not null +# user_id :integer +# created_at :datetime not null +# updated_at :datetime not null +# deleted_at :datetime +# deleted_by_id :integer +# in_reply_to_id :integer +# message :text +# cooked :text +# cooked_version :integer +# last_editor_id :integer not null +# thread_id :integer +# +# Indexes +# +# idx_chat_messages_by_created_at_not_deleted (created_at) WHERE (deleted_at IS NULL) +# index_chat_messages_on_chat_channel_id_and_created_at (chat_channel_id,created_at) +# index_chat_messages_on_chat_channel_id_and_id (chat_channel_id,id) WHERE (deleted_at IS NULL) +# index_chat_messages_on_last_editor_id (last_editor_id) +# index_chat_messages_on_thread_id (thread_id) +# diff --git a/plugins/chat/app/models/chat_message_reaction.rb b/plugins/chat/app/models/chat/message_reaction.rb similarity index 69% rename from plugins/chat/app/models/chat_message_reaction.rb rename to plugins/chat/app/models/chat/message_reaction.rb index f101b2ec353..3b378dd0481 100644 --- a/plugins/chat/app/models/chat_message_reaction.rb +++ b/plugins/chat/app/models/chat/message_reaction.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true -class ChatMessageReaction < ActiveRecord::Base - belongs_to :chat_message - belongs_to :user +module Chat + class MessageReaction < ActiveRecord::Base + self.table_name = "chat_message_reactions" + + belongs_to :chat_message, class_name: "Chat::Message" + belongs_to :user + end end # == Schema Information diff --git a/plugins/chat/app/models/chat_message_revision.rb b/plugins/chat/app/models/chat/message_revision.rb similarity index 74% rename from plugins/chat/app/models/chat_message_revision.rb rename to plugins/chat/app/models/chat/message_revision.rb index e13cf507e17..3b01ee03339 100644 --- a/plugins/chat/app/models/chat_message_revision.rb +++ b/plugins/chat/app/models/chat/message_revision.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true -class ChatMessageRevision < ActiveRecord::Base - belongs_to :chat_message - belongs_to :user +module Chat + class MessageRevision < ActiveRecord::Base + self.table_name = "chat_message_revisions" + + belongs_to :chat_message, class_name: "Chat::Message" + belongs_to :user + end end # == Schema Information diff --git a/plugins/chat/app/models/chat/reviewable_message.rb b/plugins/chat/app/models/chat/reviewable_message.rb new file mode 100644 index 00000000000..a7a0b4713e1 --- /dev/null +++ b/plugins/chat/app/models/chat/reviewable_message.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +module Chat + class ReviewableMessage < Reviewable + def serializer + Chat::ReviewableMessageSerializer + end + + def self.action_aliases + { + agree_and_keep_hidden: :agree_and_delete, + agree_and_silence: :agree_and_delete, + agree_and_suspend: :agree_and_delete, + delete_and_agree: :agree_and_delete, + } + end + + def self.score_to_silence_user + sensitivity_score(SiteSetting.chat_silence_user_sensitivity, scale: 0.6) + end + + def chat_message + @chat_message ||= (target || Chat::Message.with_deleted.find_by(id: target_id)) + end + + def chat_message_creator + @chat_message_creator ||= chat_message.user + end + + def flagged_by_user_ids + @flagged_by_user_ids ||= reviewable_scores.map(&:user_id) + end + + def post + nil + end + + def build_actions(actions, guardian, args) + return unless pending? + return if chat_message.blank? + + agree = + actions.add_bundle( + "#{id}-agree", + icon: "thumbs-up", + label: "reviewables.actions.agree.title", + ) + + if chat_message.deleted_at? + build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree) + build_action(actions, :agree_and_keep_deleted, icon: "thumbs-up", bundle: agree) + build_action(actions, :disagree_and_restore, icon: "thumbs-down") + else + build_action(actions, :agree_and_delete, icon: "far-eye-slash", bundle: agree) + build_action(actions, :agree_and_keep_message, icon: "thumbs-up", bundle: agree) + build_action(actions, :disagree, icon: "thumbs-down") + end + + if guardian.can_suspend?(chat_message_creator) + build_action( + actions, + :agree_and_suspend, + icon: "ban", + bundle: agree, + client_action: "suspend", + ) + build_action( + actions, + :agree_and_silence, + icon: "microphone-slash", + bundle: agree, + client_action: "silence", + ) + end + + build_action(actions, :ignore, icon: "external-link-alt") + + unless chat_message.deleted_at? + build_action(actions, :delete_and_agree, icon: "far-trash-alt") + end + end + + def perform_agree_and_keep_message(performed_by, args) + agree + end + + def perform_agree_and_restore(performed_by, args) + agree { chat_message.recover! } + end + + def perform_agree_and_delete(performed_by, args) + agree { chat_message.trash!(performed_by) } + end + + def perform_disagree_and_restore(performed_by, args) + disagree { chat_message.recover! } + end + + def perform_disagree(performed_by, args) + disagree + end + + def perform_ignore(performed_by, args) + ignore + end + + def perform_delete_and_ignore(performed_by, args) + ignore { chat_message.trash!(performed_by) } + end + + private + + def agree + yield if block_given? + create_result(:success, :approved) do |result| + result.update_flag_stats = { status: :agreed, user_ids: flagged_by_user_ids } + result.recalculate_score = true + end + end + + def disagree + yield if block_given? + + UserSilencer.unsilence(chat_message_creator) + + create_result(:success, :rejected) do |result| + result.update_flag_stats = { status: :disagreed, user_ids: flagged_by_user_ids } + result.recalculate_score = true + end + end + + def ignore + yield if block_given? + create_result(:success, :ignored) do |result| + result.update_flag_stats = { status: :ignored, user_ids: flagged_by_user_ids } + end + end + + def build_action( + actions, + id, + icon:, + button_class: nil, + bundle: nil, + client_action: nil, + confirm: false + ) + actions.add(id, bundle: bundle) do |action| + prefix = "reviewables.actions.#{id}" + action.icon = icon + action.button_class = button_class + action.label = "chat.#{prefix}.title" + action.description = "chat.#{prefix}.description" + action.client_action = client_action + action.confirm_message = "#{prefix}.confirm" if confirm + end + end + end +end diff --git a/plugins/chat/app/models/chat_thread.rb b/plugins/chat/app/models/chat/thread.rb similarity index 50% rename from plugins/chat/app/models/chat_thread.rb rename to plugins/chat/app/models/chat/thread.rb index c320281728d..25fb68f45b4 100644 --- a/plugins/chat/app/models/chat_thread.rb +++ b/plugins/chat/app/models/chat/thread.rb @@ -1,29 +1,34 @@ # frozen_string_literal: true -class ChatThread < ActiveRecord::Base - EXCERPT_LENGTH = 150 +module Chat + class Thread < ActiveRecord::Base + EXCERPT_LENGTH = 150 - belongs_to :channel, foreign_key: "channel_id", class_name: "ChatChannel" - belongs_to :original_message_user, foreign_key: "original_message_user_id", class_name: "User" - belongs_to :original_message, foreign_key: "original_message_id", class_name: "ChatMessage" + self.table_name = "chat_threads" - has_many :chat_messages, - -> { order("chat_messages.created_at ASC, chat_messages.id ASC") }, - foreign_key: :thread_id, - primary_key: :id + belongs_to :channel, foreign_key: "channel_id", class_name: "Chat::Channel" + belongs_to :original_message_user, foreign_key: "original_message_user_id", class_name: "User" + belongs_to :original_message, foreign_key: "original_message_id", class_name: "Chat::Message" - enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false + has_many :chat_messages, + -> { order("chat_messages.created_at ASC, chat_messages.id ASC") }, + foreign_key: :thread_id, + primary_key: :id, + class_name: "Chat::Message" - def url - "#{channel.url}/t/#{self.id}" - end + enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false - def relative_url - "#{channel.relative_url}/t/#{self.id}" - end + def url + "#{channel.url}/t/#{self.id}" + end - def excerpt - original_message.excerpt(max_length: EXCERPT_LENGTH) + def relative_url + "#{channel.relative_url}/t/#{self.id}" + end + + def excerpt + original_message.excerpt(max_length: EXCERPT_LENGTH) + end end end diff --git a/plugins/chat/app/models/chat_upload.rb b/plugins/chat/app/models/chat/upload.rb similarity index 76% rename from plugins/chat/app/models/chat_upload.rb rename to plugins/chat/app/models/chat/upload.rb index f9d969c40af..ae553d5faee 100644 --- a/plugins/chat/app/models/chat_upload.rb +++ b/plugins/chat/app/models/chat/upload.rb @@ -5,11 +5,15 @@ # # NOTE: Do not use this model anymore, chat messages are linked to uploads via # the UploadReference table now, just like everything else. -class ChatUpload < ActiveRecord::Base - belongs_to :chat_message - belongs_to :upload +module Chat + class Upload < ActiveRecord::Base + self.table_name = "chat_uploads" - deprecate *public_instance_methods(false) + belongs_to :chat_message, class_name: "Chat::Message" + belongs_to :upload + + deprecate *public_instance_methods(false) + end end # == Schema Information diff --git a/plugins/chat/app/models/user_chat_channel_membership.rb b/plugins/chat/app/models/chat/user_chat_channel_membership.rb similarity index 63% rename from plugins/chat/app/models/user_chat_channel_membership.rb rename to plugins/chat/app/models/chat/user_chat_channel_membership.rb index 643dcdb1a6e..c28b2524871 100644 --- a/plugins/chat/app/models/user_chat_channel_membership.rb +++ b/plugins/chat/app/models/chat/user_chat_channel_membership.rb @@ -1,18 +1,22 @@ # frozen_string_literal: true -class UserChatChannelMembership < ActiveRecord::Base - NOTIFICATION_LEVELS = { never: 0, mention: 1, always: 2 } +module Chat + class UserChatChannelMembership < ActiveRecord::Base + self.table_name = "user_chat_channel_memberships" - belongs_to :user - belongs_to :chat_channel - belongs_to :last_read_message, class_name: "ChatMessage", optional: true + NOTIFICATION_LEVELS = { never: 0, mention: 1, always: 2 } - enum :desktop_notification_level, NOTIFICATION_LEVELS, prefix: :desktop_notifications - enum :mobile_notification_level, NOTIFICATION_LEVELS, prefix: :mobile_notifications - enum :join_mode, { manual: 0, automatic: 1 } + belongs_to :user + belongs_to :last_read_message, class_name: "Chat::Message", optional: true + belongs_to :chat_channel, class_name: "Chat::Channel", foreign_key: :chat_channel_id - attribute :unread_count, default: 0 - attribute :unread_mentions, default: 0 + enum :desktop_notification_level, NOTIFICATION_LEVELS, prefix: :desktop_notifications + enum :mobile_notification_level, NOTIFICATION_LEVELS, prefix: :mobile_notifications + enum :join_mode, { manual: 0, automatic: 1 } + + attribute :unread_count, default: 0 + attribute :unread_mentions, default: 0 + end end # == Schema Information diff --git a/plugins/chat/app/models/chat/view.rb b/plugins/chat/app/models/chat/view.rb new file mode 100644 index 00000000000..fbb83c5c1f0 --- /dev/null +++ b/plugins/chat/app/models/chat/view.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Chat + class View + attr_reader :user, :chat_channel, :chat_messages, :can_load_more_past, :can_load_more_future + + def initialize( + chat_channel:, + chat_messages:, + user:, + can_load_more_past: nil, + can_load_more_future: nil + ) + @chat_channel = chat_channel + @chat_messages = chat_messages + @user = user + @can_load_more_past = can_load_more_past + @can_load_more_future = can_load_more_future + end + + def reviewable_ids + return @reviewable_ids if defined?(@reviewable_ids) + + @reviewable_ids = @user.staff? ? get_reviewable_ids : nil + end + + def user_flag_statuses + return @user_flag_statuses if defined?(@user_flag_statuses) + + @user_flag_statuses = get_user_flag_statuses + end + + private + + def get_reviewable_ids + sql = <<~SQL + SELECT + target_id, + MAX(r.id) reviewable_id + FROM + reviewables r + JOIN + reviewable_scores s ON reviewable_id = r.id + WHERE + r.target_id IN (:message_ids) AND + r.target_type = :target_type AND + s.status = :pending + GROUP BY + target_id + SQL + + ids = {} + + DB + .query( + sql, + pending: ReviewableScore.statuses[:pending], + message_ids: @chat_messages.map(&:id), + target_type: Chat::Message.sti_name, + ) + .each { |row| ids[row.target_id] = row.reviewable_id } + + ids + end + + def get_user_flag_statuses + sql = <<~SQL + SELECT + target_id, + s.status + FROM + reviewables r + JOIN + reviewable_scores s ON reviewable_id = r.id + WHERE + s.user_id = :user_id AND + r.target_id IN (:message_ids) AND + r.target_type = :target_type + SQL + + statuses = {} + + DB + .query( + sql, + message_ids: @chat_messages.map(&:id), + user_id: @user.id, + target_type: Chat::Message.sti_name, + ) + .each { |row| statuses[row.target_id] = row.status } + + statuses + end + end +end diff --git a/plugins/chat/app/models/chat_webhook_event.rb b/plugins/chat/app/models/chat/webhook_event.rb similarity index 58% rename from plugins/chat/app/models/chat_webhook_event.rb rename to plugins/chat/app/models/chat/webhook_event.rb index acda4ffd9b0..fe2aecb2bcf 100644 --- a/plugins/chat/app/models/chat_webhook_event.rb +++ b/plugins/chat/app/models/chat/webhook_event.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true -class ChatWebhookEvent < ActiveRecord::Base - belongs_to :chat_message - belongs_to :incoming_chat_webhook +module Chat + class WebhookEvent < ActiveRecord::Base + self.table_name = "chat_webhook_events" - delegate :username, to: :incoming_chat_webhook - delegate :emoji, to: :incoming_chat_webhook + belongs_to :chat_message, class_name: "Chat::Message" + belongs_to :incoming_chat_webhook, class_name: "Chat::IncomingWebhook" + + delegate :username, to: :incoming_chat_webhook + delegate :emoji, to: :incoming_chat_webhook + end end # == Schema Information diff --git a/plugins/chat/app/models/chat_channel.rb b/plugins/chat/app/models/chat_channel.rb deleted file mode 100644 index 35f427a2049..00000000000 --- a/plugins/chat/app/models/chat_channel.rb +++ /dev/null @@ -1,176 +0,0 @@ -# frozen_string_literal: true - -class ChatChannel < ActiveRecord::Base - include Trashable - - belongs_to :chatable, polymorphic: true - belongs_to :direct_message, - -> { where(chat_channels: { chatable_type: "DirectMessage" }) }, - foreign_key: "chatable_id" - - has_many :chat_messages - has_many :user_chat_channel_memberships - - has_one :chat_channel_archive - - enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false - - validates :name, - length: { - maximum: Proc.new { SiteSetting.max_topic_title_length }, - }, - presence: true, - allow_nil: true - validate :ensure_slug_ok, if: :slug_changed? - before_validation :generate_auto_slug - - scope :public_channels, - -> { - where(chatable_type: public_channel_chatable_types).where( - "categories.id IS NOT NULL", - ).joins( - "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", - ) - } - - delegate :empty?, to: :chat_messages, prefix: true - - class << self - def editable_statuses - statuses.filter { |k, _| !%w[read_only archived].include?(k) } - end - - def public_channel_chatable_types - ["Category"] - end - - def chatable_types - public_channel_chatable_types << "DirectMessage" - end - end - - statuses.keys.each do |status| - define_method("#{status}!") { |acting_user| change_status(acting_user, status.to_sym) } - end - - %i[ - category_channel? - direct_message_channel? - public_channel? - chatable_has_custom_fields? - read_restricted? - ].each { |name| define_method(name) { false } } - - %i[allowed_user_ids allowed_group_ids chatable_url].each { |name| define_method(name) { nil } } - - def membership_for(user) - user_chat_channel_memberships.find_by(user: user) - end - - def add(user) - Chat::ChatChannelMembershipManager.new(self).follow(user) - end - - def remove(user) - Chat::ChatChannelMembershipManager.new(self).unfollow(user) - end - - def url - "#{Discourse.base_url}/chat/c/#{self.slug || "-"}/#{self.id}" - end - - def relative_url - "#{Discourse.base_path}/chat/c/#{self.slug || "-"}/#{self.id}" - end - - def self.ensure_consistency! - update_counts - end - - # TODO (martin) Move UpdateUserCountsForChatChannels into here - def self.update_counts - # NOTE: ChatChannel#messages_count is not updated every time - # a message is created or deleted in a channel, so it should not - # be displayed in the UI. It is updated eventually via Jobs::ChatPeriodicalUpdates - DB.exec <<~SQL - UPDATE chat_channels channels - SET messages_count = subquery.messages_count - FROM ( - SELECT COUNT(*) AS messages_count, chat_channel_id - FROM chat_messages - WHERE chat_messages.deleted_at IS NULL - GROUP BY chat_channel_id - ) subquery - WHERE channels.id = subquery.chat_channel_id - AND channels.deleted_at IS NULL - AND subquery.messages_count != channels.messages_count - SQL - end - - private - - def change_status(acting_user, target_status) - return if !Guardian.new(acting_user).can_change_channel_status?(self, target_status) - self.update!(status: target_status) - log_channel_status_change(acting_user: acting_user) - end - - def log_channel_status_change(acting_user:) - DiscourseEvent.trigger( - :chat_channel_status_change, - channel: self, - old_status: status_previously_was, - new_status: status, - ) - - StaffActionLogger.new(acting_user).log_custom( - "chat_channel_status_change", - { - chat_channel_id: self.id, - chat_channel_name: self.name, - previous_value: status_previously_was, - new_value: status, - }, - ) - - ChatPublisher.publish_channel_status(self) - end - - def duplicate_slug? - ChatChannel.where(slug: self.slug).where.not(id: self.id).any? - end -end - -# == Schema Information -# -# Table name: chat_channels -# -# id :bigint not null, primary key -# chatable_id :integer not null -# deleted_at :datetime -# deleted_by_id :integer -# featured_in_category_id :integer -# delete_after_seconds :integer -# chatable_type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# name :string -# description :text -# status :integer default("open"), not null -# user_count :integer default(0), not null -# last_message_sent_at :datetime not null -# auto_join_users :boolean default(FALSE), not null -# allow_channel_wide_mentions :boolean default(TRUE), not null -# user_count_stale :boolean default(FALSE), not null -# slug :string -# type :string -# threading_enabled :boolean default(FALSE), not null -# -# Indexes -# -# index_chat_channels_on_messages_count (messages_count) -# index_chat_channels_on_chatable_id (chatable_id) -# index_chat_channels_on_chatable_id_and_chatable_type (chatable_id,chatable_type) -# index_chat_channels_on_slug (slug) UNIQUE -# index_chat_channels_on_status (status) -# diff --git a/plugins/chat/app/models/chat_message.rb b/plugins/chat/app/models/chat_message.rb deleted file mode 100644 index be14d09e496..00000000000 --- a/plugins/chat/app/models/chat_message.rb +++ /dev/null @@ -1,297 +0,0 @@ -# frozen_string_literal: true - -class ChatMessage < ActiveRecord::Base - include Trashable - attribute :has_oneboxes, default: false - - BAKED_VERSION = 2 - - belongs_to :chat_channel - belongs_to :user - belongs_to :in_reply_to, class_name: "ChatMessage" - belongs_to :last_editor, class_name: "User" - belongs_to :thread, class_name: "ChatThread" - - has_many :replies, class_name: "ChatMessage", foreign_key: "in_reply_to_id", dependent: :nullify - has_many :revisions, class_name: "ChatMessageRevision", dependent: :destroy - has_many :reactions, class_name: "ChatMessageReaction", dependent: :destroy - has_many :bookmarks, as: :bookmarkable, dependent: :destroy - has_many :upload_references, as: :target, dependent: :destroy - has_many :uploads, through: :upload_references - - # TODO (martin) Remove this when we drop the ChatUpload table - has_many :chat_uploads, dependent: :destroy - has_one :chat_webhook_event, dependent: :destroy - has_many :chat_mentions, dependent: :destroy - - scope :in_public_channel, - -> { - joins(:chat_channel).where( - chat_channel: { - chatable_type: ChatChannel.public_channel_chatable_types, - }, - ) - } - - scope :in_dm_channel, - -> { joins(:chat_channel).where(chat_channel: { chatable_type: "DirectMessage" }) } - - scope :created_before, ->(date) { where("chat_messages.created_at < ?", date) } - - before_save { ensure_last_editor_id } - - def validate_message(has_uploads:) - WatchedWordsValidator.new(attributes: [:message]).validate(self) - - if self.new_record? || self.changed.include?("message") - Chat::DuplicateMessageValidator.new(self).validate - end - - if !has_uploads && message_too_short? - self.errors.add( - :base, - I18n.t( - "chat.errors.minimum_length_not_met", - count: SiteSetting.chat_minimum_message_length, - ), - ) - end - - if message_too_long? - self.errors.add( - :base, - I18n.t("chat.errors.message_too_long", count: SiteSetting.chat_maximum_message_length), - ) - end - end - - def attach_uploads(uploads) - return if uploads.blank? || self.new_record? - - now = Time.now - ref_record_attrs = - uploads.map do |upload| - { - upload_id: upload.id, - target_id: self.id, - target_type: "ChatMessage", - created_at: now, - updated_at: now, - } - end - UploadReference.insert_all!(ref_record_attrs) - end - - def excerpt(max_length: 50) - # just show the URL if the whole message is a URL, because we cannot excerpt oneboxes - return message if UrlHelper.relaxed_parse(message).is_a?(URI) - - # upload-only messages are better represented as the filename - return uploads.first.original_filename if cooked.blank? && uploads.present? - - # this may return blank for some complex things like quotes, that is acceptable - PrettyText.excerpt(message, max_length, { text_entities: true }) - end - - def cooked_for_excerpt - (cooked.blank? && uploads.present?) ? "

#{uploads.first.original_filename}

" : cooked - end - - def push_notification_excerpt - Emoji.gsub_emoji_to_unicode(message).truncate(400) - end - - def to_markdown - upload_markdown = - self - .upload_references - .includes(:upload) - .order(:created_at) - .map(&:to_markdown) - .reject(&:empty?) - - return self.message if upload_markdown.empty? - - return ["#{self.message}\n"].concat(upload_markdown).join("\n") if self.message.present? - - upload_markdown.join("\n") - end - - def cook - ensure_last_editor_id - - self.cooked = self.class.cook(self.message, user_id: self.last_editor_id) - self.cooked_version = BAKED_VERSION - end - - def rebake!(invalidate_oneboxes: false, priority: nil) - ensure_last_editor_id - - previous_cooked = self.cooked - new_cooked = - self.class.cook( - message, - invalidate_oneboxes: invalidate_oneboxes, - user_id: self.last_editor_id, - ) - update_columns(cooked: new_cooked, cooked_version: BAKED_VERSION) - args = { chat_message_id: self.id } - args[:queue] = priority.to_s if priority && priority != :normal - args[:is_dirty] = true if previous_cooked != new_cooked - - Jobs.enqueue(:process_chat_message, args) - end - - def self.uncooked - where("cooked_version <> ? or cooked_version IS NULL", BAKED_VERSION) - end - - MARKDOWN_FEATURES = %w[ - anchor - bbcode-block - bbcode-inline - code - category-hashtag - censored - chat-transcript - discourse-local-dates - emoji - emojiShortcuts - inlineEmoji - html-img - hashtag-autocomplete - mentions - unicodeUsernames - onebox - quotes - spoiler-alert - table - text-post-process - upload-protocol - watched-words - ] - - MARKDOWN_IT_RULES = %w[ - autolink - list - backticks - newline - code - fence - image - table - linkify - link - strikethrough - blockquote - emphasis - ] - - def self.cook(message, opts = {}) - # A rule in our Markdown pipeline may have Guardian checks that require a - # user to be present. The last editing user of the message will be more - # generally up to date than the creating user. For example, we use - # this when cooking #hashtags to determine whether we should render - # the found hashtag based on whether the user can access the channel it - # is referencing. - cooked = - PrettyText.cook( - message, - features_override: MARKDOWN_FEATURES + DiscoursePluginRegistry.chat_markdown_features.to_a, - markdown_it_rules: MARKDOWN_IT_RULES, - force_quote_link: true, - user_id: opts[:user_id], - hashtag_context: "chat-composer", - ) - - result = - Oneboxer.apply(cooked) do |url| - if opts[:invalidate_oneboxes] - Oneboxer.invalidate(url) - InlineOneboxer.invalidate(url) - end - onebox = Oneboxer.cached_onebox(url) - onebox - end - - cooked = result.to_html if result.changed? - cooked - end - - def full_url - "#{Discourse.base_url}#{url}" - end - - def url - "/chat/c/-/#{self.chat_channel_id}/#{self.id}" - end - - def create_mentions(user_ids) - return if user_ids.empty? - - now = Time.zone.now - mentions = [] - User - .where(id: user_ids) - .find_each do |user| - mentions << { chat_message_id: self.id, user_id: user.id, created_at: now, updated_at: now } - end - - ChatMention.insert_all(mentions) - end - - def update_mentions(mentioned_user_ids) - old_mentions = chat_mentions.pluck(:user_id) - updated_mentions = mentioned_user_ids - mentioned_user_ids_to_drop = old_mentions - updated_mentions - mentioned_user_ids_to_add = updated_mentions - old_mentions - - delete_mentions(mentioned_user_ids_to_drop) - create_mentions(mentioned_user_ids_to_add) - end - - private - - def delete_mentions(user_ids) - chat_mentions.where(user_id: user_ids).destroy_all - end - - def message_too_short? - message.length < SiteSetting.chat_minimum_message_length - end - - def message_too_long? - message.length > SiteSetting.chat_maximum_message_length - end - - def ensure_last_editor_id - self.last_editor_id ||= self.user_id - end -end - -# == Schema Information -# -# Table name: chat_messages -# -# id :bigint not null, primary key -# chat_channel_id :integer not null -# user_id :integer -# created_at :datetime not null -# updated_at :datetime not null -# deleted_at :datetime -# deleted_by_id :integer -# in_reply_to_id :integer -# message :text -# cooked :text -# cooked_version :integer -# last_editor_id :integer not null -# thread_id :integer -# -# Indexes -# -# idx_chat_messages_by_created_at_not_deleted (created_at) WHERE (deleted_at IS NULL) -# index_chat_messages_on_chat_channel_id_and_created_at (chat_channel_id,created_at) -# index_chat_messages_on_chat_channel_id_and_id (chat_channel_id,id) WHERE (deleted_at IS NULL) -# index_chat_messages_on_last_editor_id (last_editor_id) -# index_chat_messages_on_thread_id (thread_id) -# diff --git a/plugins/chat/app/models/chat_view.rb b/plugins/chat/app/models/chat_view.rb deleted file mode 100644 index 9df0df18ddf..00000000000 --- a/plugins/chat/app/models/chat_view.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true - -class ChatView - attr_reader :user, :chat_channel, :chat_messages, :can_load_more_past, :can_load_more_future - - def initialize( - chat_channel:, - chat_messages:, - user:, - can_load_more_past: nil, - can_load_more_future: nil - ) - @chat_channel = chat_channel - @chat_messages = chat_messages - @user = user - @can_load_more_past = can_load_more_past - @can_load_more_future = can_load_more_future - end - - def reviewable_ids - return @reviewable_ids if defined?(@reviewable_ids) - - @reviewable_ids = @user.staff? ? get_reviewable_ids : nil - end - - def user_flag_statuses - return @user_flag_statuses if defined?(@user_flag_statuses) - - @user_flag_statuses = get_user_flag_statuses - end - - private - - def get_reviewable_ids - sql = <<~SQL - SELECT - target_id, - MAX(r.id) reviewable_id - FROM - reviewables r - JOIN - reviewable_scores s ON reviewable_id = r.id - WHERE - r.target_id IN (:message_ids) AND - r.target_type = 'ChatMessage' AND - s.status = :pending - GROUP BY - target_id - SQL - - ids = {} - - DB - .query( - sql, - pending: ReviewableScore.statuses[:pending], - message_ids: @chat_messages.map(&:id), - ) - .each { |row| ids[row.target_id] = row.reviewable_id } - - ids - end - - def get_user_flag_statuses - sql = <<~SQL - SELECT - target_id, - s.status - FROM - reviewables r - JOIN - reviewable_scores s ON reviewable_id = r.id - WHERE - s.user_id = :user_id AND - r.target_id IN (:message_ids) AND - r.target_type = 'ChatMessage' - SQL - - statuses = {} - - DB - .query(sql, message_ids: @chat_messages.map(&:id), user_id: @user.id) - .each { |row| statuses[row.target_id] = row.status } - - statuses - end -end diff --git a/plugins/chat/app/models/concerns/chat/chatable.rb b/plugins/chat/app/models/concerns/chat/chatable.rb new file mode 100644 index 00000000000..8e1ced9e1f2 --- /dev/null +++ b/plugins/chat/app/models/concerns/chat/chatable.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Chat + module Chatable + extend ActiveSupport::Concern + + STI_CLASS_MAPPING = { + "CategoryChannel" => Chat::CategoryChannel, + "DirectMessageChannel" => Chat::DirectMessageChannel, + } + + # the model used when loading type column + def self.sti_class_for(name) + STI_CLASS_MAPPING[name] if STI_CLASS_MAPPING.key?(name) + end + + # the type column value + def self.sti_name_for(klass) + STI_CLASS_MAPPING.invert.fetch(klass) + end + + POLYMORPHIC_CLASS_MAPPING = { "DirectMessage" => Chat::DirectMessage } + + # the model used when loading chatable_type column + def self.polymorphic_class_for(name) + POLYMORPHIC_CLASS_MAPPING[name] if POLYMORPHIC_CLASS_MAPPING.key?(name) + end + + # the chatable_type column value + def self.polymorphic_name_for(klass) + POLYMORPHIC_CLASS_MAPPING.invert.fetch(klass) + end + + def chat_channel + channel_class.new(chatable: self) + end + + def create_chat_channel!(**args) + channel_class.create!(args.merge(chatable: self)) + end + + private + + def channel_class + case self + when Chat::DirectMessage + Chat::DirectMessageChannel + when Category + Chat::CategoryChannel + else + raise("Unknown chatable #{self}") + end + end + end +end diff --git a/plugins/chat/app/models/concerns/chatable.rb b/plugins/chat/app/models/concerns/chatable.rb deleted file mode 100644 index 2128a7cf4e4..00000000000 --- a/plugins/chat/app/models/concerns/chatable.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Chatable - extend ActiveSupport::Concern - - def chat_channel - channel_class.new(chatable: self) - end - - def create_chat_channel!(**args) - channel_class.create!(args.merge(chatable: self)) - end - - private - - def channel_class - "#{self.class}Channel".safe_constantize || raise("Unknown chatable #{self}") - end -end diff --git a/plugins/chat/app/models/deleted_chat_user.rb b/plugins/chat/app/models/deleted_chat_user.rb deleted file mode 100644 index 3d6222a4a9b..00000000000 --- a/plugins/chat/app/models/deleted_chat_user.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -class DeletedChatUser < User - def username - I18n.t("chat.deleted_chat_username") - end - - def avatar_template - "/plugins/chat/images/deleted-chat-user-avatar.png" - end - - def bot? - false - end -end diff --git a/plugins/chat/app/models/direct_message.rb b/plugins/chat/app/models/direct_message.rb deleted file mode 100644 index 58427608f15..00000000000 --- a/plugins/chat/app/models/direct_message.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -class DirectMessage < ActiveRecord::Base - self.table_name = "direct_message_channels" - - include Chatable - - has_many :direct_message_users, foreign_key: :direct_message_channel_id - has_many :users, through: :direct_message_users - - def self.for_user_ids(user_ids) - joins(:users) - .group("direct_message_channels.id") - .having("ARRAY[?] = ARRAY_AGG(users.id ORDER BY users.id)", user_ids.sort) - &.first - end - - def user_can_access?(user) - users.include?(user) - end - - def chat_channel_title_for_user(chat_channel, acting_user) - users = - (direct_message_users.map(&:user) - [acting_user]).map { |user| user || DeletedChatUser.new } - - # direct message to self - if users.empty? - return I18n.t("chat.channel.dm_title.single_user", username: "@#{acting_user.username}") - end - - # all users deleted - return chat_channel.id if !users.first - - usernames_formatted = users.sort_by(&:username).map { |u| "@#{u.username}" } - if usernames_formatted.size > 5 - return( - I18n.t( - "chat.channel.dm_title.multi_user_truncated", - comma_separated_usernames: usernames_formatted[0..4].join(I18n.t("word_connector.comma")), - count: usernames_formatted.length - 5, - ) - ) - end - - I18n.t( - "chat.channel.dm_title.multi_user", - comma_separated_usernames: usernames_formatted.join(I18n.t("word_connector.comma")), - ) - end -end - -# == Schema Information -# -# Table name: direct_message_channels -# -# id :bigint not null, primary key -# created_at :datetime not null -# updated_at :datetime not null -# diff --git a/plugins/chat/app/models/direct_message_channel.rb b/plugins/chat/app/models/direct_message_channel.rb deleted file mode 100644 index 9d116643d7e..00000000000 --- a/plugins/chat/app/models/direct_message_channel.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -class DirectMessageChannel < ChatChannel - alias_attribute :direct_message, :chatable - - def direct_message_channel? - true - end - - def allowed_user_ids - direct_message.user_ids - end - - def read_restricted? - true - end - - def title(user) - direct_message.chat_channel_title_for_user(self, user) - end - - def ensure_slug_ok - true - end - - def generate_auto_slug - self.slug = nil - end -end diff --git a/plugins/chat/app/models/reviewable_chat_message.rb b/plugins/chat/app/models/reviewable_chat_message.rb deleted file mode 100644 index 75f03f305c7..00000000000 --- a/plugins/chat/app/models/reviewable_chat_message.rb +++ /dev/null @@ -1,149 +0,0 @@ -# frozen_string_literal: true - -require_dependency "reviewable" - -class ReviewableChatMessage < Reviewable - def self.action_aliases - { - agree_and_keep_hidden: :agree_and_delete, - agree_and_silence: :agree_and_delete, - agree_and_suspend: :agree_and_delete, - delete_and_agree: :agree_and_delete, - } - end - - def self.score_to_silence_user - sensitivity_score(SiteSetting.chat_silence_user_sensitivity, scale: 0.6) - end - - def chat_message - @chat_message ||= (target || ChatMessage.with_deleted.find_by(id: target_id)) - end - - def chat_message_creator - @chat_message_creator ||= chat_message.user - end - - def flagged_by_user_ids - @flagged_by_user_ids ||= reviewable_scores.map(&:user_id) - end - - def post - nil - end - - def build_actions(actions, guardian, args) - return unless pending? - return if chat_message.blank? - - agree = - actions.add_bundle("#{id}-agree", icon: "thumbs-up", label: "reviewables.actions.agree.title") - - if chat_message.deleted_at? - build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree) - build_action(actions, :agree_and_keep_deleted, icon: "thumbs-up", bundle: agree) - build_action(actions, :disagree_and_restore, icon: "thumbs-down") - else - build_action(actions, :agree_and_delete, icon: "far-eye-slash", bundle: agree) - build_action(actions, :agree_and_keep_message, icon: "thumbs-up", bundle: agree) - build_action(actions, :disagree, icon: "thumbs-down") - end - - if guardian.can_suspend?(chat_message_creator) - build_action( - actions, - :agree_and_suspend, - icon: "ban", - bundle: agree, - client_action: "suspend", - ) - build_action( - actions, - :agree_and_silence, - icon: "microphone-slash", - bundle: agree, - client_action: "silence", - ) - end - - build_action(actions, :ignore, icon: "external-link-alt") - - build_action(actions, :delete_and_agree, icon: "far-trash-alt") unless chat_message.deleted_at? - end - - def perform_agree_and_keep_message(performed_by, args) - agree - end - - def perform_agree_and_restore(performed_by, args) - agree { chat_message.recover! } - end - - def perform_agree_and_delete(performed_by, args) - agree { chat_message.trash!(performed_by) } - end - - def perform_disagree_and_restore(performed_by, args) - disagree { chat_message.recover! } - end - - def perform_disagree(performed_by, args) - disagree - end - - def perform_ignore(performed_by, args) - ignore - end - - def perform_delete_and_ignore(performed_by, args) - ignore { chat_message.trash!(performed_by) } - end - - private - - def agree - yield if block_given? - create_result(:success, :approved) do |result| - result.update_flag_stats = { status: :agreed, user_ids: flagged_by_user_ids } - result.recalculate_score = true - end - end - - def disagree - yield if block_given? - - UserSilencer.unsilence(chat_message_creator) - - create_result(:success, :rejected) do |result| - result.update_flag_stats = { status: :disagreed, user_ids: flagged_by_user_ids } - result.recalculate_score = true - end - end - - def ignore - yield if block_given? - create_result(:success, :ignored) do |result| - result.update_flag_stats = { status: :ignored, user_ids: flagged_by_user_ids } - end - end - - def build_action( - actions, - id, - icon:, - button_class: nil, - bundle: nil, - client_action: nil, - confirm: false - ) - actions.add(id, bundle: bundle) do |action| - prefix = "reviewables.actions.#{id}" - action.icon = icon - action.button_class = button_class - action.label = "chat.#{prefix}.title" - action.description = "chat.#{prefix}.description" - action.client_action = client_action - action.confirm_message = "#{prefix}.confirm" if confirm - end - end -end diff --git a/plugins/chat/app/queries/chat/channel_memberships_query.rb b/plugins/chat/app/queries/chat/channel_memberships_query.rb new file mode 100644 index 00000000000..294ca724c89 --- /dev/null +++ b/plugins/chat/app/queries/chat/channel_memberships_query.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Chat + class ChannelMembershipsQuery + def self.call(channel:, limit: 50, offset: 0, username: nil, count_only: false) + query = + Chat::UserChatChannelMembership + .joins(:user) + .includes(:user) + .where(user: User.activated.not_suspended.not_staged) + .where(chat_channel: channel, following: true) + + return query.count if count_only + + if channel.category_channel? && channel.read_restricted? && channel.allowed_group_ids + query = + query.where( + "user_id IN (SELECT user_id FROM group_users WHERE group_id IN (?))", + channel.allowed_group_ids, + ) + end + + if username.present? + if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names + query = query.where("users.username_lower ILIKE ?", "%#{username}%") + else + query = + query.where( + "LOWER(users.name) ILIKE ? OR users.username_lower ILIKE ?", + "%#{username}%", + "%#{username}%", + ) + end + end + + if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names + query = query.order("users.username_lower ASC") + else + query = query.order("users.name ASC, users.username_lower ASC") + end + + query.offset(offset).limit(limit) + end + + def self.count(channel) + call(channel: channel, count_only: true) + end + end +end diff --git a/plugins/chat/app/queries/chat_channel_unreads_query.rb b/plugins/chat/app/queries/chat/channel_unreads_query.rb similarity index 79% rename from plugins/chat/app/queries/chat_channel_unreads_query.rb rename to plugins/chat/app/queries/chat/channel_unreads_query.rb index 0d6e49ba0ea..114570dd415 100644 --- a/plugins/chat/app/queries/chat_channel_unreads_query.rb +++ b/plugins/chat/app/queries/chat/channel_unreads_query.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true -class ChatChannelUnreadsQuery - def self.call(channel_id:, user_id:) - sql = <<~SQL +module Chat + class ChannelUnreadsQuery + def self.call(channel_id:, user_id:) + sql = <<~SQL SELECT ( SELECT COUNT(*) AS unread_count FROM chat_messages @@ -27,14 +28,15 @@ class ChatChannelUnreadsQuery ) AS mention_count; SQL - DB - .query( - sql, - channel_id: channel_id, - user_id: user_id, - notification_type: Notification.types[:chat_mention], - ) - .first - .to_h + DB + .query( + sql, + channel_id: channel_id, + user_id: user_id, + notification_type: Notification.types[:chat_mention], + ) + .first + .to_h + end end end diff --git a/plugins/chat/app/queries/chat_channel_memberships_query.rb b/plugins/chat/app/queries/chat_channel_memberships_query.rb deleted file mode 100644 index e38f09eae1d..00000000000 --- a/plugins/chat/app/queries/chat_channel_memberships_query.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -class ChatChannelMembershipsQuery - def self.call(channel:, limit: 50, offset: 0, username: nil, count_only: false) - query = - UserChatChannelMembership - .joins(:user) - .includes(:user) - .where(user: User.activated.not_suspended.not_staged) - .where(chat_channel: channel, following: true) - - return query.count if count_only - - if channel.category_channel? && channel.read_restricted? && channel.allowed_group_ids - query = - query.where( - "user_id IN (SELECT user_id FROM group_users WHERE group_id IN (?))", - channel.allowed_group_ids, - ) - end - - if username.present? - if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names - query = query.where("users.username_lower ILIKE ?", "%#{username}%") - else - query = - query.where( - "LOWER(users.name) ILIKE ? OR users.username_lower ILIKE ?", - "%#{username}%", - "%#{username}%", - ) - end - end - - if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names - query = query.order("users.username_lower ASC") - else - query = query.order("users.name ASC, users.username_lower ASC") - end - - query.offset(offset).limit(limit) - end - - def self.count(channel) - call(channel: channel, count_only: true) - end -end diff --git a/plugins/chat/app/serializers/admin_chat_index_serializer.rb b/plugins/chat/app/serializers/admin_chat_index_serializer.rb deleted file mode 100644 index c8af0dc2f19..00000000000 --- a/plugins/chat/app/serializers/admin_chat_index_serializer.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -class AdminChatIndexSerializer < ApplicationSerializer - has_many :chat_channels, serializer: ChatChannelSerializer, embed: :objects - has_many :incoming_chat_webhooks, serializer: IncomingChatWebhookSerializer, embed: :objects - - def chat_channels - object[:chat_channels] - end - - def incoming_chat_webhooks - object[:incoming_chat_webhooks] - end -end diff --git a/plugins/chat/app/serializers/base_chat_channel_membership_serializer.rb b/plugins/chat/app/serializers/base_chat_channel_membership_serializer.rb deleted file mode 100644 index 90cb7827eed..00000000000 --- a/plugins/chat/app/serializers/base_chat_channel_membership_serializer.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -class BaseChatChannelMembershipSerializer < ApplicationSerializer - attributes :following, - :muted, - :desktop_notification_level, - :mobile_notification_level, - :chat_channel_id, - :last_read_message_id, - :unread_count, - :unread_mentions -end diff --git a/plugins/chat/app/serializers/chat/admin_chat_index_serializer.rb b/plugins/chat/app/serializers/chat/admin_chat_index_serializer.rb new file mode 100644 index 00000000000..1a36d6cc584 --- /dev/null +++ b/plugins/chat/app/serializers/chat/admin_chat_index_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Chat + class AdminChatIndexSerializer < ApplicationSerializer + has_many :chat_channels, serializer: Chat::ChannelSerializer, embed: :objects + has_many :incoming_chat_webhooks, serializer: Chat::IncomingWebhookSerializer, embed: :objects + + def chat_channels + object[:chat_channels] + end + + def incoming_chat_webhooks + object[:incoming_chat_webhooks] + end + end +end diff --git a/plugins/chat/app/serializers/chat/base_channel_membership_serializer.rb b/plugins/chat/app/serializers/chat/base_channel_membership_serializer.rb new file mode 100644 index 00000000000..1aef99fd33e --- /dev/null +++ b/plugins/chat/app/serializers/chat/base_channel_membership_serializer.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Chat + class BaseChannelMembershipSerializer < ApplicationSerializer + attributes :following, + :muted, + :desktop_notification_level, + :mobile_notification_level, + :chat_channel_id, + :last_read_message_id, + :unread_count, + :unread_mentions + end +end diff --git a/plugins/chat/app/serializers/chat/channel_index_serializer.rb b/plugins/chat/app/serializers/chat/channel_index_serializer.rb new file mode 100644 index 00000000000..31c3eebe286 --- /dev/null +++ b/plugins/chat/app/serializers/chat/channel_index_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Chat + class ChannelIndexSerializer < ::Chat::StructuredChannelSerializer + attributes :global_presence_channel_state + + def global_presence_channel_state + PresenceChannelStateSerializer.new(PresenceChannel.new("/chat/online").state, root: nil) + end + end +end diff --git a/plugins/chat/app/serializers/chat/channel_search_serializer.rb b/plugins/chat/app/serializers/chat/channel_search_serializer.rb new file mode 100644 index 00000000000..196f9f78e29 --- /dev/null +++ b/plugins/chat/app/serializers/chat/channel_search_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Chat + class ChannelSearchSerializer < ::Chat::StructuredChannelSerializer + has_many :users, serializer: BasicUserSerializer, embed: :objects + + def users + object[:users] + end + end +end diff --git a/plugins/chat/app/serializers/chat/channel_serializer.rb b/plugins/chat/app/serializers/chat/channel_serializer.rb new file mode 100644 index 00000000000..7f267a9d60b --- /dev/null +++ b/plugins/chat/app/serializers/chat/channel_serializer.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module Chat + class ChannelSerializer < ApplicationSerializer + attributes :id, + :auto_join_users, + :allow_channel_wide_mentions, + :chatable, + :chatable_id, + :chatable_type, + :chatable_url, + :description, + :title, + :slug, + :last_message_sent_at, + :status, + :archive_failed, + :archive_completed, + :archived_messages, + :total_messages, + :archive_topic_id, + :memberships_count, + :current_user_membership, + :meta, + :threading_enabled + + def threading_enabled + SiteSetting.enable_experimental_chat_threaded_discussions && object.threading_enabled + end + + def initialize(object, opts) + super(object, opts) + + @opts = opts + @current_user_membership = opts[:membership] + end + + def include_description? + object.description.present? + end + + def memberships_count + object.user_count + end + + def chatable_url + object.chatable_url + end + + def title + object.name || object.title(scope.user) + end + + def chatable + case object.chatable_type + when "Category" + BasicCategorySerializer.new(object.chatable, root: false).as_json + when "DirectMessage" + Chat::DirectMessageSerializer.new(object.chatable, scope: scope, root: false).as_json + when "Site" + nil + end + end + + def archive + object.chat_channel_archive + end + + def include_archive_status? + !object.direct_message_channel? && scope.is_staff? && archive.present? + end + + def archive_completed + archive.complete? + end + + def archive_failed + archive.failed? + end + + def archived_messages + archive.archived_messages + end + + def total_messages + archive.total_messages + end + + def archive_topic_id + archive.destination_topic_id + end + + def include_auto_join_users? + scope.can_edit_chat_channel? + end + + def include_current_user_membership? + @current_user_membership.present? + end + + def current_user_membership + @current_user_membership.chat_channel = object + + Chat::BaseChannelMembershipSerializer.new( + @current_user_membership, + scope: scope, + root: false, + ).as_json + end + + def meta + { + message_bus_last_ids: { + channel_message_bus_last_id: MessageBus.last_id("/chat/#{object.id}"), + new_messages: + @opts[:new_messages_message_bus_last_id] || + MessageBus.last_id(Chat::Publisher.new_messages_message_bus_channel(object.id)), + new_mentions: + @opts[:new_mentions_message_bus_last_id] || + MessageBus.last_id(Chat::Publisher.new_mentions_message_bus_channel(object.id)), + }, + } + end + + alias_method :include_archive_topic_id?, :include_archive_status? + alias_method :include_total_messages?, :include_archive_status? + alias_method :include_archived_messages?, :include_archive_status? + alias_method :include_archive_failed?, :include_archive_status? + alias_method :include_archive_completed?, :include_archive_status? + end +end diff --git a/plugins/chat/app/serializers/chat/direct_message_serializer.rb b/plugins/chat/app/serializers/chat/direct_message_serializer.rb new file mode 100644 index 00000000000..6cc97f22c8a --- /dev/null +++ b/plugins/chat/app/serializers/chat/direct_message_serializer.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Chat + class DirectMessageSerializer < ApplicationSerializer + has_many :users, serializer: Chat::UserWithCustomFieldsAndStatusSerializer, embed: :objects + + def users + users = object.direct_message_users.map(&:user).map { |u| u || Chat::DeletedUser.new } + + return users - [scope.user] if users.count > 1 + users + end + end +end diff --git a/plugins/chat/app/serializers/chat/in_reply_to_serializer.rb b/plugins/chat/app/serializers/chat/in_reply_to_serializer.rb new file mode 100644 index 00000000000..f538c078593 --- /dev/null +++ b/plugins/chat/app/serializers/chat/in_reply_to_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Chat + class InReplyToSerializer < ApplicationSerializer + has_one :user, serializer: BasicUserSerializer, embed: :objects + has_one :chat_webhook_event, serializer: Chat::WebhookEventSerializer, embed: :objects + + attributes :id, :cooked, :excerpt + + def excerpt + WordWatcher.censor(object.excerpt) + end + + def user + object.user || Chat::DeletedUser.new + end + end +end diff --git a/plugins/chat/app/serializers/chat/incoming_webhook_serializer.rb b/plugins/chat/app/serializers/chat/incoming_webhook_serializer.rb new file mode 100644 index 00000000000..65518d077a8 --- /dev/null +++ b/plugins/chat/app/serializers/chat/incoming_webhook_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Chat + class IncomingWebhookSerializer < ApplicationSerializer + has_one :chat_channel, serializer: Chat::ChannelSerializer, embed: :objects + + attributes :id, :name, :description, :emoji, :url, :username, :updated_at + end +end diff --git a/plugins/chat/app/serializers/chat/message_serializer.rb b/plugins/chat/app/serializers/chat/message_serializer.rb new file mode 100644 index 00000000000..b6454b96c77 --- /dev/null +++ b/plugins/chat/app/serializers/chat/message_serializer.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +module Chat + class MessageSerializer < ::ApplicationSerializer + attributes :id, + :message, + :cooked, + :created_at, + :excerpt, + :deleted_at, + :deleted_by_id, + :reviewable_id, + :user_flag_status, + :edited, + :reactions, + :bookmark, + :available_flags, + :thread_id, + :chat_channel_id + + has_one :user, serializer: Chat::MessageUserSerializer, embed: :objects + has_one :chat_webhook_event, serializer: Chat::WebhookEventSerializer, embed: :objects + has_one :in_reply_to, serializer: Chat::InReplyToSerializer, embed: :objects + has_many :uploads, serializer: ::UploadSerializer, embed: :objects + + def channel + @channel ||= @options.dig(:chat_channel) || object.chat_channel + end + + def user + object.user || Chat::DeletedUser.new + end + + def excerpt + WordWatcher.censor(object.excerpt) + end + + def reactions + object + .reactions + .group_by(&:emoji) + .map do |emoji, reactions| + next unless Emoji.exists?(emoji) + + users = reactions.take(5).map(&:user) + + { + emoji: emoji, + count: reactions.count, + users: + ActiveModel::ArraySerializer.new(users, each_serializer: BasicUserSerializer).as_json, + reacted: users_reactions.include?(emoji), + } + end + .compact + end + + def include_reactions? + object.reactions.any? + end + + def users_reactions + @users_reactions ||= + object.reactions.select { |reaction| reaction.user_id == scope&.user&.id }.map(&:emoji) + end + + def users_bookmark + @user_bookmark ||= object.bookmarks.find { |bookmark| bookmark.user_id == scope&.user&.id } + end + + def include_bookmark? + users_bookmark.present? + end + + def bookmark + { + id: users_bookmark.id, + reminder_at: users_bookmark.reminder_at, + name: users_bookmark.name, + auto_delete_preference: users_bookmark.auto_delete_preference, + bookmarkable_id: users_bookmark.bookmarkable_id, + bookmarkable_type: users_bookmark.bookmarkable_type, + } + end + + def edited + true + end + + def include_edited? + object.revisions.any? + end + + def deleted_at + object.user ? object.deleted_at : Time.zone.now + end + + def deleted_by_id + object.user ? object.deleted_by_id : Discourse.system_user.id + end + + def include_deleted_at? + object.user ? !object.deleted_at.nil? : true + end + + def include_deleted_by_id? + object.user ? !object.deleted_at.nil? : true + end + + def include_in_reply_to? + object.in_reply_to_id.presence + end + + def reviewable_id + return @reviewable_id if defined?(@reviewable_id) + return @reviewable_id = nil unless @options && @options[:reviewable_ids] + + @reviewable_id = @options[:reviewable_ids][object.id] + end + + def include_reviewable_id? + reviewable_id.present? + end + + def user_flag_status + return @user_flag_status if defined?(@user_flag_status) + return @user_flag_status = nil unless @options&.dig(:user_flag_statuses) + + @user_flag_status = @options[:user_flag_statuses][object.id] + end + + def include_user_flag_status? + user_flag_status.present? + end + + def available_flags + return [] if !scope.can_flag_chat_message?(object) + return [] if reviewable_id.present? && user_flag_status == ReviewableScore.statuses[:pending] + + PostActionType.flag_types.map do |sym, id| + next if channel.direct_message_channel? && %i[notify_moderators notify_user].include?(sym) + + if sym == :notify_user && + ( + scope.current_user == user || user.bot? || + !scope.current_user.in_any_groups?(SiteSetting.personal_message_enabled_groups_map) + ) + next + end + + sym + end + end + end +end diff --git a/plugins/chat/app/serializers/chat/message_user_serializer.rb b/plugins/chat/app/serializers/chat/message_user_serializer.rb new file mode 100644 index 00000000000..92d222a6782 --- /dev/null +++ b/plugins/chat/app/serializers/chat/message_user_serializer.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Chat + class MessageUserSerializer < BasicUserWithStatusSerializer + attributes :moderator?, :admin?, :staff?, :moderator?, :new_user?, :primary_group_name + + def moderator? + !!(object&.moderator?) + end + + def admin? + !!(object&.admin?) + end + + def staff? + !!(object&.staff?) + end + + def new_user? + object.trust_level == TrustLevel[0] + end + + def primary_group_name + return nil unless object && object.primary_group_id + object.primary_group.name if object.primary_group + end + end +end diff --git a/plugins/chat/app/serializers/chat/reviewable_message_serializer.rb b/plugins/chat/app/serializers/chat/reviewable_message_serializer.rb new file mode 100644 index 00000000000..41f74e31c81 --- /dev/null +++ b/plugins/chat/app/serializers/chat/reviewable_message_serializer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_dependency "reviewable_serializer" + +module Chat + class ReviewableMessageSerializer < ReviewableSerializer + target_attributes :cooked + payload_attributes :transcript_topic_id, :message_cooked + attributes :target_id + + has_one :chat_channel, serializer: Chat::ChannelSerializer, root: false, embed: :objects + + def chat_channel + object.chat_message.chat_channel + end + + def target_id + object.target&.id + end + end +end diff --git a/plugins/chat/app/serializers/chat/structured_channel_serializer.rb b/plugins/chat/app/serializers/chat/structured_channel_serializer.rb new file mode 100644 index 00000000000..aca74386956 --- /dev/null +++ b/plugins/chat/app/serializers/chat/structured_channel_serializer.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Chat + class StructuredChannelSerializer < ApplicationSerializer + attributes :public_channels, :direct_message_channels, :meta + + def public_channels + object[:public_channels].map do |channel| + Chat::ChannelSerializer.new( + channel, + root: nil, + scope: scope, + membership: channel_membership(channel.id), + new_messages_message_bus_last_id: + chat_message_bus_last_ids[Chat::Publisher.new_messages_message_bus_channel(channel.id)], + new_mentions_message_bus_last_id: + chat_message_bus_last_ids[Chat::Publisher.new_mentions_message_bus_channel(channel.id)], + ) + end + end + + def direct_message_channels + object[:direct_message_channels].map do |channel| + Chat::ChannelSerializer.new( + channel, + root: nil, + scope: scope, + membership: channel_membership(channel.id), + new_messages_message_bus_last_id: + chat_message_bus_last_ids[Chat::Publisher.new_messages_message_bus_channel(channel.id)], + new_mentions_message_bus_last_id: + chat_message_bus_last_ids[Chat::Publisher.new_mentions_message_bus_channel(channel.id)], + ) + end + end + + def channel_membership(channel_id) + return if scope.anonymous? + object[:memberships].find { |membership| membership.chat_channel_id == channel_id } + end + + def meta + last_ids = { + channel_metadata: + chat_message_bus_last_ids[Chat::Publisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL], + channel_edits: + chat_message_bus_last_ids[Chat::Publisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL], + channel_status: + chat_message_bus_last_ids[Chat::Publisher::CHANNEL_STATUS_MESSAGE_BUS_CHANNEL], + new_channel: chat_message_bus_last_ids[Chat::Publisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL], + archive_status: + chat_message_bus_last_ids[Chat::Publisher::CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL], + } + + if id = + chat_message_bus_last_ids[ + Chat::Publisher.user_tracking_state_message_bus_channel(scope.user.id) + ] + last_ids[:user_tracking_state] = id + end + + { message_bus_last_ids: last_ids } + end + + private + + def chat_message_bus_last_ids + @chat_message_bus_last_ids ||= + begin + message_bus_channels = [ + Chat::Publisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL, + Chat::Publisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL, + Chat::Publisher::CHANNEL_STATUS_MESSAGE_BUS_CHANNEL, + Chat::Publisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL, + Chat::Publisher::CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL, + ] + + if !scope.anonymous? + message_bus_channels.push( + Chat::Publisher.user_tracking_state_message_bus_channel(scope.user.id), + ) + end + + object[:public_channels].each do |channel| + message_bus_channels.push(Chat::Publisher.new_messages_message_bus_channel(channel.id)) + message_bus_channels.push(Chat::Publisher.new_mentions_message_bus_channel(channel.id)) + end + + object[:direct_message_channels].each do |channel| + message_bus_channels.push(Chat::Publisher.new_messages_message_bus_channel(channel.id)) + message_bus_channels.push(Chat::Publisher.new_mentions_message_bus_channel(channel.id)) + end + + MessageBus.last_ids(*message_bus_channels) + end + end + end +end diff --git a/plugins/chat/app/serializers/chat/thread_original_message_serializer.rb b/plugins/chat/app/serializers/chat/thread_original_message_serializer.rb new file mode 100644 index 00000000000..57efe2c49c6 --- /dev/null +++ b/plugins/chat/app/serializers/chat/thread_original_message_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Chat + class ThreadOriginalMessageSerializer < ApplicationSerializer + attributes :id, :created_at, :excerpt, :thread_id + + has_one :chat_webhook_event, serializer: Chat::WebhookEventSerializer, embed: :objects + + def excerpt + WordWatcher.censor(object.excerpt(max_length: Chat::Thread::EXCERPT_LENGTH)) + end + end +end diff --git a/plugins/chat/app/serializers/chat/thread_serializer.rb b/plugins/chat/app/serializers/chat/thread_serializer.rb new file mode 100644 index 00000000000..0408387c0f2 --- /dev/null +++ b/plugins/chat/app/serializers/chat/thread_serializer.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Chat + class ThreadSerializer < ApplicationSerializer + has_one :original_message_user, serializer: BasicUserWithStatusSerializer, embed: :objects + has_one :original_message, serializer: Chat::ThreadOriginalMessageSerializer, embed: :objects + + attributes :id, :title, :status + end +end diff --git a/plugins/chat/app/serializers/chat/user_channel_membership_serializer.rb b/plugins/chat/app/serializers/chat/user_channel_membership_serializer.rb new file mode 100644 index 00000000000..8dd02a7ffa1 --- /dev/null +++ b/plugins/chat/app/serializers/chat/user_channel_membership_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Chat + class UserChannelMembershipSerializer < BaseChannelMembershipSerializer + has_one :user, serializer: BasicUserSerializer, embed: :objects + + def user + object.user + end + end +end diff --git a/plugins/chat/app/serializers/chat/user_message_bookmark_serializer.rb b/plugins/chat/app/serializers/chat/user_message_bookmark_serializer.rb new file mode 100644 index 00000000000..f1ba24bdc16 --- /dev/null +++ b/plugins/chat/app/serializers/chat/user_message_bookmark_serializer.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Chat + class UserMessageBookmarkSerializer < UserBookmarkBaseSerializer + attr_reader :chat_message + + def title + fancy_title + end + + def fancy_title + @fancy_title ||= chat_message.chat_channel.title(scope.user) + end + + def cooked + chat_message.cooked + end + + def bookmarkable_user + @bookmarkable_user ||= chat_message.user + end + + def bookmarkable_url + chat_message.url + end + + def excerpt + return nil unless cooked + @excerpt ||= PrettyText.excerpt(cooked, 300, keep_emoji_images: true) + end + + private + + def chat_message + object.bookmarkable + end + end +end diff --git a/plugins/chat/app/serializers/chat/user_with_custom_fields_and_status_serializer.rb b/plugins/chat/app/serializers/chat/user_with_custom_fields_and_status_serializer.rb new file mode 100644 index 00000000000..d9589d730b8 --- /dev/null +++ b/plugins/chat/app/serializers/chat/user_with_custom_fields_and_status_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Chat + class UserWithCustomFieldsAndStatusSerializer < ::UserWithCustomFieldsSerializer + attributes :status + + def include_status? + SiteSetting.enable_user_status && user.has_status? + end + + def status + ::UserStatusSerializer.new(user.user_status, root: false) + end + end +end diff --git a/plugins/chat/app/serializers/chat/view_serializer.rb b/plugins/chat/app/serializers/chat/view_serializer.rb new file mode 100644 index 00000000000..3acfffc2162 --- /dev/null +++ b/plugins/chat/app/serializers/chat/view_serializer.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Chat + class ViewSerializer < ApplicationSerializer + attributes :meta, :chat_messages + + def chat_messages + ActiveModel::ArraySerializer.new( + object.chat_messages, + each_serializer: Chat::MessageSerializer, + reviewable_ids: object.reviewable_ids, + user_flag_statuses: object.user_flag_statuses, + chat_channel: object.chat_channel, + scope: scope, + ) + end + + def meta + meta_hash = { + channel_id: object.chat_channel.id, + can_flag: scope.can_flag_in_chat_channel?(object.chat_channel), + channel_status: object.chat_channel.status, + user_silenced: !scope.can_create_chat_message?, + can_moderate: scope.can_moderate_chat?(object.chat_channel.chatable), + can_delete_self: scope.can_delete_own_chats?(object.chat_channel.chatable), + can_delete_others: scope.can_delete_other_chats?(object.chat_channel.chatable), + channel_message_bus_last_id: MessageBus.last_id("/chat/#{object.chat_channel.id}"), + } + meta_hash[ + :can_load_more_past + ] = object.can_load_more_past unless object.can_load_more_past.nil? + meta_hash[ + :can_load_more_future + ] = object.can_load_more_future unless object.can_load_more_future.nil? + meta_hash + end + end +end diff --git a/plugins/chat/app/serializers/chat/webhook_event_serializer.rb b/plugins/chat/app/serializers/chat/webhook_event_serializer.rb new file mode 100644 index 00000000000..309cd06bf16 --- /dev/null +++ b/plugins/chat/app/serializers/chat/webhook_event_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Chat + class WebhookEventSerializer < ApplicationSerializer + attributes :username, :emoji + end +end diff --git a/plugins/chat/app/serializers/chat_channel_index_serializer.rb b/plugins/chat/app/serializers/chat_channel_index_serializer.rb deleted file mode 100644 index 59c555a90f7..00000000000 --- a/plugins/chat/app/serializers/chat_channel_index_serializer.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class ChatChannelIndexSerializer < StructuredChannelSerializer - attributes :global_presence_channel_state - - def global_presence_channel_state - PresenceChannelStateSerializer.new(PresenceChannel.new("/chat/online").state, root: nil) - end -end diff --git a/plugins/chat/app/serializers/chat_channel_search_serializer.rb b/plugins/chat/app/serializers/chat_channel_search_serializer.rb deleted file mode 100644 index cf5bc083cc9..00000000000 --- a/plugins/chat/app/serializers/chat_channel_search_serializer.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class ChatChannelSearchSerializer < StructuredChannelSerializer - has_many :users, serializer: BasicUserSerializer, embed: :objects - - def users - object[:users] - end -end diff --git a/plugins/chat/app/serializers/chat_channel_serializer.rb b/plugins/chat/app/serializers/chat_channel_serializer.rb deleted file mode 100644 index d007ca651a2..00000000000 --- a/plugins/chat/app/serializers/chat_channel_serializer.rb +++ /dev/null @@ -1,129 +0,0 @@ -# frozen_string_literal: true - -class ChatChannelSerializer < ApplicationSerializer - attributes :id, - :auto_join_users, - :allow_channel_wide_mentions, - :chatable, - :chatable_id, - :chatable_type, - :chatable_url, - :description, - :title, - :slug, - :last_message_sent_at, - :status, - :archive_failed, - :archive_completed, - :archived_messages, - :total_messages, - :archive_topic_id, - :memberships_count, - :current_user_membership, - :meta, - :threading_enabled - - def threading_enabled - SiteSetting.enable_experimental_chat_threaded_discussions && object.threading_enabled - end - - def initialize(object, opts) - super(object, opts) - - @opts = opts - @current_user_membership = opts[:membership] - end - - def include_description? - object.description.present? - end - - def memberships_count - object.user_count - end - - def chatable_url - object.chatable_url - end - - def title - object.name || object.title(scope.user) - end - - def chatable - case object.chatable_type - when "Category" - BasicCategorySerializer.new(object.chatable, root: false).as_json - when "DirectMessage" - DirectMessageSerializer.new(object.chatable, scope: scope, root: false).as_json - when "Site" - nil - end - end - - def archive - object.chat_channel_archive - end - - def include_archive_status? - !object.direct_message_channel? && scope.is_staff? && archive.present? - end - - def archive_completed - archive.complete? - end - - def archive_failed - archive.failed? - end - - def archived_messages - archive.archived_messages - end - - def total_messages - archive.total_messages - end - - def archive_topic_id - archive.destination_topic_id - end - - def include_auto_join_users? - scope.can_edit_chat_channel? - end - - def include_current_user_membership? - @current_user_membership.present? - end - - def current_user_membership - @current_user_membership.chat_channel = object - - BaseChatChannelMembershipSerializer.new( - @current_user_membership, - scope: scope, - root: false, - ).as_json - end - - def meta - { - message_bus_last_ids: { - channel_message_bus_last_id: MessageBus.last_id("/chat/#{object.id}"), - new_messages: - @opts[:new_messages_message_bus_last_id] || - MessageBus.last_id(ChatPublisher.new_messages_message_bus_channel(object.id)), - new_mentions: - @opts[:new_mentions_message_bus_last_id] || - MessageBus.last_id(ChatPublisher.new_mentions_message_bus_channel(object.id)), - }, - } - end - - alias_method :include_archive_topic_id?, :include_archive_status? - alias_method :include_total_messages?, :include_archive_status? - alias_method :include_archived_messages?, :include_archive_status? - alias_method :include_archive_failed?, :include_archive_status? - alias_method :include_archive_completed?, :include_archive_status? -end diff --git a/plugins/chat/app/serializers/chat_in_reply_to_serializer.rb b/plugins/chat/app/serializers/chat_in_reply_to_serializer.rb deleted file mode 100644 index 25cb08c8fde..00000000000 --- a/plugins/chat/app/serializers/chat_in_reply_to_serializer.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class ChatInReplyToSerializer < ApplicationSerializer - has_one :user, serializer: BasicUserSerializer, embed: :objects - has_one :chat_webhook_event, serializer: ChatWebhookEventSerializer, embed: :objects - - attributes :id, :cooked, :excerpt - - def excerpt - WordWatcher.censor(object.excerpt) - end - - def user - object.user || DeletedChatUser.new - end -end diff --git a/plugins/chat/app/serializers/chat_message_serializer.rb b/plugins/chat/app/serializers/chat_message_serializer.rb deleted file mode 100644 index 4ff2b7e5ff0..00000000000 --- a/plugins/chat/app/serializers/chat_message_serializer.rb +++ /dev/null @@ -1,153 +0,0 @@ -# frozen_string_literal: true - -class ChatMessageSerializer < ApplicationSerializer - attributes :id, - :message, - :cooked, - :created_at, - :excerpt, - :deleted_at, - :deleted_by_id, - :reviewable_id, - :user_flag_status, - :edited, - :reactions, - :bookmark, - :available_flags, - :thread_id, - :chat_channel_id - - has_one :user, serializer: ChatMessageUserSerializer, embed: :objects - has_one :chat_webhook_event, serializer: ChatWebhookEventSerializer, embed: :objects - has_one :in_reply_to, serializer: ChatInReplyToSerializer, embed: :objects - has_many :uploads, serializer: UploadSerializer, embed: :objects - - def channel - @channel ||= @options.dig(:chat_channel) || object.chat_channel - end - - def user - object.user || DeletedChatUser.new - end - - def excerpt - WordWatcher.censor(object.excerpt) - end - - def reactions - object - .reactions - .group_by(&:emoji) - .map do |emoji, reactions| - next unless Emoji.exists?(emoji) - - users = reactions.take(5).map(&:user) - - { - emoji: emoji, - count: reactions.count, - users: - ActiveModel::ArraySerializer.new(users, each_serializer: BasicUserSerializer).as_json, - reacted: users_reactions.include?(emoji), - } - end - .compact - end - - def include_reactions? - object.reactions.any? - end - - def users_reactions - @users_reactions ||= - object.reactions.select { |reaction| reaction.user_id == scope&.user&.id }.map(&:emoji) - end - - def users_bookmark - @user_bookmark ||= object.bookmarks.find { |bookmark| bookmark.user_id == scope&.user&.id } - end - - def include_bookmark? - users_bookmark.present? - end - - def bookmark - { - id: users_bookmark.id, - reminder_at: users_bookmark.reminder_at, - name: users_bookmark.name, - auto_delete_preference: users_bookmark.auto_delete_preference, - bookmarkable_id: users_bookmark.bookmarkable_id, - bookmarkable_type: users_bookmark.bookmarkable_type, - } - end - - def edited - true - end - - def include_edited? - object.revisions.any? - end - - def deleted_at - object.user ? object.deleted_at : Time.zone.now - end - - def deleted_by_id - object.user ? object.deleted_by_id : Discourse.system_user.id - end - - def include_deleted_at? - object.user ? !object.deleted_at.nil? : true - end - - def include_deleted_by_id? - object.user ? !object.deleted_at.nil? : true - end - - def include_in_reply_to? - object.in_reply_to_id.presence - end - - def reviewable_id - return @reviewable_id if defined?(@reviewable_id) - return @reviewable_id = nil unless @options && @options[:reviewable_ids] - - @reviewable_id = @options[:reviewable_ids][object.id] - end - - def include_reviewable_id? - reviewable_id.present? - end - - def user_flag_status - return @user_flag_status if defined?(@user_flag_status) - return @user_flag_status = nil unless @options&.dig(:user_flag_statuses) - - @user_flag_status = @options[:user_flag_statuses][object.id] - end - - def include_user_flag_status? - user_flag_status.present? - end - - def available_flags - return [] if !scope.can_flag_chat_message?(object) - return [] if reviewable_id.present? && user_flag_status == ReviewableScore.statuses[:pending] - - PostActionType.flag_types.map do |sym, id| - next if channel.direct_message_channel? && %i[notify_moderators notify_user].include?(sym) - - if sym == :notify_user && - ( - scope.current_user == user || user.bot? || - !scope.current_user.in_any_groups?(SiteSetting.personal_message_enabled_groups_map) - ) - next - end - - sym - end - end -end diff --git a/plugins/chat/app/serializers/chat_message_user_serializer.rb b/plugins/chat/app/serializers/chat_message_user_serializer.rb deleted file mode 100644 index 25e3f2518b8..00000000000 --- a/plugins/chat/app/serializers/chat_message_user_serializer.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -class ChatMessageUserSerializer < BasicUserWithStatusSerializer - attributes :moderator?, :admin?, :staff?, :moderator?, :new_user?, :primary_group_name - - def moderator? - !!(object&.moderator?) - end - - def admin? - !!(object&.admin?) - end - - def staff? - !!(object&.staff?) - end - - def new_user? - object.trust_level == TrustLevel[0] - end - - def primary_group_name - return nil unless object && object.primary_group_id - object.primary_group.name if object.primary_group - end -end diff --git a/plugins/chat/app/serializers/chat_thread_original_message_serializer.rb b/plugins/chat/app/serializers/chat_thread_original_message_serializer.rb deleted file mode 100644 index 0cbf498bcc0..00000000000 --- a/plugins/chat/app/serializers/chat_thread_original_message_serializer.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class ChatThreadOriginalMessageSerializer < ApplicationSerializer - attributes :id, :created_at, :excerpt, :thread_id - - has_one :chat_webhook_event, serializer: ChatWebhookEventSerializer, embed: :objects - - def excerpt - WordWatcher.censor(object.excerpt(max_length: ChatThread::EXCERPT_LENGTH)) - end -end diff --git a/plugins/chat/app/serializers/chat_thread_serializer.rb b/plugins/chat/app/serializers/chat_thread_serializer.rb deleted file mode 100644 index 614f5d79dbc..00000000000 --- a/plugins/chat/app/serializers/chat_thread_serializer.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class ChatThreadSerializer < ApplicationSerializer - has_one :original_message_user, serializer: BasicUserWithStatusSerializer, embed: :objects - has_one :original_message, serializer: ChatThreadOriginalMessageSerializer, embed: :objects - - attributes :id, :title, :status -end diff --git a/plugins/chat/app/serializers/chat_view_serializer.rb b/plugins/chat/app/serializers/chat_view_serializer.rb deleted file mode 100644 index 129cd31f17b..00000000000 --- a/plugins/chat/app/serializers/chat_view_serializer.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -class ChatViewSerializer < ApplicationSerializer - attributes :meta, :chat_messages - - def chat_messages - ActiveModel::ArraySerializer.new( - object.chat_messages, - each_serializer: ChatMessageSerializer, - reviewable_ids: object.reviewable_ids, - user_flag_statuses: object.user_flag_statuses, - chat_channel: object.chat_channel, - scope: scope, - ) - end - - def meta - meta_hash = { - channel_id: object.chat_channel.id, - can_flag: scope.can_flag_in_chat_channel?(object.chat_channel), - channel_status: object.chat_channel.status, - user_silenced: !scope.can_create_chat_message?, - can_moderate: scope.can_moderate_chat?(object.chat_channel.chatable), - can_delete_self: scope.can_delete_own_chats?(object.chat_channel.chatable), - can_delete_others: scope.can_delete_other_chats?(object.chat_channel.chatable), - channel_message_bus_last_id: MessageBus.last_id("/chat/#{object.chat_channel.id}"), - } - meta_hash[:can_load_more_past] = object.can_load_more_past unless object.can_load_more_past.nil? - meta_hash[ - :can_load_more_future - ] = object.can_load_more_future unless object.can_load_more_future.nil? - meta_hash - end -end diff --git a/plugins/chat/app/serializers/chat_webhook_event_serializer.rb b/plugins/chat/app/serializers/chat_webhook_event_serializer.rb deleted file mode 100644 index 3fb674c653f..00000000000 --- a/plugins/chat/app/serializers/chat_webhook_event_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class ChatWebhookEventSerializer < ApplicationSerializer - attributes :username, :emoji -end diff --git a/plugins/chat/app/serializers/direct_message_serializer.rb b/plugins/chat/app/serializers/direct_message_serializer.rb deleted file mode 100644 index 817902467dd..00000000000 --- a/plugins/chat/app/serializers/direct_message_serializer.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -class DirectMessageSerializer < ApplicationSerializer - has_many :users, serializer: UserWithCustomFieldsAndStatusSerializer, embed: :objects - - def users - users = object.direct_message_users.map(&:user).map { |u| u || DeletedChatUser.new } - - return users - [scope.user] if users.count > 1 - users - end -end diff --git a/plugins/chat/app/serializers/incoming_chat_webhook_serializer.rb b/plugins/chat/app/serializers/incoming_chat_webhook_serializer.rb deleted file mode 100644 index 7f097e62bfd..00000000000 --- a/plugins/chat/app/serializers/incoming_chat_webhook_serializer.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class IncomingChatWebhookSerializer < ApplicationSerializer - has_one :chat_channel, serializer: ChatChannelSerializer, embed: :objects - - attributes :id, :name, :description, :emoji, :url, :username, :updated_at -end diff --git a/plugins/chat/app/serializers/reviewable_chat_message_serializer.rb b/plugins/chat/app/serializers/reviewable_chat_message_serializer.rb deleted file mode 100644 index 5c56d39fb70..00000000000 --- a/plugins/chat/app/serializers/reviewable_chat_message_serializer.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require_dependency "reviewable_serializer" - -class ReviewableChatMessageSerializer < ReviewableSerializer - target_attributes :cooked - payload_attributes :transcript_topic_id, :message_cooked - attributes :target_id - - has_one :chat_channel, serializer: ChatChannelSerializer, root: false, embed: :objects - - def chat_channel - object.chat_message.chat_channel - end - - def target_id - object.target&.id - end -end diff --git a/plugins/chat/app/serializers/structured_channel_serializer.rb b/plugins/chat/app/serializers/structured_channel_serializer.rb deleted file mode 100644 index e88b9a00b5d..00000000000 --- a/plugins/chat/app/serializers/structured_channel_serializer.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -class StructuredChannelSerializer < ApplicationSerializer - attributes :public_channels, :direct_message_channels, :meta - - def public_channels - object[:public_channels].map do |channel| - ChatChannelSerializer.new( - channel, - root: nil, - scope: scope, - membership: channel_membership(channel.id), - new_messages_message_bus_last_id: - chat_message_bus_last_ids[ChatPublisher.new_messages_message_bus_channel(channel.id)], - new_mentions_message_bus_last_id: - chat_message_bus_last_ids[ChatPublisher.new_mentions_message_bus_channel(channel.id)], - ) - end - end - - def direct_message_channels - object[:direct_message_channels].map do |channel| - ChatChannelSerializer.new( - channel, - root: nil, - scope: scope, - membership: channel_membership(channel.id), - new_messages_message_bus_last_id: - chat_message_bus_last_ids[ChatPublisher.new_messages_message_bus_channel(channel.id)], - new_mentions_message_bus_last_id: - chat_message_bus_last_ids[ChatPublisher.new_mentions_message_bus_channel(channel.id)], - ) - end - end - - def channel_membership(channel_id) - return if scope.anonymous? - object[:memberships].find { |membership| membership.chat_channel_id == channel_id } - end - - def meta - last_ids = { - channel_metadata: - chat_message_bus_last_ids[ChatPublisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL], - channel_edits: chat_message_bus_last_ids[ChatPublisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL], - channel_status: chat_message_bus_last_ids[ChatPublisher::CHANNEL_STATUS_MESSAGE_BUS_CHANNEL], - new_channel: chat_message_bus_last_ids[ChatPublisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL], - archive_status: - chat_message_bus_last_ids[ChatPublisher::CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL], - } - - if id = - chat_message_bus_last_ids[ - ChatPublisher.user_tracking_state_message_bus_channel(scope.user.id) - ] - last_ids[:user_tracking_state] = id - end - - { message_bus_last_ids: last_ids } - end - - private - - def chat_message_bus_last_ids - @chat_message_bus_last_ids ||= - begin - message_bus_channels = [ - ChatPublisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL, - ChatPublisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL, - ChatPublisher::CHANNEL_STATUS_MESSAGE_BUS_CHANNEL, - ChatPublisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL, - ChatPublisher::CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL, - ] - - if !scope.anonymous? - message_bus_channels.push( - ChatPublisher.user_tracking_state_message_bus_channel(scope.user.id), - ) - end - - object[:public_channels].each do |channel| - message_bus_channels.push(ChatPublisher.new_messages_message_bus_channel(channel.id)) - message_bus_channels.push(ChatPublisher.new_mentions_message_bus_channel(channel.id)) - end - - object[:direct_message_channels].each do |channel| - message_bus_channels.push(ChatPublisher.new_messages_message_bus_channel(channel.id)) - message_bus_channels.push(ChatPublisher.new_mentions_message_bus_channel(channel.id)) - end - - MessageBus.last_ids(*message_bus_channels) - end - end -end diff --git a/plugins/chat/app/serializers/user_chat_channel_membership_serializer.rb b/plugins/chat/app/serializers/user_chat_channel_membership_serializer.rb deleted file mode 100644 index 18c26222e34..00000000000 --- a/plugins/chat/app/serializers/user_chat_channel_membership_serializer.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class UserChatChannelMembershipSerializer < BaseChatChannelMembershipSerializer - has_one :user, serializer: BasicUserSerializer, embed: :objects - - def user - object.user - end -end diff --git a/plugins/chat/app/serializers/user_chat_message_bookmark_serializer.rb b/plugins/chat/app/serializers/user_chat_message_bookmark_serializer.rb deleted file mode 100644 index 49f4c7af6f6..00000000000 --- a/plugins/chat/app/serializers/user_chat_message_bookmark_serializer.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -class UserChatMessageBookmarkSerializer < UserBookmarkBaseSerializer - attr_reader :chat_message - - def title - fancy_title - end - - def fancy_title - @fancy_title ||= chat_message.chat_channel.title(scope.user) - end - - def cooked - chat_message.cooked - end - - def bookmarkable_user - @bookmarkable_user ||= chat_message.user - end - - def bookmarkable_url - chat_message.url - end - - def excerpt - return nil unless cooked - @excerpt ||= PrettyText.excerpt(cooked, 300, keep_emoji_images: true) - end - - private - - def chat_message - object.bookmarkable - end -end diff --git a/plugins/chat/app/serializers/user_with_custom_fields_and_status_serializer.rb b/plugins/chat/app/serializers/user_with_custom_fields_and_status_serializer.rb deleted file mode 100644 index e0897abfd54..00000000000 --- a/plugins/chat/app/serializers/user_with_custom_fields_and_status_serializer.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -class UserWithCustomFieldsAndStatusSerializer < UserWithCustomFieldsSerializer - attributes :status - - def include_status? - SiteSetting.enable_user_status && user.has_status? - end - - def status - UserStatusSerializer.new(user.user_status, root: false) - end -end diff --git a/plugins/chat/app/services/base.rb b/plugins/chat/app/services/base.rb deleted file mode 100644 index ad440c2ac09..00000000000 --- a/plugins/chat/app/services/base.rb +++ /dev/null @@ -1,430 +0,0 @@ -# frozen_string_literal: true - -module Chat - module Service - # Module to be included to provide steps DSL to any class. This allows to - # create easy to understand services as the whole service cycle is visible - # simply by reading the beginning of its class. - # - # Steps are executed in the order they’re defined. They will use their name - # to execute the corresponding method defined in the service class. - # - # Currently, there are 5 types of steps: - # - # * +contract(name = :default)+: used to validate the input parameters, - # typically provided by a user calling an endpoint. A special embedded - # +Contract+ class has to be defined to holds the validations. If the - # validations fail, the step will fail. Otherwise, the resulting contract - # will be available in +context[:contract]+. When calling +step(name)+ or - # +model(name = :model)+ methods after validating a contract, the contract - # should be used as an argument instead of context attributes. - # * +model(name = :model)+: used to instantiate a model (either by building - # it or fetching it from the DB). If a falsy value is returned, then the - # step will fail. Otherwise the resulting object will be assigned in - # +context[name]+ (+context[:model]+ by default). - # * +policy(name = :default)+: used to perform a check on the state of the - # system. Typically used to run guardians. If a falsy value is returned, - # the step will fail. - # * +step(name)+: used to run small snippets of arbitrary code. The step - # doesn’t care about its return value, so to mark the service as failed, - # {#fail!} has to be called explicitly. - # * +transaction+: used to wrap other steps inside a DB transaction. - # - # The methods defined on the service are automatically provided with - # the whole context passed as keyword arguments. This allows to define in a - # very explicit way what dependencies are used by the method. If for - # whatever reason a key isn’t found in the current context, then Ruby will - # raise an exception when the method is called. - # - # Regarding contract classes, they automatically have {ActiveModel} modules - # included so all the {ActiveModel} API is available. - # - # @example An example from the {TrashChannel} service - # class TrashChannel - # include Base - # - # model :channel, :fetch_channel - # policy :invalid_access - # transaction do - # step :prevents_slug_collision - # step :soft_delete_channel - # step :log_channel_deletion - # end - # step :enqueue_delete_channel_relations_job - # - # private - # - # def fetch_channel(channel_id:, **) - # ChatChannel.find_by(id: channel_id) - # end - # - # def invalid_access(guardian:, channel:, **) - # guardian.can_preview_chat_channel?(channel) && guardian.can_delete_chat_channel? - # end - # - # def prevents_slug_collision(channel:, **) - # … - # end - # - # def soft_delete_channel(guardian:, channel:, **) - # … - # end - # - # def log_channel_deletion(guardian:, channel:, **) - # … - # end - # - # def enqueue_delete_channel_relations_job(channel:, **) - # … - # end - # end - # @example An example from the {UpdateChannelStatus} service which uses a contract - # class UpdateChannelStatus - # include Base - # - # model :channel, :fetch_channel - # contract - # policy :check_channel_permission - # step :change_status - # - # class Contract - # attribute :status - # validates :status, inclusion: { in: ChatChannel.editable_statuses.keys } - # end - # - # … - # end - module Base - extend ActiveSupport::Concern - - # The only exception that can be raised by a service. - class Failure < StandardError - # @return [Context] - attr_reader :context - - # @!visibility private - def initialize(context = nil) - @context = context - super - end - end - - # Simple structure to hold the context of the service during its whole lifecycle. - class Context < OpenStruct - # @return [Boolean] returns +true+ if the conext is set as successful (default) - def success? - !failure? - end - - # @return [Boolean] returns +true+ if the context is set as failed - # @see #fail! - # @see #fail - def failure? - @failure || false - end - - # Marks the context as failed. - # @param context [Hash, Context] the context to merge into the current one - # @example - # context.fail!("failure": "something went wrong") - # @return [Context] - def fail!(context = {}) - fail(context) - raise Failure, self - end - - # Marks the context as failed without raising an exception. - # @param context [Hash, Context] the context to merge into the current one - # @example - # context.fail("failure": "something went wrong") - # @return [Context] - def fail(context = {}) - merge(context) - @failure = true - self - end - - # Merges the given context into the current one. - # @!visibility private - def merge(other_context = {}) - other_context.each { |key, value| self[key.to_sym] = value } - self - end - - private - - def self.build(context = {}) - self === context ? context : new(context) - end - end - - # Internal module to define available steps as DSL - # @!visibility private - module StepsHelpers - def model(name = :model, step_name = :"fetch_#{name}") - steps << ModelStep.new(name, step_name) - end - - def contract(name = :default, class_name: self::Contract, default_values_from: nil) - steps << ContractStep.new( - name, - class_name: class_name, - default_values_from: default_values_from, - ) - end - - def policy(name = :default) - steps << PolicyStep.new(name) - end - - def step(name) - steps << Step.new(name) - end - - def transaction(&block) - steps << TransactionStep.new(&block) - end - end - - # @!visibility private - class Step - attr_reader :name, :method_name, :class_name - - def initialize(name, method_name = name, class_name: nil) - @name = name - @method_name = method_name - @class_name = class_name - end - - def call(instance, context) - method = instance.method(method_name) - args = {} - args = context.to_h if method.arity.nonzero? - context[result_key] = Context.build - instance.instance_exec(**args, &method) - end - - private - - def type - self.class.name.split("::").last.downcase.sub(/^(\w+)step$/, "\\1") - end - - def result_key - "result.#{type}.#{name}" - end - end - - # @!visibility private - class ModelStep < Step - def call(instance, context) - context[name] = super - raise ArgumentError, "Model not found" if context[name].blank? - rescue ArgumentError => exception - context[result_key].fail(exception: exception) - context.fail! - end - end - - # @!visibility private - class PolicyStep < Step - def call(instance, context) - if !super - context[result_key].fail - context.fail! - end - end - end - - # @!visibility private - class ContractStep < Step - attr_reader :default_values_from - - def initialize(name, method_name = name, class_name: nil, default_values_from: nil) - super(name, method_name, class_name: class_name) - @default_values_from = default_values_from - end - - def call(instance, context) - attributes = class_name.attribute_names.map(&:to_sym) - default_values = {} - default_values = context[default_values_from].slice(*attributes) if default_values_from - contract = class_name.new(default_values.merge(context.to_h.slice(*attributes))) - context[contract_name] = contract - context[result_key] = Context.build - if contract.invalid? - context[result_key].fail(errors: contract.errors) - context.fail! - end - end - - private - - def contract_name - return :contract if name.to_sym == :default - :"#{name}_contract" - end - end - - # @!visibility private - class TransactionStep < Step - include StepsHelpers - - attr_reader :steps - - def initialize(&block) - @steps = [] - instance_exec(&block) - end - - def call(instance, context) - ActiveRecord::Base.transaction { steps.each { |step| step.call(instance, context) } } - end - end - - included do - # The global context which is available from any step. - attr_reader :context - - # @!visibility private - # Internal class used to setup the base contract of the service. - self::Contract = - Class.new do - include ActiveModel::API - include ActiveModel::Attributes - include ActiveModel::AttributeMethods - include ActiveModel::Validations::Callbacks - end - end - - class_methods do - include StepsHelpers - - def call(context = {}) - new(context).tap(&:run).context - end - - def call!(context = {}) - new(context).tap(&:run!).context - end - - def steps - @steps ||= [] - end - end - - # @!scope class - # @!method model(name = :model, step_name = :"fetch_#{name}") - # @param name [Symbol] name of the model - # @param step_name [Symbol] name of the method to call for this step - # Evaluates arbitrary code to build or fetch a model (typically from the - # DB). If the step returns a falsy value, then the step will fail. - # - # It stores the resulting model in +context[:model]+ by default (can be - # customized by providing the +name+ argument). - # - # @example - # model :channel, :fetch_channel - # - # private - # - # def fetch_channel(channel_id:, **) - # ChatChannel.find_by(id: channel_id) - # end - - # @!scope class - # @!method policy(name = :default) - # @param name [Symbol] name for this policy - # Performs checks related to the state of the system. If the - # step doesn’t return a truthy value, then the policy will fail. - # - # @example - # policy :no_direct_message_channel - # - # private - # - # def no_direct_message_channel(channel:, **) - # !channel.direct_message_channel? - # end - - # @!scope class - # @!method contract(name = :default, class_name: self::Contract, default_values_from: nil) - # @param name [Symbol] name for this contract - # @param class_name [Class] a class defining the contract - # @param default_values_from [Symbol] name of the model to get default values from - # Checks the validity of the input parameters. - # Implements ActiveModel::Validations and ActiveModel::Attributes. - # - # It stores the resulting contract in +context[:contract]+ by default - # (can be customized by providing the +name+ argument). - # - # @example - # contract - # - # class Contract - # attribute :name - # validates :name, presence: true - # end - - # @!scope class - # @!method step(name) - # @param name [Symbol] the name of this step - # Runs arbitrary code. To mark a step as failed, a call to {#fail!} needs - # to be made explicitly. - # - # @example - # step :update_channel - # - # private - # - # def update_channel(channel:, params_to_edit:, **) - # channel.update!(params_to_edit) - # end - # @example using {#fail!} in a step - # step :save_channel - # - # private - # - # def save_channel(channel:, **) - # fail!("something went wrong") if !channel.save - # end - - # @!scope class - # @!method transaction(&block) - # @param block [Proc] a block containing steps to be run inside a transaction - # Runs steps inside a DB transaction. - # - # @example - # transaction do - # step :prevents_slug_collision - # step :soft_delete_channel - # step :log_channel_deletion - # end - - # @!visibility private - def initialize(initial_context = {}) - @initial_context = initial_context.with_indifferent_access - @context = Context.build(initial_context.merge(__steps__: self.class.steps)) - end - - # @!visibility private - def run - run! - rescue Failure => exception - raise if context.object_id != exception.context.object_id - end - - # @!visibility private - def run! - self.class.steps.each { |step| step.call(self, context) } - end - - # @!visibility private - def fail!(message) - step_name = caller_locations(1, 1)[0].label - context["result.step.#{step_name}"].fail(error: message) - context.fail! - end - end - end -end diff --git a/plugins/chat/app/services/chat/lookup_thread.rb b/plugins/chat/app/services/chat/lookup_thread.rb new file mode 100644 index 00000000000..ead2e00e277 --- /dev/null +++ b/plugins/chat/app/services/chat/lookup_thread.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Chat + # Finds a thread within a channel. The thread_id and channel_id must + # match. For now we do not want to allow fetching threads if the + # enable_experimental_chat_threaded_discussions hidden site setting + # is not turned on, and the channel must specifically have threading + # enabled. + # + # @example + # Chat::LookupThread.call(thread_id: 88, channel_id: 2, guardian: guardian) + # + class LookupThread + include Service::Base + + # @!method call(thread_id:, channel_id:, guardian:) + # @param [Integer] thread_id + # @param [Integer] channel_id + # @param [Guardian] guardian + # @return [Service::Base::Context] + + policy :threaded_discussions_enabled + contract + model :thread, :fetch_thread + policy :invalid_access + policy :threading_enabled_for_channel + + # @!visibility private + class Contract + attribute :thread_id, :integer + attribute :channel_id, :integer + + validates :thread_id, :channel_id, presence: true + end + + private + + def threaded_discussions_enabled + SiteSetting.enable_experimental_chat_threaded_discussions + end + + def fetch_thread(contract:, **) + Chat::Thread.includes( + :channel, + original_message_user: :user_status, + original_message: :chat_webhook_event, + ).find_by(id: contract.thread_id, channel_id: contract.channel_id) + end + + def invalid_access(guardian:, thread:, **) + guardian.can_preview_chat_channel?(thread.channel) + end + + def threading_enabled_for_channel(thread:, **) + thread.channel.threading_enabled + end + end +end diff --git a/plugins/chat/app/services/chat/message_destroyer.rb b/plugins/chat/app/services/chat/message_destroyer.rb new file mode 100644 index 00000000000..c5dc28de5ed --- /dev/null +++ b/plugins/chat/app/services/chat/message_destroyer.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Chat + class MessageDestroyer + def destroy_in_batches(chat_messages_query, batch_size: 200) + chat_messages_query + .in_batches(of: batch_size) + .each do |relation| + destroyed_ids = relation.destroy_all.pluck(:id) + reset_last_read(destroyed_ids) + delete_flags(destroyed_ids) + end + end + + def trash_message(message, actor) + Chat::Message.transaction do + message.trash!(actor) + Chat::Mention.where(chat_message: message).destroy_all + DiscourseEvent.trigger(:chat_message_trashed, message, message.chat_channel, actor) + + # FIXME: We should do something to prevent the blue/green bubble + # of other channel members from getting out of sync when a message + # gets deleted. + Chat::Publisher.publish_delete!(message.chat_channel, message) + end + end + + private + + def reset_last_read(message_ids) + Chat::UserChatChannelMembership.where(last_read_message_id: message_ids).update_all( + last_read_message_id: nil, + ) + end + + def delete_flags(message_ids) + Chat::ReviewableMessage.where(target_id: message_ids).destroy_all + end + end +end diff --git a/plugins/chat/app/services/chat/publisher.rb b/plugins/chat/app/services/chat/publisher.rb new file mode 100644 index 00000000000..b04cfa069c9 --- /dev/null +++ b/plugins/chat/app/services/chat/publisher.rb @@ -0,0 +1,268 @@ +# frozen_string_literal: true + +module Chat + module Publisher + def self.new_messages_message_bus_channel(chat_channel_id) + "/chat/#{chat_channel_id}/new-messages" + end + + def self.publish_new!(chat_channel, chat_message, staged_id) + content = + Chat::MessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :sent + content[:staged_id] = staged_id + permissions = permissions(chat_channel) + + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions) + + MessageBus.publish( + self.new_messages_message_bus_channel(chat_channel.id), + { + channel_id: chat_channel.id, + message_id: chat_message.id, + user_id: chat_message.user.id, + username: chat_message.user.username, + thread_id: chat_message.thread_id, + }, + permissions, + ) + end + + def self.publish_processed!(chat_message) + chat_channel = chat_message.chat_channel + content = { + type: :processed, + chat_message: { + id: chat_message.id, + cooked: chat_message.cooked, + }, + } + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_edit!(chat_channel, chat_message) + content = + Chat::MessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :edit + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_refresh!(chat_channel, chat_message) + content = + Chat::MessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :refresh + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_reaction!(chat_channel, chat_message, action, user, emoji) + content = { + action: action, + user: BasicUserSerializer.new(user, root: false).as_json, + emoji: emoji, + type: :reaction, + chat_message_id: chat_message.id, + } + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_presence!(chat_channel, user, typ) + raise NotImplementedError + end + + def self.publish_delete!(chat_channel, chat_message) + MessageBus.publish( + "/chat/#{chat_channel.id}", + { type: "delete", deleted_id: chat_message.id, deleted_at: chat_message.deleted_at }, + permissions(chat_channel), + ) + end + + def self.publish_bulk_delete!(chat_channel, deleted_message_ids) + MessageBus.publish( + "/chat/#{chat_channel.id}", + { typ: "bulk_delete", deleted_ids: deleted_message_ids, deleted_at: Time.zone.now }, + permissions(chat_channel), + ) + end + + def self.publish_restore!(chat_channel, chat_message) + content = + Chat::MessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :restore + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_flag!(chat_message, user, reviewable, score) + # Publish to user who created flag + MessageBus.publish( + "/chat/#{chat_message.chat_channel_id}", + { + type: "self_flagged", + user_flag_status: score.status_for_database, + chat_message_id: chat_message.id, + }.as_json, + user_ids: [user.id], + ) + + # Publish flag with link to reviewable to staff + MessageBus.publish( + "/chat/#{chat_message.chat_channel_id}", + { type: "flag", chat_message_id: chat_message.id, reviewable_id: reviewable.id }.as_json, + group_ids: [Group::AUTO_GROUPS[:staff]], + ) + end + + def self.user_tracking_state_message_bus_channel(user_id) + "/chat/user-tracking-state/#{user_id}" + end + + def self.publish_user_tracking_state(user, chat_channel_id, chat_message_id) + data = { chat_channel_id: chat_channel_id, chat_message_id: chat_message_id }.merge( + Chat::ChannelUnreadsQuery.call(channel_id: chat_channel_id, user_id: user.id), + ) + + MessageBus.publish( + self.user_tracking_state_message_bus_channel(user.id), + data.as_json, + user_ids: [user.id], + ) + end + + def self.new_mentions_message_bus_channel(chat_channel_id) + "/chat/#{chat_channel_id}/new-mentions" + end + + def self.publish_new_mention(user_id, chat_channel_id, chat_message_id) + MessageBus.publish( + self.new_mentions_message_bus_channel(chat_channel_id), + { message_id: chat_message_id, channel_id: chat_channel_id }.as_json, + user_ids: [user_id], + ) + end + + NEW_CHANNEL_MESSAGE_BUS_CHANNEL = "/chat/new-channel" + + def self.publish_new_channel(chat_channel, users) + users.each do |user| + # FIXME: This could generate a lot of queries depending on the amount of users + membership = chat_channel.membership_for(user) + + # TODO: this event is problematic as some code will update the membership before calling it + # and other code will update it after calling it + # it means frontend must handle logic for both cases + serialized_channel = + Chat::ChannelSerializer.new( + chat_channel, + scope: Guardian.new(user), # We need a guardian here for direct messages + root: :channel, + membership: membership, + ).as_json + + MessageBus.publish(NEW_CHANNEL_MESSAGE_BUS_CHANNEL, serialized_channel, user_ids: [user.id]) + end + end + + def self.publish_inaccessible_mentions( + user_id, + chat_message, + cannot_chat_users, + without_membership, + too_many_members, + mentions_disabled + ) + MessageBus.publish( + "/chat/#{chat_message.chat_channel_id}", + { + type: :mention_warning, + chat_message_id: chat_message.id, + cannot_see: cannot_chat_users.map { |u| { username: u.username, id: u.id } }.as_json, + without_membership: + without_membership.map { |u| { username: u.username, id: u.id } }.as_json, + groups_with_too_many_members: too_many_members.map(&:name).as_json, + group_mentions_disabled: mentions_disabled.map(&:name).as_json, + }, + user_ids: [user_id], + ) + end + + CHANNEL_EDITS_MESSAGE_BUS_CHANNEL = "/chat/channel-edits" + + def self.publish_chat_channel_edit(chat_channel, acting_user) + MessageBus.publish( + CHANNEL_EDITS_MESSAGE_BUS_CHANNEL, + { + chat_channel_id: chat_channel.id, + name: chat_channel.title(acting_user), + description: chat_channel.description, + slug: chat_channel.slug, + }, + permissions(chat_channel), + ) + end + + CHANNEL_STATUS_MESSAGE_BUS_CHANNEL = "/chat/channel-status" + + def self.publish_channel_status(chat_channel) + MessageBus.publish( + CHANNEL_STATUS_MESSAGE_BUS_CHANNEL, + { chat_channel_id: chat_channel.id, status: chat_channel.status }, + permissions(chat_channel), + ) + end + + CHANNEL_METADATA_MESSAGE_BUS_CHANNEL = "/chat/channel-metadata" + + def self.publish_chat_channel_metadata(chat_channel) + MessageBus.publish( + CHANNEL_METADATA_MESSAGE_BUS_CHANNEL, + { chat_channel_id: chat_channel.id, memberships_count: chat_channel.user_count }, + permissions(chat_channel), + ) + end + + CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL = "/chat/channel-archive-status" + + def self.publish_archive_status( + chat_channel, + archive_status:, + archived_messages:, + archive_topic_id:, + total_messages: + ) + MessageBus.publish( + CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL, + { + chat_channel_id: chat_channel.id, + archive_failed: archive_status == :failed, + archive_completed: archive_status == :success, + archived_messages: archived_messages, + total_messages: total_messages, + archive_topic_id: archive_topic_id, + }, + permissions(chat_channel), + ) + end + + private + + def self.permissions(chat_channel) + { user_ids: chat_channel.allowed_user_ids, group_ids: chat_channel.allowed_group_ids } + end + + def self.anonymous_guardian + Guardian.new(nil) + end + end +end diff --git a/plugins/chat/app/services/chat/trash_channel.rb b/plugins/chat/app/services/chat/trash_channel.rb new file mode 100644 index 00000000000..33a80b2c018 --- /dev/null +++ b/plugins/chat/app/services/chat/trash_channel.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for trashing a chat channel. + # Note the slug is modified to prevent collisions. + # + # @example + # Chat::TrashChannel.call(channel_id: 2, guardian: guardian) + # + class TrashChannel + include Service::Base + + # @!method call(channel_id:, guardian:) + # @param [Integer] channel_id + # @param [Guardian] guardian + # @return [Service::Base::Context] + + DELETE_CHANNEL_LOG_KEY = "chat_channel_delete" + + model :channel, :fetch_channel + policy :invalid_access + transaction do + step :prevents_slug_collision + step :soft_delete_channel + step :log_channel_deletion + end + step :enqueue_delete_channel_relations_job + + private + + def fetch_channel(channel_id:, **) + Chat::Channel.find_by(id: channel_id) + end + + def invalid_access(guardian:, channel:, **) + guardian.can_preview_chat_channel?(channel) && guardian.can_delete_chat_channel? + end + + def prevents_slug_collision(channel:, **) + channel.update!( + slug: + "#{Time.current.strftime("%Y%m%d-%H%M")}-#{channel.slug}-deleted".truncate( + SiteSetting.max_topic_title_length, + omission: "", + ), + ) + end + + def soft_delete_channel(guardian:, channel:, **) + channel.trash!(guardian.user) + end + + def log_channel_deletion(guardian:, channel:, **) + StaffActionLogger.new(guardian.user).log_custom( + DELETE_CHANNEL_LOG_KEY, + { chat_channel_id: channel.id, chat_channel_name: channel.title(guardian.user) }, + ) + end + + def enqueue_delete_channel_relations_job(channel:, **) + Jobs.enqueue(Jobs::Chat::ChannelDelete, chat_channel_id: channel.id) + end + end +end diff --git a/plugins/chat/app/services/chat/update_channel.rb b/plugins/chat/app/services/chat/update_channel.rb new file mode 100644 index 00000000000..08534a291d8 --- /dev/null +++ b/plugins/chat/app/services/chat/update_channel.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for updating a chat channel's name, slug, and description. + # + # For a CategoryChannel, the settings for auto_join_users and allow_channel_wide_mentions + # are also editable. + # + # @example + # Service::Chat::UpdateChannel.call( + # channel_id: 2, + # guardian: guardian, + # name: "SuperChannel", + # description: "This is the best channel", + # slug: "super-channel", + # ) + # + class UpdateChannel + include Service::Base + + # @!method call(channel_id:, guardian:, **params_to_edit) + # @param [Integer] channel_id + # @param [Guardian] guardian + # @param [Hash] params_to_edit + # @option params_to_edit [String,nil] name + # @option params_to_edit [String,nil] description + # @option params_to_edit [String,nil] slug + # @option params_to_edit [Boolean] auto_join_users Only valid for {CategoryChannel}. Whether active users + # with permission to see the category should automatically join the channel. + # @option params_to_edit [Boolean] allow_channel_wide_mentions Allow the use of @here and @all in the channel. + # @return [Service::Base::Context] + + model :channel, :fetch_channel + policy :no_direct_message_channel + policy :check_channel_permission + contract default_values_from: :channel + step :update_channel + step :publish_channel_update + step :auto_join_users_if_needed + + # @!visibility private + class Contract + attribute :name, :string + attribute :description, :string + attribute :slug, :string + attribute :auto_join_users, :boolean, default: false + attribute :allow_channel_wide_mentions, :boolean, default: true + + before_validation do + assign_attributes( + attributes.symbolize_keys.slice(:name, :description, :slug).transform_values(&:presence), + ) + end + end + + private + + def fetch_channel(channel_id:, **) + Chat::Channel.find_by(id: channel_id) + end + + def no_direct_message_channel(channel:, **) + !channel.direct_message_channel? + end + + def check_channel_permission(guardian:, channel:, **) + guardian.can_preview_chat_channel?(channel) && guardian.can_edit_chat_channel? + end + + def update_channel(channel:, contract:, **) + channel.update!(contract.attributes) + end + + def publish_channel_update(channel:, guardian:, **) + Chat::Publisher.publish_chat_channel_edit(channel, guardian.user) + end + + def auto_join_users_if_needed(channel:, **) + return unless channel.auto_join_users? + Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships + end + end +end diff --git a/plugins/chat/app/services/chat/update_channel_status.rb b/plugins/chat/app/services/chat/update_channel_status.rb new file mode 100644 index 00000000000..f3e84185942 --- /dev/null +++ b/plugins/chat/app/services/chat/update_channel_status.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for updating a chat channel status. + # + # @example + # Chat::UpdateChannelStatus.call(channel_id: 2, guardian: guardian, status: "open") + # + class UpdateChannelStatus + include Service::Base + + # @!method call(channel_id:, guardian:, status:) + # @param [Integer] channel_id + # @param [Guardian] guardian + # @param [String] status + # @return [Service::Base::Context] + + model :channel, :fetch_channel + contract + policy :check_channel_permission + step :change_status + + # @!visibility private + class Contract + attribute :status + validates :status, inclusion: { in: Chat::Channel.editable_statuses.keys } + end + + private + + def fetch_channel(channel_id:, **) + Chat::Channel.find_by(id: channel_id) + end + + def check_channel_permission(guardian:, channel:, status:, **) + guardian.can_preview_chat_channel?(channel) && + guardian.can_change_channel_status?(channel, status.to_sym) + end + + def change_status(channel:, status:, guardian:, **) + channel.public_send("#{status}!", guardian.user) + end + end +end diff --git a/plugins/chat/app/services/chat/update_user_last_read.rb b/plugins/chat/app/services/chat/update_user_last_read.rb new file mode 100644 index 00000000000..0eb01159b25 --- /dev/null +++ b/plugins/chat/app/services/chat/update_user_last_read.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for updating the last read message id of a membership. + # + # @example + # Chat::UpdateUserLastRead.call(user_id: 1, channel_id: 2, message_id: 3, guardian: guardian) + # + class UpdateUserLastRead + include Service::Base + + # @!method call(user_id:, channel_id:, message_id:, guardian:) + # @param [Integer] user_id + # @param [Integer] channel_id + # @param [Integer] message_id + # @param [Guardian] guardian + # @return [Service::Base::Context] + + contract + model :membership, :fetch_active_membership + policy :invalid_access + policy :ensure_message_id_recency + policy :ensure_message_exists + step :update_last_read_message_id + step :mark_associated_mentions_as_read + step :publish_new_last_read_to_clients + + # @!visibility private + class Contract + attribute :message_id, :integer + attribute :user_id, :integer + attribute :channel_id, :integer + + validates :message_id, :user_id, :channel_id, presence: true + end + + private + + def fetch_active_membership(user_id:, channel_id:, **) + Chat::UserChatChannelMembership.includes(:user, :chat_channel).find_by( + user_id: user_id, + chat_channel_id: channel_id, + following: true, + ) + end + + def invalid_access(guardian:, membership:, **) + guardian.can_join_chat_channel?(membership.chat_channel) + end + + def ensure_message_id_recency(message_id:, membership:, **) + !membership.last_read_message_id || message_id >= membership.last_read_message_id + end + + def ensure_message_exists(channel_id:, message_id:, **) + Chat::Message.with_deleted.exists?(chat_channel_id: channel_id, id: message_id) + end + + def update_last_read_message_id(message_id:, membership:, **) + membership.update!(last_read_message_id: message_id) + end + + def mark_associated_mentions_as_read(membership:, message_id:, **) + Notification + .where(notification_type: Notification.types[:chat_mention]) + .where(user: membership.user) + .where(read: false) + .joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id") + .joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id") + .where("chat_messages.id <= ?", message_id) + .where("chat_messages.chat_channel_id = ?", membership.chat_channel.id) + .update_all(read: true) + end + + def publish_new_last_read_to_clients(guardian:, channel_id:, message_id:, **) + Chat::Publisher.publish_user_tracking_state(guardian.user, channel_id, message_id) + end + end +end diff --git a/plugins/chat/app/services/chat_message_destroyer.rb b/plugins/chat/app/services/chat_message_destroyer.rb deleted file mode 100644 index b3216d6b3ee..00000000000 --- a/plugins/chat/app/services/chat_message_destroyer.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -class ChatMessageDestroyer - def destroy_in_batches(chat_messages_query, batch_size: 200) - chat_messages_query - .in_batches(of: batch_size) - .each do |relation| - destroyed_ids = relation.destroy_all.pluck(:id) - reset_last_read(destroyed_ids) - delete_flags(destroyed_ids) - end - end - - def trash_message(message, actor) - ChatMessage.transaction do - message.trash!(actor) - ChatMention.where(chat_message: message).destroy_all - DiscourseEvent.trigger(:chat_message_trashed, message, message.chat_channel, actor) - - # FIXME: We should do something to prevent the blue/green bubble - # of other channel members from getting out of sync when a message - # gets deleted. - ChatPublisher.publish_delete!(message.chat_channel, message) - end - end - - private - - def reset_last_read(message_ids) - UserChatChannelMembership.where(last_read_message_id: message_ids).update_all( - last_read_message_id: nil, - ) - end - - def delete_flags(message_ids) - ReviewableChatMessage.where(target_id: message_ids).destroy_all - end -end diff --git a/plugins/chat/app/services/chat_publisher.rb b/plugins/chat/app/services/chat_publisher.rb deleted file mode 100644 index 0778b655da1..00000000000 --- a/plugins/chat/app/services/chat_publisher.rb +++ /dev/null @@ -1,266 +0,0 @@ -# frozen_string_literal: true - -module ChatPublisher - def self.new_messages_message_bus_channel(chat_channel_id) - "/chat/#{chat_channel_id}/new-messages" - end - - def self.publish_new!(chat_channel, chat_message, staged_id) - content = - ChatMessageSerializer.new( - chat_message, - { scope: anonymous_guardian, root: :chat_message }, - ).as_json - content[:type] = :sent - content[:staged_id] = staged_id - permissions = permissions(chat_channel) - - MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions) - - MessageBus.publish( - self.new_messages_message_bus_channel(chat_channel.id), - { - channel_id: chat_channel.id, - message_id: chat_message.id, - user_id: chat_message.user.id, - username: chat_message.user.username, - thread_id: chat_message.thread_id, - }, - permissions, - ) - end - - def self.publish_processed!(chat_message) - chat_channel = chat_message.chat_channel - content = { - type: :processed, - chat_message: { - id: chat_message.id, - cooked: chat_message.cooked, - }, - } - MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) - end - - def self.publish_edit!(chat_channel, chat_message) - content = - ChatMessageSerializer.new( - chat_message, - { scope: anonymous_guardian, root: :chat_message }, - ).as_json - content[:type] = :edit - MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) - end - - def self.publish_refresh!(chat_channel, chat_message) - content = - ChatMessageSerializer.new( - chat_message, - { scope: anonymous_guardian, root: :chat_message }, - ).as_json - content[:type] = :refresh - MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) - end - - def self.publish_reaction!(chat_channel, chat_message, action, user, emoji) - content = { - action: action, - user: BasicUserSerializer.new(user, root: false).as_json, - emoji: emoji, - type: :reaction, - chat_message_id: chat_message.id, - } - MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) - end - - def self.publish_presence!(chat_channel, user, typ) - raise NotImplementedError - end - - def self.publish_delete!(chat_channel, chat_message) - MessageBus.publish( - "/chat/#{chat_channel.id}", - { type: "delete", deleted_id: chat_message.id, deleted_at: chat_message.deleted_at }, - permissions(chat_channel), - ) - end - - def self.publish_bulk_delete!(chat_channel, deleted_message_ids) - MessageBus.publish( - "/chat/#{chat_channel.id}", - { typ: "bulk_delete", deleted_ids: deleted_message_ids, deleted_at: Time.zone.now }, - permissions(chat_channel), - ) - end - - def self.publish_restore!(chat_channel, chat_message) - content = - ChatMessageSerializer.new( - chat_message, - { scope: anonymous_guardian, root: :chat_message }, - ).as_json - content[:type] = :restore - MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) - end - - def self.publish_flag!(chat_message, user, reviewable, score) - # Publish to user who created flag - MessageBus.publish( - "/chat/#{chat_message.chat_channel_id}", - { - type: "self_flagged", - user_flag_status: score.status_for_database, - chat_message_id: chat_message.id, - }.as_json, - user_ids: [user.id], - ) - - # Publish flag with link to reviewable to staff - MessageBus.publish( - "/chat/#{chat_message.chat_channel_id}", - { type: "flag", chat_message_id: chat_message.id, reviewable_id: reviewable.id }.as_json, - group_ids: [Group::AUTO_GROUPS[:staff]], - ) - end - - def self.user_tracking_state_message_bus_channel(user_id) - "/chat/user-tracking-state/#{user_id}" - end - - def self.publish_user_tracking_state(user, chat_channel_id, chat_message_id) - data = { chat_channel_id: chat_channel_id, chat_message_id: chat_message_id }.merge( - ChatChannelUnreadsQuery.call(channel_id: chat_channel_id, user_id: user.id), - ) - - MessageBus.publish( - self.user_tracking_state_message_bus_channel(user.id), - data.as_json, - user_ids: [user.id], - ) - end - - def self.new_mentions_message_bus_channel(chat_channel_id) - "/chat/#{chat_channel_id}/new-mentions" - end - - def self.publish_new_mention(user_id, chat_channel_id, chat_message_id) - MessageBus.publish( - self.new_mentions_message_bus_channel(chat_channel_id), - { message_id: chat_message_id, channel_id: chat_channel_id }.as_json, - user_ids: [user_id], - ) - end - - NEW_CHANNEL_MESSAGE_BUS_CHANNEL = "/chat/new-channel" - - def self.publish_new_channel(chat_channel, users) - users.each do |user| - # FIXME: This could generate a lot of queries depending on the amount of users - membership = chat_channel.membership_for(user) - - # TODO: this event is problematic as some code will update the membership before calling it - # and other code will update it after calling it - # it means frontend must handle logic for both cases - serialized_channel = - ChatChannelSerializer.new( - chat_channel, - scope: Guardian.new(user), # We need a guardian here for direct messages - root: :channel, - membership: membership, - ).as_json - - MessageBus.publish(NEW_CHANNEL_MESSAGE_BUS_CHANNEL, serialized_channel, user_ids: [user.id]) - end - end - - def self.publish_inaccessible_mentions( - user_id, - chat_message, - cannot_chat_users, - without_membership, - too_many_members, - mentions_disabled - ) - MessageBus.publish( - "/chat/#{chat_message.chat_channel_id}", - { - type: :mention_warning, - chat_message_id: chat_message.id, - cannot_see: cannot_chat_users.map { |u| { username: u.username, id: u.id } }.as_json, - without_membership: - without_membership.map { |u| { username: u.username, id: u.id } }.as_json, - groups_with_too_many_members: too_many_members.map(&:name).as_json, - group_mentions_disabled: mentions_disabled.map(&:name).as_json, - }, - user_ids: [user_id], - ) - end - - CHANNEL_EDITS_MESSAGE_BUS_CHANNEL = "/chat/channel-edits" - - def self.publish_chat_channel_edit(chat_channel, acting_user) - MessageBus.publish( - CHANNEL_EDITS_MESSAGE_BUS_CHANNEL, - { - chat_channel_id: chat_channel.id, - name: chat_channel.title(acting_user), - description: chat_channel.description, - slug: chat_channel.slug, - }, - permissions(chat_channel), - ) - end - - CHANNEL_STATUS_MESSAGE_BUS_CHANNEL = "/chat/channel-status" - - def self.publish_channel_status(chat_channel) - MessageBus.publish( - CHANNEL_STATUS_MESSAGE_BUS_CHANNEL, - { chat_channel_id: chat_channel.id, status: chat_channel.status }, - permissions(chat_channel), - ) - end - - CHANNEL_METADATA_MESSAGE_BUS_CHANNEL = "/chat/channel-metadata" - - def self.publish_chat_channel_metadata(chat_channel) - MessageBus.publish( - CHANNEL_METADATA_MESSAGE_BUS_CHANNEL, - { chat_channel_id: chat_channel.id, memberships_count: chat_channel.user_count }, - permissions(chat_channel), - ) - end - - CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL = "/chat/channel-archive-status" - - def self.publish_archive_status( - chat_channel, - archive_status:, - archived_messages:, - archive_topic_id:, - total_messages: - ) - MessageBus.publish( - CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL, - { - chat_channel_id: chat_channel.id, - archive_failed: archive_status == :failed, - archive_completed: archive_status == :success, - archived_messages: archived_messages, - total_messages: total_messages, - archive_topic_id: archive_topic_id, - }, - permissions(chat_channel), - ) - end - - private - - def self.permissions(chat_channel) - { user_ids: chat_channel.allowed_user_ids, group_ids: chat_channel.allowed_group_ids } - end - - def self.anonymous_guardian - Guardian.new(nil) - end -end diff --git a/plugins/chat/app/services/lookup_thread.rb b/plugins/chat/app/services/lookup_thread.rb deleted file mode 100644 index 9e4c3ca26c7..00000000000 --- a/plugins/chat/app/services/lookup_thread.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -module Chat - module Service - # Finds a thread within a channel. The thread_id and channel_id must - # match. For now we do not want to allow fetching threads if the - # enable_experimental_chat_threaded_discussions hidden site setting - # is not turned on, and the channel must specifically have threading - # enabled. - # - # @example - # Chat::Service::LookupThread.call(thread_id: 88, channel_id: 2, guardian: guardian) - # - class LookupThread - include Base - - # @!method call(thread_id:, channel_id:, guardian:) - # @param [Integer] thread_id - # @param [Integer] channel_id - # @param [Guardian] guardian - # @return [Chat::Service::Base::Context] - - policy :threaded_discussions_enabled - contract - model :thread, :fetch_thread - policy :invalid_access - policy :threading_enabled_for_channel - - # @!visibility private - class Contract - attribute :thread_id, :integer - attribute :channel_id, :integer - - validates :thread_id, :channel_id, presence: true - end - - private - - def threaded_discussions_enabled - SiteSetting.enable_experimental_chat_threaded_discussions - end - - def fetch_thread(contract:, **) - ChatThread.includes( - :channel, - original_message_user: :user_status, - original_message: :chat_webhook_event, - ).find_by(id: contract.thread_id, channel_id: contract.channel_id) - end - - def invalid_access(guardian:, thread:, **) - guardian.can_preview_chat_channel?(thread.channel) - end - - def threading_enabled_for_channel(thread:, **) - thread.channel.threading_enabled - end - end - end -end diff --git a/plugins/chat/app/services/service.rb b/plugins/chat/app/services/service.rb new file mode 100644 index 00000000000..c45e25eb0da --- /dev/null +++ b/plugins/chat/app/services/service.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Service + # Module to be included to provide steps DSL to any class. This allows to + # create easy to understand services as the whole service cycle is visible + # simply by reading the beginning of its class. + # + # Steps are executed in the order they’re defined. They will use their name + # to execute the corresponding method defined in the service class. + # + # Currently, there are 5 types of steps: + # + # * +contract(name = :default)+: used to validate the input parameters, + # typically provided by a user calling an endpoint. A special embedded + # +Contract+ class has to be defined to holds the validations. If the + # validations fail, the step will fail. Otherwise, the resulting contract + # will be available in +context[:contract]+. When calling +step(name)+ or + # +model(name = :model)+ methods after validating a contract, the contract + # should be used as an argument instead of context attributes. + # * +model(name = :model)+: used to instantiate a model (either by building + # it or fetching it from the DB). If a falsy value is returned, then the + # step will fail. Otherwise the resulting object will be assigned in + # +context[name]+ (+context[:model]+ by default). + # * +policy(name = :default)+: used to perform a check on the state of the + # system. Typically used to run guardians. If a falsy value is returned, + # the step will fail. + # * +step(name)+: used to run small snippets of arbitrary code. The step + # doesn’t care about its return value, so to mark the service as failed, + # {#fail!} has to be called explicitly. + # * +transaction+: used to wrap other steps inside a DB transaction. + # + # The methods defined on the service are automatically provided with + # the whole context passed as keyword arguments. This allows to define in a + # very explicit way what dependencies are used by the method. If for + # whatever reason a key isn’t found in the current context, then Ruby will + # raise an exception when the method is called. + # + # Regarding contract classes, they automatically have {ActiveModel} modules + # included so all the {ActiveModel} API is available. + # + # @example An example from the {TrashChannel} service + # class TrashChannel + # include Base + # + # model :channel, :fetch_channel + # policy :invalid_access + # transaction do + # step :prevents_slug_collision + # step :soft_delete_channel + # step :log_channel_deletion + # end + # step :enqueue_delete_channel_relations_job + # + # private + # + # def fetch_channel(channel_id:, **) + # Chat::Channel.find_by(id: channel_id) + # end + # + # def invalid_access(guardian:, channel:, **) + # guardian.can_preview_chat_channel?(channel) && guardian.can_delete_chat_channel? + # end + # + # def prevents_slug_collision(channel:, **) + # … + # end + # + # def soft_delete_channel(guardian:, channel:, **) + # … + # end + # + # def log_channel_deletion(guardian:, channel:, **) + # … + # end + # + # def enqueue_delete_channel_relations_job(channel:, **) + # … + # end + # end + # @example An example from the {UpdateChannelStatus} service which uses a contract + # class UpdateChannelStatus + # include Base + # + # model :channel, :fetch_channel + # contract + # policy :check_channel_permission + # step :change_status + # + # class Contract + # attribute :status + # validates :status, inclusion: { in: Chat::Channel.editable_statuses.keys } + # end + # + # … + # end +end diff --git a/plugins/chat/app/services/service/base.rb b/plugins/chat/app/services/service/base.rb new file mode 100644 index 00000000000..ccd35016cc8 --- /dev/null +++ b/plugins/chat/app/services/service/base.rb @@ -0,0 +1,336 @@ +# frozen_string_literal: true + +module Service + module Base + extend ActiveSupport::Concern + + # The only exception that can be raised by a service. + class Failure < StandardError + # @return [Context] + attr_reader :context + + # @!visibility private + def initialize(context = nil) + @context = context + super + end + end + + # Simple structure to hold the context of the service during its whole lifecycle. + class Context < OpenStruct + # @return [Boolean] returns +true+ if the conext is set as successful (default) + def success? + !failure? + end + + # @return [Boolean] returns +true+ if the context is set as failed + # @see #fail! + # @see #fail + def failure? + @failure || false + end + + # Marks the context as failed. + # @param context [Hash, Context] the context to merge into the current one + # @example + # context.fail!("failure": "something went wrong") + # @return [Context] + def fail!(context = {}) + fail(context) + raise Failure, self + end + + # Marks the context as failed without raising an exception. + # @param context [Hash, Context] the context to merge into the current one + # @example + # context.fail("failure": "something went wrong") + # @return [Context] + def fail(context = {}) + merge(context) + @failure = true + self + end + + # Merges the given context into the current one. + # @!visibility private + def merge(other_context = {}) + other_context.each { |key, value| self[key.to_sym] = value } + self + end + + private + + def self.build(context = {}) + self === context ? context : new(context) + end + end + + # Internal module to define available steps as DSL + # @!visibility private + module StepsHelpers + def model(name = :model, step_name = :"fetch_#{name}") + steps << ModelStep.new(name, step_name) + end + + def contract(name = :default, class_name: self::Contract, default_values_from: nil) + steps << ContractStep.new( + name, + class_name: class_name, + default_values_from: default_values_from, + ) + end + + def policy(name = :default) + steps << PolicyStep.new(name) + end + + def step(name) + steps << Step.new(name) + end + + def transaction(&block) + steps << TransactionStep.new(&block) + end + end + + # @!visibility private + class Step + attr_reader :name, :method_name, :class_name + + def initialize(name, method_name = name, class_name: nil) + @name = name + @method_name = method_name + @class_name = class_name + end + + def call(instance, context) + method = instance.method(method_name) + args = {} + args = context.to_h if method.arity.nonzero? + context[result_key] = Context.build + instance.instance_exec(**args, &method) + end + + private + + def type + self.class.name.split("::").last.downcase.sub(/^(\w+)step$/, "\\1") + end + + def result_key + "result.#{type}.#{name}" + end + end + + # @!visibility private + class ModelStep < Step + def call(instance, context) + context[name] = super + raise ArgumentError, "Model not found" if context[name].blank? + rescue ArgumentError => exception + context[result_key].fail(exception: exception) + context.fail! + end + end + + # @!visibility private + class PolicyStep < Step + def call(instance, context) + if !super + context[result_key].fail + context.fail! + end + end + end + + # @!visibility private + class ContractStep < Step + attr_reader :default_values_from + + def initialize(name, method_name = name, class_name: nil, default_values_from: nil) + super(name, method_name, class_name: class_name) + @default_values_from = default_values_from + end + + def call(instance, context) + attributes = class_name.attribute_names.map(&:to_sym) + default_values = {} + default_values = context[default_values_from].slice(*attributes) if default_values_from + contract = class_name.new(default_values.merge(context.to_h.slice(*attributes))) + context[contract_name] = contract + context[result_key] = Context.build + if contract.invalid? + context[result_key].fail(errors: contract.errors) + context.fail! + end + end + + private + + def contract_name + return :contract if name.to_sym == :default + :"#{name}_contract" + end + end + + # @!visibility private + class TransactionStep < Step + include StepsHelpers + + attr_reader :steps + + def initialize(&block) + @steps = [] + instance_exec(&block) + end + + def call(instance, context) + ActiveRecord::Base.transaction { steps.each { |step| step.call(instance, context) } } + end + end + + included do + # The global context which is available from any step. + attr_reader :context + + # @!visibility private + # Internal class used to setup the base contract of the service. + self::Contract = + Class.new do + include ActiveModel::API + include ActiveModel::Attributes + include ActiveModel::AttributeMethods + include ActiveModel::Validations::Callbacks + end + end + + class_methods do + include StepsHelpers + + def call(context = {}) + new(context).tap(&:run).context + end + + def call!(context = {}) + new(context).tap(&:run!).context + end + + def steps + @steps ||= [] + end + end + + # @!scope class + # @!method model(name = :model, step_name = :"fetch_#{name}") + # @param name [Symbol] name of the model + # @param step_name [Symbol] name of the method to call for this step + # Evaluates arbitrary code to build or fetch a model (typically from the + # DB). If the step returns a falsy value, then the step will fail. + # + # It stores the resulting model in +context[:model]+ by default (can be + # customized by providing the +name+ argument). + # + # @example + # model :channel, :fetch_channel + # + # private + # + # def fetch_channel(channel_id:, **) + # Chat::Channel.find_by(id: channel_id) + # end + + # @!scope class + # @!method policy(name = :default) + # @param name [Symbol] name for this policy + # Performs checks related to the state of the system. If the + # step doesn’t return a truthy value, then the policy will fail. + # + # @example + # policy :no_direct_message_channel + # + # private + # + # def no_direct_message_channel(channel:, **) + # !channel.direct_message_channel? + # end + + # @!scope class + # @!method contract(name = :default, class_name: self::Contract, default_values_from: nil) + # @param name [Symbol] name for this contract + # @param class_name [Class] a class defining the contract + # @param default_values_from [Symbol] name of the model to get default values from + # Checks the validity of the input parameters. + # Implements ActiveModel::Validations and ActiveModel::Attributes. + # + # It stores the resulting contract in +context[:contract]+ by default + # (can be customized by providing the +name+ argument). + # + # @example + # contract + # + # class Contract + # attribute :name + # validates :name, presence: true + # end + + # @!scope class + # @!method step(name) + # @param name [Symbol] the name of this step + # Runs arbitrary code. To mark a step as failed, a call to {#fail!} needs + # to be made explicitly. + # + # @example + # step :update_channel + # + # private + # + # def update_channel(channel:, params_to_edit:, **) + # channel.update!(params_to_edit) + # end + # @example using {#fail!} in a step + # step :save_channel + # + # private + # + # def save_channel(channel:, **) + # fail!("something went wrong") if !channel.save + # end + + # @!scope class + # @!method transaction(&block) + # @param block [Proc] a block containing steps to be run inside a transaction + # Runs steps inside a DB transaction. + # + # @example + # transaction do + # step :prevents_slug_collision + # step :soft_delete_channel + # step :log_channel_deletion + # end + + # @!visibility private + def initialize(initial_context = {}) + @initial_context = initial_context.with_indifferent_access + @context = Context.build(initial_context.merge(__steps__: self.class.steps)) + end + + # @!visibility private + def run + run! + rescue Failure => exception + raise if context.object_id != exception.context.object_id + end + + # @!visibility private + def run! + self.class.steps.each { |step| step.call(self, context) } + end + + # @!visibility private + def fail!(message) + step_name = caller_locations(1, 1)[0].label + context["result.step.#{step_name}"].fail(error: message) + context.fail! + end + end +end diff --git a/plugins/chat/app/services/trash_channel.rb b/plugins/chat/app/services/trash_channel.rb deleted file mode 100644 index 48dde07f17f..00000000000 --- a/plugins/chat/app/services/trash_channel.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -module Chat - module Service - # Service responsible for trashing a chat channel. - # Note the slug is modified to prevent collisions. - # - # @example - # Chat::Service::TrashChannel.call(channel_id: 2, guardian: guardian) - # - class TrashChannel - include Base - - # @!method call(channel_id:, guardian:) - # @param [Integer] channel_id - # @param [Guardian] guardian - # @return [Chat::Service::Base::Context] - - DELETE_CHANNEL_LOG_KEY = "chat_channel_delete" - - model :channel, :fetch_channel - policy :invalid_access - transaction do - step :prevents_slug_collision - step :soft_delete_channel - step :log_channel_deletion - end - step :enqueue_delete_channel_relations_job - - private - - def fetch_channel(channel_id:, **) - ChatChannel.find_by(id: channel_id) - end - - def invalid_access(guardian:, channel:, **) - guardian.can_preview_chat_channel?(channel) && guardian.can_delete_chat_channel? - end - - def prevents_slug_collision(channel:, **) - channel.update!( - slug: - "#{Time.current.strftime("%Y%m%d-%H%M")}-#{channel.slug}-deleted".truncate( - SiteSetting.max_topic_title_length, - omission: "", - ), - ) - end - - def soft_delete_channel(guardian:, channel:, **) - channel.trash!(guardian.user) - end - - def log_channel_deletion(guardian:, channel:, **) - StaffActionLogger.new(guardian.user).log_custom( - DELETE_CHANNEL_LOG_KEY, - { chat_channel_id: channel.id, chat_channel_name: channel.title(guardian.user) }, - ) - end - - def enqueue_delete_channel_relations_job(channel:, **) - Jobs.enqueue(:chat_channel_delete, chat_channel_id: channel.id) - end - end - end -end diff --git a/plugins/chat/app/services/update_channel.rb b/plugins/chat/app/services/update_channel.rb deleted file mode 100644 index bcbb59b101e..00000000000 --- a/plugins/chat/app/services/update_channel.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -module Chat - module Service - # Service responsible for updating a chat channel's name, slug, and description. - # - # For a CategoryChannel, the settings for auto_join_users and allow_channel_wide_mentions - # are also editable. - # - # @example - # Chat::Service::UpdateChannel.call( - # channel_id: 2, - # guardian: guardian, - # name: "SuperChannel", - # description: "This is the best channel", - # slug: "super-channel", - # ) - # - class UpdateChannel - include Base - - # @!method call(channel_id:, guardian:, **params_to_edit) - # @param [Integer] channel_id - # @param [Guardian] guardian - # @param [Hash] params_to_edit - # @option params_to_edit [String,nil] name - # @option params_to_edit [String,nil] description - # @option params_to_edit [String,nil] slug - # @option params_to_edit [Boolean] auto_join_users Only valid for {CategoryChannel}. Whether active users - # with permission to see the category should automatically join the channel. - # @option params_to_edit [Boolean] allow_channel_wide_mentions Allow the use of @here and @all in the channel. - # @return [Chat::Service::Base::Context] - - model :channel, :fetch_channel - policy :no_direct_message_channel - policy :check_channel_permission - contract default_values_from: :channel - step :update_channel - step :publish_channel_update - step :auto_join_users_if_needed - - # @!visibility private - class Contract - attribute :name, :string - attribute :description, :string - attribute :slug, :string - attribute :auto_join_users, :boolean, default: false - attribute :allow_channel_wide_mentions, :boolean, default: true - - before_validation do - assign_attributes( - attributes - .symbolize_keys - .slice(:name, :description, :slug) - .transform_values(&:presence), - ) - end - end - - private - - def fetch_channel(channel_id:, **) - ChatChannel.find_by(id: channel_id) - end - - def no_direct_message_channel(channel:, **) - !channel.direct_message_channel? - end - - def check_channel_permission(guardian:, channel:, **) - guardian.can_preview_chat_channel?(channel) && guardian.can_edit_chat_channel? - end - - def update_channel(channel:, contract:, **) - channel.update!(contract.attributes) - end - - def publish_channel_update(channel:, guardian:, **) - ChatPublisher.publish_chat_channel_edit(channel, guardian.user) - end - - def auto_join_users_if_needed(channel:, **) - return unless channel.auto_join_users? - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships - end - end - end -end diff --git a/plugins/chat/app/services/update_channel_status.rb b/plugins/chat/app/services/update_channel_status.rb deleted file mode 100644 index e11e80c6eed..00000000000 --- a/plugins/chat/app/services/update_channel_status.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module Chat - module Service - # Service responsible for updating a chat channel status. - # - # @example - # Chat::Service::UpdateChannelStatus.call(channel_id: 2, guardian: guardian, status: "open") - # - class UpdateChannelStatus - include Base - - # @!method call(channel_id:, guardian:, status:) - # @param [Integer] channel_id - # @param [Guardian] guardian - # @param [String] status - # @return [Chat::Service::Base::Context] - - model :channel, :fetch_channel - contract - policy :check_channel_permission - step :change_status - - # @!visibility private - class Contract - attribute :status - validates :status, inclusion: { in: ChatChannel.editable_statuses.keys } - end - - private - - def fetch_channel(channel_id:, **) - ChatChannel.find_by(id: channel_id) - end - - def check_channel_permission(guardian:, channel:, status:, **) - guardian.can_preview_chat_channel?(channel) && - guardian.can_change_channel_status?(channel, status.to_sym) - end - - def change_status(channel:, status:, guardian:, **) - channel.public_send("#{status}!", guardian.user) - end - end - end -end diff --git a/plugins/chat/app/services/update_user_last_read.rb b/plugins/chat/app/services/update_user_last_read.rb deleted file mode 100644 index ad9e90dc6ca..00000000000 --- a/plugins/chat/app/services/update_user_last_read.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -module Chat - module Service - # Service responsible for updating the last read message id of a membership. - # - # @example - # Chat::Service::UpdateUserLastRead.call(user_id: 1, channel_id: 2, message_id: 3, guardian: guardian) - # - class UpdateUserLastRead - include Base - - # @!method call(user_id:, channel_id:, message_id:, guardian:) - # @param [Integer] user_id - # @param [Integer] channel_id - # @param [Integer] message_id - # @param [Guardian] guardian - # @return [Chat::Service::Base::Context] - - contract - model :membership, :fetch_active_membership - policy :invalid_access - policy :ensure_message_id_recency - policy :ensure_message_exists - step :update_last_read_message_id - step :mark_associated_mentions_as_read - step :publish_new_last_read_to_clients - - # @!visibility private - class Contract - attribute :message_id, :integer - attribute :user_id, :integer - attribute :channel_id, :integer - - validates :message_id, :user_id, :channel_id, presence: true - end - - private - - def fetch_active_membership(user_id:, channel_id:, **) - UserChatChannelMembership.includes(:user, :chat_channel).find_by( - user_id: user_id, - chat_channel_id: channel_id, - following: true, - ) - end - - def invalid_access(guardian:, membership:, **) - guardian.can_join_chat_channel?(membership.chat_channel) - end - - def ensure_message_id_recency(message_id:, membership:, **) - !membership.last_read_message_id || message_id >= membership.last_read_message_id - end - - def ensure_message_exists(channel_id:, message_id:, **) - ChatMessage.with_deleted.exists?(chat_channel_id: channel_id, id: message_id) - end - - def update_last_read_message_id(message_id:, membership:, **) - membership.update!(last_read_message_id: message_id) - end - - def mark_associated_mentions_as_read(membership:, message_id:, **) - Notification - .where(notification_type: Notification.types[:chat_mention]) - .where(user: membership.user) - .where(read: false) - .joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id") - .joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id") - .where("chat_messages.id <= ?", message_id) - .where("chat_messages.chat_channel_id = ?", membership.chat_channel.id) - .update_all(read: true) - end - - def publish_new_last_read_to_clients(guardian:, channel_id:, message_id:, **) - ChatPublisher.publish_user_tracking_state(guardian.user, channel_id, message_id) - end - end - end -end diff --git a/plugins/chat/app/validators/chat/allow_uploads_validator.rb b/plugins/chat/app/validators/chat/allow_uploads_validator.rb new file mode 100644 index 00000000000..df859b53f62 --- /dev/null +++ b/plugins/chat/app/validators/chat/allow_uploads_validator.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Chat + class AllowUploadsValidator + 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 +end diff --git a/plugins/chat/app/validators/chat/default_channel_validator.rb b/plugins/chat/app/validators/chat/default_channel_validator.rb new file mode 100644 index 00000000000..c8f23893851 --- /dev/null +++ b/plugins/chat/app/validators/chat/default_channel_validator.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Chat + class DefaultChannelValidator + def initialize(opts = {}) + @opts = opts + end + + def valid_value?(value) + !!(value == "" || Chat::Channel.find_by(id: value.to_i)&.public_channel?) + end + + def error_message + I18n.t("site_settings.errors.chat_default_channel") + end + end +end diff --git a/plugins/chat/app/validators/chat/direct_message_enabled_groups_validator.rb b/plugins/chat/app/validators/chat/direct_message_enabled_groups_validator.rb new file mode 100644 index 00000000000..f56fadf5dde --- /dev/null +++ b/plugins/chat/app/validators/chat/direct_message_enabled_groups_validator.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Chat + 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 +end diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.js b/plugins/chat/assets/javascripts/discourse/components/chat-message.js index 14534d88d83..eb8f13ef4c2 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.js @@ -614,7 +614,7 @@ export default class ChatMessage extends Component { this.args.message.bookmark || Bookmark.createFor( this.currentUser, - "ChatMessage", + "Chat::Message", this.args.message.id ), { diff --git a/plugins/chat/assets/stylesheets/common/common.scss b/plugins/chat/assets/stylesheets/common/base-common.scss similarity index 100% rename from plugins/chat/assets/stylesheets/common/common.scss rename to plugins/chat/assets/stylesheets/common/base-common.scss diff --git a/plugins/chat/assets/stylesheets/common/chat-message.scss b/plugins/chat/assets/stylesheets/common/chat-message.scss index 286fd52a97a..2ee5de37395 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message.scss @@ -14,47 +14,6 @@ } } -@mixin chat-reaction { - align-items: center; - display: inline-flex; - padding: 0.3em 0.6em; - margin: 1px 0.25em 1px 0; - font-size: var(--font-down-2); - border-radius: 4px; - border: 1px solid var(--primary-low); - background: transparent; - cursor: pointer; - user-select: none; - transition: background 0.2s, border-color 0.2s; - - &.reacted { - border-color: var(--tertiary-medium); - background: var(--tertiary-very-low); - color: var(--tertiary-hover); - - &:hover { - background: var(--tertiary-low); - } - } - - &:not(.reacted) { - &:hover { - background: var(--primary-low); - border-color: var(--primary-low-mid); - } - - &:focus { - background: none; - } - } - - .emoji { - height: 15px; - margin-right: 4px; - width: auto; - } -} - .chat-message { align-items: flex-start; padding: 0.25em 0.5em 0.25em 0.75em; diff --git a/plugins/chat/assets/stylesheets/common/index.scss b/plugins/chat/assets/stylesheets/common/index.scss new file mode 100644 index 00000000000..ec8e8c2d6b6 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/index.scss @@ -0,0 +1,45 @@ +@import "base-common"; +@import "sidebar-extensions"; +@import "chat-browse"; +@import "chat-channel-card"; +@import "chat-channel-info"; +@import "chat-channel-preview-card"; +@import "chat-channel-selector-modal"; +@import "chat-channel-settings-saved-indicator"; +@import "chat-channel-title"; +@import "chat-composer-dropdown"; +@import "chat-composer-inline-button"; +@import "chat-composer-upload"; +@import "chat-composer-uploads"; +@import "chat-composer"; +@import "chat-draft-channel"; +@import "chat-drawer"; +@import "chat-emoji-picker"; +@import "chat-form"; +@import "chat-index"; +@import "chat-mention-warnings"; +@import "chat-message-actions"; +@import "chat-message-collapser"; +@import "chat-message-images"; +@import "chat-message-info"; +@import "chat-message-left-gutter"; +@import "chat-message-separator"; +@import "chat-message"; +@import "chat-onebox"; +@import "chat-reply"; +@import "chat-replying-indicator"; +@import "chat-retention-reminder"; +@import "chat-selection-manager"; +@import "chat-side-panel"; +@import "chat-skeleton"; +@import "chat-tabs"; +@import "chat-thread"; +@import "chat-transcript"; +@import "core-extensions"; +@import "create-channel-modal"; +@import "d-progress-bar"; +@import "dc-filter-input"; +@import "direct-message-creator"; +@import "full-page-chat-header"; +@import "incoming-chat-webhooks"; +@import "reviewable-chat-message"; diff --git a/plugins/chat/assets/stylesheets/sidebar-extensions.scss b/plugins/chat/assets/stylesheets/common/sidebar-extensions.scss similarity index 100% rename from plugins/chat/assets/stylesheets/sidebar-extensions.scss rename to plugins/chat/assets/stylesheets/common/sidebar-extensions.scss diff --git a/plugins/chat/assets/stylesheets/desktop/desktop.scss b/plugins/chat/assets/stylesheets/desktop/base-desktop.scss similarity index 100% rename from plugins/chat/assets/stylesheets/desktop/desktop.scss rename to plugins/chat/assets/stylesheets/desktop/base-desktop.scss diff --git a/plugins/chat/assets/stylesheets/desktop/index.scss b/plugins/chat/assets/stylesheets/desktop/index.scss new file mode 100644 index 00000000000..e08babb76a8 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/index.scss @@ -0,0 +1,9 @@ +@import "base-desktop"; +@import "chat-channel-title"; +@import "chat-composer-uploads"; +@import "chat-composer"; +@import "chat-index-drawer"; +@import "chat-index-full-page"; +@import "chat-message-actions"; +@import "chat-message"; +@import "sidebar-extensions"; diff --git a/plugins/chat/assets/stylesheets/mixins/chat-reaction.scss b/plugins/chat/assets/stylesheets/mixins/chat-reaction.scss new file mode 100644 index 00000000000..233423be031 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mixins/chat-reaction.scss @@ -0,0 +1,40 @@ +@mixin chat-reaction { + align-items: center; + display: inline-flex; + padding: 0.3em 0.6em; + margin: 1px 0.25em 1px 0; + font-size: var(--font-down-2); + border-radius: 4px; + border: 1px solid var(--primary-low); + background: transparent; + cursor: pointer; + user-select: none; + transition: background 0.2s, border-color 0.2s; + + &.reacted { + border-color: var(--tertiary-medium); + background: var(--tertiary-very-low); + color: var(--tertiary-hover); + + &:hover { + background: var(--tertiary-low); + } + } + + &:not(.reacted) { + &:hover { + background: var(--primary-low); + border-color: var(--primary-low-mid); + } + + &:focus { + background: none; + } + } + + .emoji { + height: 15px; + margin-right: 4px; + width: auto; + } +} diff --git a/plugins/chat/assets/stylesheets/mixins/index.scss b/plugins/chat/assets/stylesheets/mixins/index.scss new file mode 100644 index 00000000000..82e3451dc32 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mixins/index.scss @@ -0,0 +1,2 @@ +@import "chat-scrollbar"; +@import "chat-reaction"; diff --git a/plugins/chat/assets/stylesheets/mobile/mobile.scss b/plugins/chat/assets/stylesheets/mobile/base-mobile.scss similarity index 100% rename from plugins/chat/assets/stylesheets/mobile/mobile.scss rename to plugins/chat/assets/stylesheets/mobile/base-mobile.scss diff --git a/plugins/chat/assets/stylesheets/mobile/index.scss b/plugins/chat/assets/stylesheets/mobile/index.scss new file mode 100644 index 00000000000..752b6567d00 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/index.scss @@ -0,0 +1,7 @@ +@import "base-mobile"; +@import "chat-channel-info"; +@import "chat-composer"; +@import "chat-index"; +@import "chat-message-actions"; +@import "chat-message"; +@import "chat-selection-manager"; diff --git a/plugins/chat/config/routes.rb b/plugins/chat/config/routes.rb new file mode 100644 index 00000000000..0c66fac78eb --- /dev/null +++ b/plugins/chat/config/routes.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +Chat::Engine.routes.draw do + namespace :api, defaults: { format: :json } do + get "/chatables" => "chatables#index" + get "/channels" => "channels#index" + get "/channels/me" => "current_user_channels#index" + post "/channels" => "channels#create" + delete "/channels/:channel_id" => "channels#destroy" + put "/channels/:channel_id" => "channels#update" + get "/channels/:channel_id" => "channels#show" + put "/channels/:channel_id/status" => "channels_status#update" + post "/channels/:channel_id/messages/moves" => "channels_messages_moves#create" + post "/channels/:channel_id/archives" => "channels_archives#create" + get "/channels/:channel_id/memberships" => "channels_memberships#index" + delete "/channels/:channel_id/memberships/me" => "channels_current_user_membership#destroy" + post "/channels/:channel_id/memberships/me" => "channels_current_user_membership#create" + put "/channels/:channel_id/notifications-settings/me" => + "channels_current_user_notifications_settings#update" + + # Category chatables controller hints. Only used by staff members, we don't want to leak category permissions. + get "/category-chatables/:id/permissions" => "category_chatables#permissions", + :format => :json, + :constraints => StaffConstraint.new + + # Hints for JIT warnings. + get "/mentions/groups" => "hints#check_group_mentions", :format => :json + + get "/channels/:channel_id/threads/:thread_id" => "channel_threads#show" + end + + # direct_messages_controller routes + get "/direct_messages" => "direct_messages#index" + post "/direct_messages/create" => "direct_messages#create" + + # incoming_webhooks_controller routes + post "/hooks/:key" => "incoming_webhooks#create_message" + + # incoming_webhooks_controller routes + post "/hooks/:key/slack" => "incoming_webhooks#create_message_slack_compatible" + + # chat_controller routes + get "/" => "chat#respond" + get "/browse" => "chat#respond" + get "/browse/all" => "chat#respond" + get "/browse/closed" => "chat#respond" + get "/browse/open" => "chat#respond" + get "/browse/archived" => "chat#respond" + get "/draft-channel" => "chat#respond" + post "/enable" => "chat#enable_chat" + post "/disable" => "chat#disable_chat" + post "/dismiss-retention-reminder" => "chat#dismiss_retention_reminder" + get "/:chat_channel_id/messages" => "chat#messages" + get "/message/:message_id" => "chat#message_link" + put ":chat_channel_id/edit/:message_id" => "chat#edit_message" + put ":chat_channel_id/react/:message_id" => "chat#react" + delete "/:chat_channel_id/:message_id" => "chat#delete" + put "/:chat_channel_id/:message_id/rebake" => "chat#rebake" + post "/:chat_channel_id/:message_id/flag" => "chat#flag" + post "/:chat_channel_id/quote" => "chat#quote_messages" + put "/:chat_channel_id/restore/:message_id" => "chat#restore" + get "/lookup/:message_id" => "chat#lookup_message" + put "/:chat_channel_id/read/:message_id" => "chat#update_user_last_read" + put "/user_chat_enabled/:user_id" => "chat#set_user_chat_status" + put "/:chat_channel_id/invite" => "chat#invite_users" + post "/drafts" => "chat#set_draft" + post "/:chat_channel_id" => "chat#create_message" + put "/flag" => "chat#flag" + get "/emojis" => "emojis#index" + + base_c_route = "/c/:channel_title/:channel_id" + get base_c_route => "chat#respond", :as => "channel" + get "#{base_c_route}/:message_id" => "chat#respond" + + %w[info info/about info/members info/settings].each do |route| + get "#{base_c_route}/#{route}" => "chat#respond" + end + + # /channel -> /c redirects + get "/channel/:channel_id", to: redirect("/chat/c/-/%{channel_id}") + + get "#{base_c_route}/t/:thread_id" => "chat#respond" + + base_channel_route = "/channel/:channel_id/:channel_title" + redirect_base = "/chat/c/%{channel_title}/%{channel_id}" + + get base_channel_route, to: redirect(redirect_base) + + %w[info info/about info/members info/settings].each do |route| + get "#{base_channel_route}/#{route}", to: redirect("#{redirect_base}/#{route}") + end +end diff --git a/plugins/chat/config/settings.yml b/plugins/chat/config/settings.yml index e62299b59cc..a1947fc499e 100644 --- a/plugins/chat/config/settings.yml +++ b/plugins/chat/config/settings.yml @@ -59,7 +59,7 @@ chat: chat_default_channel_id: default: "" client: true - validator: "ChatDefaultChannelValidator" + validator: "Chat::DefaultChannelValidator" chat_duplicate_message_sensitivity: type: float default: 0.5 @@ -85,7 +85,7 @@ chat: chat_allow_uploads: default: true client: true - validator: "ChatAllowUploadsValidator" + validator: "Chat::AllowUploadsValidator" max_chat_auto_joined_users: min: 0 default: 10000 @@ -97,7 +97,7 @@ chat: client: true allow_any: false refresh: true - validator: "DirectMessageEnabledGroupsValidator" + validator: "Chat::DirectMessageEnabledGroupsValidator" chat_message_flag_allowed_groups: default: "11" # @trust_level_1 type: group_list diff --git a/plugins/chat/db/fixtures/600_chat_channels.rb b/plugins/chat/db/fixtures/600_chat_channels.rb index 972398ba7f0..63b55a19cc3 100644 --- a/plugins/chat/db/fixtures/600_chat_channels.rb +++ b/plugins/chat/db/fixtures/600_chat_channels.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -ChatSeeder.new.execute if !Rails.env.test? +Chat::Seeder.new.execute if !Rails.env.test? diff --git a/plugins/chat/lib/chat/bookmark_extension.rb b/plugins/chat/lib/chat/bookmark_extension.rb new file mode 100644 index 00000000000..31bca99444a --- /dev/null +++ b/plugins/chat/lib/chat/bookmark_extension.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Chat + module BookmarkExtension + extend ActiveSupport::Concern + + prepended do + def valid_bookmarkable_type + return true if self.bookmarkable_type == Chat::Message.sti_name + super if defined?(super) + end + + CLASS_MAPPING = { "ChatMessage" => Chat::Message } + + # the model used when loading chatable_type column + def self.polymorphic_class_for(name) + return CLASS_MAPPING[name] if CLASS_MAPPING.key?(name) + super if defined?(super) + end + end + end +end diff --git a/plugins/chat/lib/chat/category_extension.rb b/plugins/chat/lib/chat/category_extension.rb new file mode 100644 index 00000000000..eb7b3fd8496 --- /dev/null +++ b/plugins/chat/lib/chat/category_extension.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Chat + module CategoryExtension + extend ActiveSupport::Concern + + include Chat::Chatable + + def self.polymorphic_name + Chat::Chatable.polymorphic_name_for(self) || super + end + + prepended do + has_one :category_channel, + as: :chatable, + class_name: "Chat::CategoryChannel", + dependent: :destroy + end + + def cannot_delete_reason + return I18n.t("category.cannot_delete.has_chat_channels") if category_channel + super + end + + def deletable_for_chat? + return true if !category_channel + category_channel.chat_messages_empty? + end + end +end diff --git a/plugins/chat/lib/chat/channel_archive_service.rb b/plugins/chat/lib/chat/channel_archive_service.rb new file mode 100644 index 00000000000..d3d7a349588 --- /dev/null +++ b/plugins/chat/lib/chat/channel_archive_service.rb @@ -0,0 +1,310 @@ +# frozen_string_literal: true + +## +# From time to time, site admins may choose to sunset a chat channel and archive +# the messages within. 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. +module Chat + class ChannelArchiveService + ARCHIVED_MESSAGES_PER_POST = 100 + + class ArchiveValidationError < StandardError + attr_reader :errors + + def initialize(errors: []) + super + @errors = errors + end + end + + def self.create_archive_process(chat_channel:, acting_user:, topic_params:) + return if Chat::ChannelArchive.exists?(chat_channel: chat_channel) + + # Only need to validate topic params for a new topic, not an existing one. + if topic_params[:topic_id].blank? + valid, errors = + Chat::ChannelArchiveService.validate_topic_params(Guardian.new(acting_user), topic_params) + + raise ArchiveValidationError.new(errors: errors) if !valid + end + + Chat::ChannelArchive.transaction do + chat_channel.read_only!(acting_user) + + archive = + Chat::ChannelArchive.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(Jobs::Chat::ChannelArchive, 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( + Jobs::Chat::ChannelArchive, + chat_channel_archive_id: chat_channel.chat_channel_archive.id, + ) + chat_channel.chat_channel_archive + end + + def self.validate_topic_params(guardian, topic_params) + topic_creator = + TopicCreator.new( + Discourse.system_user, + guardian, + { + title: topic_params[:topic_title], + category: topic_params[:category_id], + tags: topic_params[:tags], + import_mode: true, + }, + ) + [topic_creator.valid?, topic_creator.errors.full_messages] + 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 + return if !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( + Chat::TranscriptService.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_message: err.message) + 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, + }, + ) + + if topic_creator.valid? + chat_channel_archive.update!(destination_topic: topic_creator.create) + else + Rails.logger.info("Destination topic for #{chat_channel_title} archive was not valid.") + notify_archiver( + :failed_no_topic, + error_message: topic_creator.errors.full_messages.join("\n"), + ) + end + end + + if chat_channel_archive.destination_topic.present? + 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, + ), + ) + end + else + Rails.logger.info("Topic already exists for #{chat_channel_title} archive.") + end + + if chat_channel_archive.destination_topic.present? + update_destination_topic_status + return true + end + + false + 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.new_topic? + 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) + Chat::Message.transaction do + Chat::Message.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_message: nil) + base_translation_params = { + channel_hashtag_or_name: channel_hashtag_or_name, + topic_title: chat_channel_archive.destination_topic&.title, + topic_url: chat_channel_archive.destination_topic&.url, + topic_validation_errors: result == :failed_no_topic ? error_message : nil, + } + + if result == :failed || result == :failed_no_topic + Discourse.warn_exception( + error_message, + 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) + message_translation_key = + case result + when :failed + :chat_channel_archive_failed + when :failed_no_topic + :chat_channel_archive_failed_no_topic + end + SystemMessage.create_from_system_user( + chat_channel_archive.archived_by, + message_translation_key, + error_translation_params, + ) + else + SystemMessage.create_from_system_user( + chat_channel_archive.archived_by, + :chat_channel_archive_complete, + base_translation_params, + ) + end + + Chat::Publisher.publish_archive_status( + chat_channel, + archive_status: result != :success ? :failed : :success, + 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::ChannelMembershipManager.new(chat_channel).unfollow_all_users + end + + def channel_hashtag_or_name + if chat_channel.slug.present? && SiteSetting.enable_experimental_hashtag_autocomplete + return "##{chat_channel.slug}::channel" + end + chat_channel_title + end + end +end diff --git a/plugins/chat/lib/chat/channel_fetcher.rb b/plugins/chat/lib/chat/channel_fetcher.rb new file mode 100644 index 00000000000..852185ef4e0 --- /dev/null +++ b/plugins/chat/lib/chat/channel_fetcher.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + +module Chat + class ChannelFetcher + MAX_PUBLIC_CHANNEL_RESULTS = 50 + + def self.structured(guardian) + memberships = Chat::ChannelMembershipManager.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, exclude_dm_channels: false) + category_channel_sql = + Category + .post_create_allowed(guardian) + .joins( + "INNER JOIN chat_channels ON chat_channels.chatable_id = categories.id AND chat_channels.chatable_type = 'Category'", + ) + .select("chat_channels.id") + .to_sql + dm_channel_sql = "" + if !exclude_dm_channels + dm_channel_sql = <<~SQL + UNION + + -- secured direct message chat channels + #{ + Chat::Channel + .select(:id) + .joins( + "INNER JOIN direct_message_channels ON direct_message_channels.id = chat_channels.chatable_id + AND chat_channels.chatable_type = 'DirectMessage' + 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 + + <<~SQL + -- secured category chat channels + #{category_channel_sql} + #{dm_channel_sql} + SQL + end + + def self.secured_public_channel_slug_lookup(guardian, slugs) + allowed_channel_ids = generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) + + Chat::Channel + .joins( + "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + .where(chatable_type: Chat::Channel.public_channel_chatable_types) + .where("chat_channels.id IN (#{allowed_channel_ids})") + .where("chat_channels.slug IN (:slugs)", slugs: slugs) + .limit(1) + end + + def self.secured_public_channel_search(guardian, options = {}) + allowed_channel_ids = generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) + + channels = Chat::Channel.includes(chatable: [:topic_only_relative_url]) + channels = channels.includes(:chat_channel_archive) if options[:include_archives] + + channels = + channels + .joins( + "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + .where(chatable_type: Chat::Channel.public_channel_chatable_types) + .where("chat_channels.id IN (#{allowed_channel_ids})") + + channels = channels.where(status: options[:status]) if options[:status].present? + + if options[:filter].present? + category_filter = + (options[:filter_on_category_name] ? "OR categories.name ILIKE :filter" : "") + + sql = + "chat_channels.name ILIKE :filter OR chat_channels.slug ILIKE :filter #{category_filter}" + if options[:match_filter_on_starts_with] + filter_sql = "#{options[:filter].downcase}%" + else + filter_sql = "%#{options[:filter].downcase}%" + end + + channels = + channels.where(sql, filter: filter_sql).order( + "chat_channels.name ASC, categories.name ASC", + ) + end + + if options.key?(:slugs) + channels = channels.where("chat_channels.slug IN (:slugs)", slugs: options[:slugs]) + 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.merge(include_archives: true, filter_on_category_name: true), + ) + + 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" || c.chatable_type == "category" } + .map(&:chatable), + preload_fields, + ) + end + + def self.secured_direct_message_channels(user_id, memberships, guardian) + query = Chat::Channel.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: Chat::Channel.direct_channel_chatable_types) + .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 = + Chat::Channel.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_join_chat_channel?(chat_channel) + chat_channel + end + end +end diff --git a/plugins/chat/lib/chat/channel_hashtag_data_source.rb b/plugins/chat/lib/chat/channel_hashtag_data_source.rb new file mode 100644 index 00000000000..f97b1e7334c --- /dev/null +++ b/plugins/chat/lib/chat/channel_hashtag_data_source.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Chat + class ChannelHashtagDataSource + def self.icon + "comment" + end + + def self.type + "channel" + end + + def self.channel_to_hashtag_item(guardian, channel) + HashtagAutocompleteService::HashtagItem.new.tap do |item| + item.text = channel.title + item.description = channel.description + item.slug = channel.slug + item.icon = icon + item.relative_url = channel.relative_url + item.type = "channel" + end + end + + def self.lookup(guardian, slugs) + if SiteSetting.enable_experimental_hashtag_autocomplete + return [] if !guardian.can_chat? + Chat::ChannelFetcher + .secured_public_channel_slug_lookup(guardian, slugs) + .map { |channel| channel_to_hashtag_item(guardian, channel) } + else + [] + end + end + + def self.search( + guardian, + term, + limit, + condition = HashtagAutocompleteService.search_conditions[:contains] + ) + if SiteSetting.enable_experimental_hashtag_autocomplete + return [] if !guardian.can_chat? + Chat::ChannelFetcher + .secured_public_channel_search( + guardian, + filter: term, + limit: limit, + exclude_dm_channels: true, + match_filter_on_starts_with: + condition == HashtagAutocompleteService.search_conditions[:starts_with], + ) + .map { |channel| channel_to_hashtag_item(guardian, channel) } + else + [] + end + end + + def self.search_sort(search_results, _) + search_results.sort_by { |result| result.text.downcase } + end + + def self.search_without_term(guardian, limit) + if SiteSetting.enable_experimental_hashtag_autocomplete + return [] if !guardian.can_chat? + allowed_channel_ids_sql = + Chat::ChannelFetcher.generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) + Chat::Channel + .joins( + "INNER JOIN user_chat_channel_memberships + ON user_chat_channel_memberships.chat_channel_id = chat_channels.id + AND user_chat_channel_memberships.user_id = #{guardian.user.id} + AND user_chat_channel_memberships.following = true", + ) + .where("chat_channels.id IN (#{allowed_channel_ids_sql})") + .order(messages_count: :desc) + .limit(limit) + .map { |channel| channel_to_hashtag_item(guardian, channel) } + else + [] + end + end + end +end diff --git a/plugins/chat/lib/chat/channel_membership_manager.rb b/plugins/chat/lib/chat/channel_membership_manager.rb new file mode 100644 index 00000000000..d55a11546e6 --- /dev/null +++ b/plugins/chat/lib/chat/channel_membership_manager.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Chat + class ChannelMembershipManager + def self.all_for_user(user) + Chat::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? + + Chat::UserChatChannelMembership.includes(:user, :chat_channel).find_by(params) + end + + def follow(user) + membership = + find_for_user(user) || + Chat::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 Chat::Channel.exists?(id: channel.id, user_count_stale: true) + channel.update!(user_count_stale: true) + Jobs.enqueue_in(3.seconds, Jobs::Chat::UpdateChannelUserCount, chat_channel_id: channel.id) + end + + def unfollow_all_users + Chat::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(Jobs::Chat::AutoManageChannelMemberships, chat_channel_id: channel.id) + end + + def enforce_automatic_user_membership(user) + Jobs.enqueue( + Jobs::Chat::AutoJoinChannelBatch, + chat_channel_id: channel.id, + starts_at: user.id, + ends_at: user.id, + ) + end + end +end diff --git a/plugins/chat/lib/chat/direct_message_channel_creator.rb b/plugins/chat/lib/chat/direct_message_channel_creator.rb new file mode 100644 index 00000000000..baad5d355d5 --- /dev/null +++ b/plugins/chat/lib/chat/direct_message_channel_creator.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +module Chat + class 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_message = Chat::DirectMessage.for_user_ids(target_users.map(&:id)) + + if direct_message + chat_channel = Chat::Channel.find_by!(chatable: direct_message) + else + enforce_max_direct_message_users!(acting_user, target_users) + ensure_actor_can_communicate!(acting_user, target_users) + direct_message = Chat::DirectMessage.create!(user_ids: target_users.map(&:id)) + chat_channel = direct_message.create_chat_channel! + end + + update_memberships(acting_user, target_users, chat_channel.id) + Chat::Publisher.publish_new_channel(chat_channel, target_users) + + chat_channel + end + + private + + def self.enforce_max_direct_message_users!(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 } + + if !acting_user.staff? && target_users.size > SiteSetting.chat_max_direct_message_users + if SiteSetting.chat_max_direct_message_users == 0 + raise NotAllowed.new(I18n.t("chat.errors.over_chat_max_direct_message_users_allow_self")) + else + raise NotAllowed.new( + I18n.t( + "chat.errors.over_chat_max_direct_message_users", + count: SiteSetting.chat_max_direct_message_users + 1, # +1 for the acting_user + ), + ) + end + end + end + + 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: Chat::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 +end diff --git a/plugins/chat/lib/chat/duplicate_message_validator.rb b/plugins/chat/lib/chat/duplicate_message_validator.rb new file mode 100644 index 00000000000..fa9175b8ed7 --- /dev/null +++ b/plugins/chat/lib/chat/duplicate_message_validator.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Chat + class 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 = + 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 +end diff --git a/plugins/chat/lib/chat/engine.rb b/plugins/chat/lib/chat/engine.rb new file mode 100644 index 00000000000..38cca8ab7ba --- /dev/null +++ b/plugins/chat/lib/chat/engine.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module ::Chat + HAS_CHAT_ENABLED = "has_chat_enabled" + + class Engine < ::Rails::Engine + engine_name PLUGIN_NAME + isolate_namespace Chat + config.autoload_paths << File.join(config.root, "lib") + end + + def self.allowed_group_ids + SiteSetting.chat_allowed_groups_map + end + + def self.onebox_template + @onebox_template ||= + begin + path = "#{Rails.root}/plugins/chat/lib/onebox/templates/discourse_chat.mustache" + File.read(path) + end + end +end diff --git a/plugins/chat/lib/chat/guardian_extensions.rb b/plugins/chat/lib/chat/guardian_extensions.rb new file mode 100644 index 00000000000..f5a067e295a --- /dev/null +++ b/plugins/chat/lib/chat/guardian_extensions.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +module Chat + module 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? + return false if anonymous? + @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? + + # FIXME: This logic shouldn't be handled in guardian + 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_preview_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_join_chat_channel?(chat_channel) + return false if anonymous? + can_preview_chat_channel?(chat_channel) && + (chat_channel.direct_message_channel? || can_post_in_category?(chat_channel.chatable)) + end + + def can_flag_chat_messages? + return false if @user.silenced? + return true if @user.staff? + + @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_join_chat_channel?(chat_channel) + end + + def can_flag_chat_message?(chat_message) + if !authenticated? || !chat_message || chat_message.trashed? || !chat_message.user + return false + end + 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 + when Category + return can_see_category?(chatable) + when Chat::DirectMessage + 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.deletable_for_chat? + end + end +end diff --git a/plugins/chat/lib/chat/mailer.rb b/plugins/chat/lib/chat/mailer.rb new file mode 100644 index 00000000000..f6a2579af1e --- /dev/null +++ b/plugins/chat/lib/chat/mailer.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Chat + class Mailer + 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 + + users = + User + .joins(:user_option) + .where(user_options: { chat_enabled: true, chat_email_frequency: when_away_frequency }) + .where("users.last_seen_at < ?", 15.minutes.ago) + + if !allowed_group_ids.include?(Group::AUTO_GROUPS[:everyone]) + users = users.joins(:groups).where(groups: { id: allowed_group_ids }) + end + + users + .select( + "users.id", + "ARRAY_AGG(ARRAY[uccm.id, c_msg.id]) AS memberships_with_unread_messages", + ) + .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 = 'DirectMessage') + ) + SQL + .group("users.id, uccm.user_id") + end + end +end diff --git a/plugins/chat/lib/chat/message_bookmarkable.rb b/plugins/chat/lib/chat/message_bookmarkable.rb new file mode 100644 index 00000000000..41c286cbf37 --- /dev/null +++ b/plugins/chat/lib/chat/message_bookmarkable.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Chat + class MessageBookmarkable < BaseBookmarkable + def self.model + Chat::Message + end + + def self.serializer + Chat::UserMessageBookmarkSerializer + end + + def self.preload_associations + [:chat_channel] + end + + def self.list_query(user, guardian) + accessible_channel_ids = Chat::ChannelFetcher.all_secured_channel_ids(guardian) + return if accessible_channel_ids.empty? + + joins = + ActiveRecord::Base.public_send( + :sanitize_sql_array, + [ + "INNER JOIN chat_messages ON chat_messages.id = bookmarks.bookmarkable_id AND chat_messages.deleted_at IS NULL AND bookmarks.bookmarkable_type = ?", + Chat::Message.sti_name, + ], + ) + + user + .bookmarks_of_type(Chat::Message.sti_name) + .joins(joins) + .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_join_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_join_chat_channel?(bookmark.bookmarkable.chat_channel) + end + + def self.cleanup_deleted + DB.query(<<~SQL, grace_time: 3.days.ago, bookmarkable_type: Chat::Message.sti_name) + DELETE FROM bookmarks b + USING chat_messages cm + WHERE b.bookmarkable_id = cm.id + AND b.bookmarkable_type = :bookmarkable_type + AND (cm.deleted_at < :grace_time) + SQL + end + end +end diff --git a/plugins/chat/lib/chat/message_creator.rb b/plugins/chat/lib/chat/message_creator.rb new file mode 100644 index 00000000000..7eec88b7a76 --- /dev/null +++ b/plugins/chat/lib/chat/message_creator.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true +module Chat + class MessageCreator + 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, + thread_id: nil, + user:, + content:, + staged_id: nil, + incoming_chat_webhook: nil, + upload_ids: nil + ) + @chat_channel = chat_channel + @user = user + @guardian = Guardian.new(user) + + # NOTE: We confirm this exists and the user can access it in the ChatController, + # but in future the checks should be here + @in_reply_to_id = in_reply_to_id + @content = content + @staged_id = staged_id + @incoming_chat_webhook = incoming_chat_webhook + @upload_ids = upload_ids || [] + @thread_id = thread_id + @error = nil + + @chat_message = + Chat::Message.new( + chat_channel: @chat_channel, + user_id: @user.id, + last_editor_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?) + validate_reply_chain! + validate_existing_thread! + @chat_message.thread_id = @existing_thread&.id + @chat_message.cook + @chat_message.save! + create_chat_webhook_event + create_thread + @chat_message.attach_uploads(uploads) + Chat::Draft.where(user_id: @user.id, chat_channel_id: @chat_channel.id).destroy_all + Chat::Publisher.publish_new!(@chat_channel, @chat_message, @staged_id) + Jobs.enqueue(Jobs::Chat::ProcessMessage, { chat_message_id: @chat_message.id }) + Chat::Notifier.notify_new(chat_message: @chat_message, timestamp: @chat_message.created_at) + @chat_channel.touch(:last_message_sent_at) + DiscourseEvent.trigger(:chat_message_created, @chat_message, @chat_channel, @user) + 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.#{@chat_channel.status}"), + ) + end + end + + def validate_reply_chain! + return if @in_reply_to_id.blank? + + @original_message_id = DB.query_single(<<~SQL).last + WITH RECURSIVE original_message_finder( id, in_reply_to_id ) + AS ( + -- start with the message id we want to find the parents of + SELECT id, in_reply_to_id + FROM chat_messages + WHERE id = #{@in_reply_to_id} + + UNION ALL + + -- get the chain of direct parents of the message + -- following in_reply_to_id + SELECT cm.id, cm.in_reply_to_id + FROM original_message_finder rm + JOIN chat_messages cm ON rm.in_reply_to_id = cm.id + ) + SELECT id FROM original_message_finder + + -- this makes it so only the root parent ID is returned, we can + -- exclude this to return all parents in the chain + WHERE in_reply_to_id IS NULL; + SQL + + if @original_message_id.blank? + raise StandardError.new(I18n.t("chat.errors.original_message_not_found")) + end + + @original_message = Chat::Message.with_deleted.find_by(id: @original_message_id) + if @original_message&.trashed? + raise StandardError.new(I18n.t("chat.errors.original_message_not_found")) + end + end + + def validate_existing_thread! + return if @thread_id.blank? + @existing_thread = Chat::Thread.find(@thread_id) + + if @existing_thread.channel_id != @chat_channel.id + raise StandardError.new(I18n.t("chat.errors.thread_invalid_for_channel")) + end + + reply_to_thread_mismatch = + @chat_message.in_reply_to&.thread_id && + @chat_message.in_reply_to.thread_id != @existing_thread.id + original_message_has_no_thread = @original_message && @original_message.thread_id.blank? + original_message_thread_mismatch = + @original_message && @original_message.thread_id != @existing_thread.id + if reply_to_thread_mismatch || original_message_has_no_thread || + original_message_thread_mismatch + raise StandardError.new(I18n.t("chat.errors.thread_does_not_match_parent")) + 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? + Chat::WebhookEvent.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 + + def create_thread + return if @in_reply_to_id.blank? + return if @chat_message.thread_id.present? + + thread = + @original_message.thread || + Chat::Thread.create!( + original_message: @chat_message.in_reply_to, + original_message_user: @chat_message.in_reply_to.user, + channel: @chat_message.chat_channel, + ) + + # NOTE: We intentionally do not try to correct thread IDs within the chain + # if they are incorrect, and only set the thread ID of messages where the + # thread ID is NULL. In future we may want some sync/background job to correct + # any inconsistencies. + DB.exec(<<~SQL) + WITH RECURSIVE thread_updater AS ( + SELECT cm.id, cm.in_reply_to_id + FROM chat_messages cm + WHERE cm.in_reply_to_id IS NULL AND cm.id = #{@original_message_id} + + UNION ALL + + SELECT cm.id, cm.in_reply_to_id + FROM chat_messages cm + JOIN thread_updater ON cm.in_reply_to_id = thread_updater.id + ) + UPDATE chat_messages + SET thread_id = #{thread.id} + FROM thread_updater + WHERE thread_id IS NULL AND chat_messages.id = thread_updater.id + SQL + + @chat_message.thread_id = thread.id + end + end +end diff --git a/plugins/chat/lib/chat/message_mentions.rb b/plugins/chat/lib/chat/message_mentions.rb new file mode 100644 index 00000000000..613b8aead1f --- /dev/null +++ b/plugins/chat/lib/chat/message_mentions.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Chat + class MessageMentions + def initialize(message) + @message = message + + mentions = parse_mentions(message) + group_mentions = parse_group_mentions(message) + + @has_global_mention = mentions.include?("@all") + @has_here_mention = mentions.include?("@here") + @parsed_direct_mentions = normalize(mentions) + @parsed_group_mentions = normalize(group_mentions) + end + + attr_accessor :has_global_mention, + :has_here_mention, + :parsed_direct_mentions, + :parsed_group_mentions + + def all_mentioned_users_ids + @all_mentioned_users_ids ||= + begin + user_ids = global_mentions.pluck(:id) + user_ids.concat(direct_mentions.pluck(:id)) + user_ids.concat(group_mentions.pluck(:id)) + user_ids.concat(here_mentions.pluck(:id)) + user_ids.uniq! + user_ids + end + end + + def global_mentions + return User.none unless @has_global_mention + channel_members.where.not(username_lower: @parsed_direct_mentions) + end + + def direct_mentions + chat_users.where(username_lower: @parsed_direct_mentions) + end + + def group_mentions + chat_users.includes(:groups).joins(:groups).where(groups: mentionable_groups) + end + + def here_mentions + return User.none unless @has_here_mention + + channel_members + .where("last_seen_at > ?", 5.minutes.ago) + .where.not(username_lower: @parsed_direct_mentions) + end + + def mentionable_groups + @mentionable_groups ||= + Group.mentionable(@message.user, include_public: false).where(id: visible_groups.map(&:id)) + end + + def visible_groups + @visible_groups ||= + Group.where("LOWER(name) IN (?)", @parsed_group_mentions).visible_groups(@message.user) + end + + private + + def channel_members + chat_users.where( + user_chat_channel_memberships: { + following: true, + chat_channel_id: @message.chat_channel.id, + }, + ) + end + + def chat_users + User + .includes(:user_chat_channel_memberships, :group_users) + .distinct + .joins("LEFT OUTER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id") + .joins(:user_option) + .real + .where(user_options: { chat_enabled: true }) + .where.not(username_lower: @message.user.username.downcase) + end + + def parse_mentions(message) + Nokogiri::HTML5.fragment(message.cooked).css(".mention").map(&:text) + end + + def parse_group_mentions(message) + Nokogiri::HTML5.fragment(message.cooked).css(".mention-group").map(&:text) + end + + def normalize(mentions) + mentions.reduce([]) do |memo, mention| + %w[@here @all].include?(mention.downcase) ? memo : (memo << mention[1..-1].downcase) + end + end + end +end diff --git a/plugins/chat/lib/chat/message_mover.rb b/plugins/chat/lib/chat/message_mover.rb new file mode 100644 index 00000000000..3b8184e7596 --- /dev/null +++ b/plugins/chat/lib/chat/message_mover.rb @@ -0,0 +1,244 @@ +# 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. +# +# Reply chains are a little complex. No reply chains are preserved when moving +# messages into a new channel. Remaining messages that referenced moved ones +# have their in_reply_to_id cleared so the data makes sense. +# +# Threads are even more complex. No threads are preserved when moving messages +# into a new channel, they end up as just a flat series of messages that are +# not in a chain. If the original message of a thread and N other messages +# in that thread, then any messages left behind just get placed into a new +# thread. Message moving will be disabled in the thread UI while +# enable_experimental_chat_threaded_discussions is present, its too complicated +# to have end users reason about for now, and we may want a standalone +# "Move Thread" UI later on. +module Chat + class 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 + + Chat::Message.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 + update_reply_references + update_thread_references + end + + add_moved_placeholder(destination_channel, moved_messages.first) + moved_messages + end + + private + + def find_messages(message_ids, channel) + Chat::Message + .includes(thread: %i[original_message original_message_user]) + .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, last_editor_id, message, cooked, cooked_version, created_at, updated_at + ) + SELECT :destination_channel_id, + user_id, + last_editor_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, target_type: Chat::Message.sti_name) + UPDATE upload_references uref + SET target_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE uref.target_id = mm.old_chat_message_id AND uref.target_type = :target_type + 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 + # We do this so @source_messages is not nulled out, which is the + # case when using update_all here. + DB.exec(<<~SQL, source_message_ids: @source_message_ids, deleted_by_id: @acting_user.id) + UPDATE chat_messages + SET deleted_at = NOW(), deleted_by_id = :deleted_by_id + WHERE id IN (:source_message_ids) + SQL + Chat::Publisher.publish_bulk_delete!(@source_channel, @source_message_ids) + end + + def add_moved_placeholder(destination_channel, first_moved_message) + Chat::MessageCreator.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 + + def update_reply_references + DB.exec(<<~SQL, deleted_reply_to_ids: @source_message_ids) + UPDATE chat_messages + SET in_reply_to_id = NULL + WHERE in_reply_to_id IN (:deleted_reply_to_ids) + SQL + end + + def update_thread_references + threads_to_update = [] + @source_messages + .select { |message| message.thread_id.present? } + .each do |message_with_thread| + # If one of the messages we are moving is the original message in a thread, + # then all the remaining messages for that thread must be moved to a new one, + # otherwise they will be pointing to a thread in a different channel. + if message_with_thread.thread.original_message_id == message_with_thread.id + threads_to_update << message_with_thread.thread + end + end + + threads_to_update.each do |thread| + # NOTE: We may want to do something different with the old empty thread at some + # point when we add an explicit thread move UI, for now we can just delete it, + # since it will not contain any important data. + if thread.chat_messages.empty? + thread.destroy! + next + end + + Chat::Thread.transaction do + original_message = thread.chat_messages.first + new_thread = + Chat::Thread.create!( + original_message: original_message, + original_message_user: original_message.user, + channel: @source_channel, + ) + thread.chat_messages.update_all(thread_id: new_thread.id) + end + end + end + end +end diff --git a/plugins/chat/lib/chat/message_processor.rb b/plugins/chat/lib/chat/message_processor.rb new file mode 100644 index 00000000000..bc9ec2acee8 --- /dev/null +++ b/plugins/chat/lib/chat/message_processor.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Chat + class MessageProcessor + include ::CookedProcessorMixin + + def initialize(chat_message) + @model = chat_message + @previous_cooked = (chat_message.cooked || "").dup + @with_secure_uploads = false + @size_cache = {} + @opts = {} + + cooked = Chat::Message.cook(chat_message.message, user_id: chat_message.last_editor_id) + @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 +end diff --git a/plugins/chat/lib/chat/message_rate_limiter.rb b/plugins/chat/lib/chat/message_rate_limiter.rb new file mode 100644 index 00000000000..8d1f3b83f00 --- /dev/null +++ b/plugins/chat/lib/chat/message_rate_limiter.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Chat + class MessageRateLimiter + 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 if silenced_for_minutes.zero? + + UserSilencer.silence( + @user, + Discourse.system_user, + silenced_till: silenced_for_minutes.minutes.from_now, + reason: I18n.t("chat.errors.rate_limit_exceeded"), + ) + end + end +end diff --git a/plugins/chat/lib/chat/message_reactor.rb b/plugins/chat/lib/chat/message_reactor.rb new file mode 100644 index 00000000000..74dcdc2c8c5 --- /dev/null +++ b/plugins/chat/lib/chat/message_reactor.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Chat + class MessageReactor + 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_join_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) + + reaction = nil + ActiveRecord::Base.transaction do + enforce_channel_membership! + reaction = create_reaction(message, react_action, emoji) + end + + publish_reaction(message, react_action, emoji) + + reaction + end + + private + + def ensure_chat_message!(message_id) + message = Chat::Message.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::ChannelMembershipManager.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.#{@chat_channel.status}", + ) + 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) + Chat::Publisher.publish_reaction!(@chat_channel, message, react_action, @user, emoji) + end + end +end diff --git a/plugins/chat/lib/chat/message_updater.rb b/plugins/chat/lib/chat/message_updater.rb new file mode 100644 index 00000000000..13eea6cf18b --- /dev/null +++ b/plugins/chat/lib/chat/message_updater.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module Chat + class MessageUpdater + attr_reader :error + + def self.update(opts) + instance = new(**opts) + instance.update + instance + end + + def initialize(guardian:, chat_message:, new_content:, upload_ids: nil) + @guardian = guardian + @user = guardian.user + @chat_message = chat_message + @old_message_content = chat_message.message + @chat_channel = @chat_message.chat_channel + @new_content = new_content + @upload_ids = upload_ids + @error = nil + end + + def update + begin + validate_channel_status! + @guardian.ensure_can_edit_chat!(@chat_message) + @chat_message.message = @new_content + @chat_message.last_editor_id = @user.id + 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! + @chat_message.reload + Chat::Publisher.publish_edit!(@chat_channel, @chat_message) + Jobs.enqueue(Jobs::Chat::ProcessMessage, { chat_message_id: @chat_message.id }) + Chat::Notifier.notify_edit(chat_message: @chat_message, timestamp: revision.created_at) + DiscourseEvent.trigger(:chat_message_edited, @chat_message, @chat_channel, @user) + 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.#{@chat_channel.status}"), + ) + 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] + + DB.exec("DELETE FROM chat_uploads WHERE chat_message_id = #{@chat_message.id}") + UploadReference.where(target: @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, + user_id: @user.id, + ) + end + end +end diff --git a/plugins/chat/lib/chat/notifier.rb b/plugins/chat/lib/chat/notifier.rb new file mode 100644 index 00000000000..e0805b5b274 --- /dev/null +++ b/plugins/chat/lib/chat/notifier.rb @@ -0,0 +1,317 @@ +# 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 Jobs::Chat::NotifyWatching job, +# which prevents desktop / push notifications being sent. +module Chat + class Notifier + 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:) + Jobs.enqueue( + Jobs::Chat::SendMessageNotifications, + chat_message_id: chat_message.id, + timestamp: timestamp.iso8601(6), + reason: "edit", + ) + end + + def notify_new(chat_message:, timestamp:) + Jobs.enqueue( + Jobs::Chat::SendMessageNotifications, + chat_message_id: chat_message.id, + timestamp: timestamp.iso8601(6), + reason: "new", + ) + end + end + + def initialize(chat_message, timestamp) + @chat_message = chat_message + @timestamp = timestamp + @chat_channel = @chat_message.chat_channel + @user = @chat_message.user + @mentions = Chat::MessageMentions.new(chat_message) + end + + ### Public API + + def notify_new + if @mentions.all_mentioned_users_ids.present? + @chat_message.create_mentions(@mentions.all_mentioned_users_ids) + end + + to_notify = list_users_to_notify + mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids] + + mentioned_user_ids.each do |member_id| + Chat::Publisher.publish_new_mention(member_id, @chat_channel.id, @chat_message.id) + end + + notify_creator_of_inaccessible_mentions(to_notify) + + notify_mentioned_users(to_notify) + notify_watching_users(except: mentioned_user_ids << @user.id) + + to_notify + end + + def notify_edit + @chat_message.update_mentions(@mentions.all_mentioned_users_ids) + + existing_notifications = + Chat::Mention.includes(:user, :notification).where(chat_message: @chat_message) + already_notified_user_ids = existing_notifications.map(&:user_id) + + to_notify = list_users_to_notify + 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(to_notify) + + notify_mentioned_users(to_notify, already_notified_user_ids: already_notified_user_ids) + + to_notify + end + + private + + def list_users_to_notify + mentions_count = + @mentions.parsed_direct_mentions.length + @mentions.parsed_group_mentions.length + mentions_count += 1 if @mentions.has_global_mention + mentions_count += 1 if @mentions.has_here_mention + + skip_notifications = mentions_count > SiteSetting.max_mentions_per_chat_message + + {}.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, skip_notifications) + expand_group_mentions(to_notify, already_covered_ids, skip_notifications) + expand_here_mention(to_notify, already_covered_ids, skip_notifications) + expand_global_mention(to_notify, already_covered_ids, skip_notifications) + + filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids) + + to_notify[:all_mentioned_user_ids] = already_covered_ids + end + end + + def expand_global_mention(to_notify, already_covered_ids, skip) + has_all_mention = @mentions.has_global_mention + + if has_all_mention && @chat_channel.allow_channel_wide_mentions && !skip + to_notify[:global_mentions] = @mentions + .global_mentions + .not_suspended + .where(user_options: { ignore_channel_wide_mention: [false, nil] }) + .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, skip) + has_here_mention = @mentions.has_here_mention + + if has_here_mention && @chat_channel.allow_channel_wide_mentions && !skip + to_notify[:here_mentions] = @mentions + .here_mentions + .not_suspended + .where(user_options: { ignore_channel_wide_mention: [false, nil] }) + .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? && guardian.can_join_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, skip) + if skip + direct_mentions = [] + else + direct_mentions = @mentions.direct_mentions.not_suspended.where.not(id: already_covered_ids) + end + + 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 expand_group_mentions(to_notify, already_covered_ids, skip) + return [] if skip || @mentions.visible_groups.empty? + + reached_by_group = + @mentions + .group_mentions + .not_suspended + .where("user_count <= ?", SiteSetting.max_users_notified_per_group_mention) + .where.not(id: already_covered_ids) + + too_many_members, mentionable = + @mentions.mentionable_groups.partition do |group| + group.user_count > SiteSetting.max_users_notified_per_group_mention + end + + mentions_disabled = @mentions.visible_groups - @mentions.mentionable_groups + to_notify[:group_mentions_disabled] = mentions_disabled + to_notify[:too_many_members] = too_many_members + mentionable.each { |g| to_notify[g.name.downcase] = [] } + + 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 = + @mentions.parsed_group_mentions & mentionable.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 + already_covered_ids << user.id + end + + 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(to_notify) + inaccessible = + to_notify.extract!( + :unreachable, + :welcome_to_join, + :too_many_members, + :group_mentions_disabled, + ) + return if inaccessible.values.all?(&:blank?) + + Chat::Publisher.publish_inaccessible_mentions( + @user.id, + @chat_message, + inaccessible[:unreachable].to_a, + inaccessible[:welcome_to_join].to_a, + inaccessible[:too_many_members].to_a, + inaccessible[:group_mentions_disabled].to_a, + ) + 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 Jobs::Chat::NotifyMentioned job and are not prompted for + # invitation by the creator. + def filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids) + screen_targets = already_covered_ids.concat(to_notify[:welcome_to_join].map(&:id)) + + return if screen_targets.blank? + + screener = UserCommScreener.new(acting_user: @user, target_user_ids: screen_targets) + to_notify + .except(:unreachable, :welcome_to_join) + .each do |key, user_ids| + to_notify[key] = user_ids.reject { |user_id| screener.ignoring_or_muting_actor?(user_id) } + end + + # :welcome_to_join contains users because it's serialized by MB. + to_notify[:welcome_to_join] = to_notify[:welcome_to_join].reject do |user| + screener.ignoring_or_muting_actor?(user.id) + end + + already_covered_ids.reject! do |already_covered| + screener.ignoring_or_muting_actor?(already_covered) + end + end + + def notify_mentioned_users(to_notify, already_notified_user_ids: []) + Jobs.enqueue( + Jobs::Chat::NotifyMentioned, + { + chat_message_id: @chat_message.id, + to_notify_ids_map: to_notify.as_json, + already_notified_user_ids: already_notified_user_ids, + timestamp: @timestamp, + }, + ) + end + + def notify_watching_users(except: []) + Jobs.enqueue( + Jobs::Chat::NotifyWatching, + { chat_message_id: @chat_message.id, except_user_ids: except, timestamp: @timestamp }, + ) + end + end +end diff --git a/plugins/chat/lib/chat/plugin_instance_extension.rb b/plugins/chat/lib/chat/plugin_instance_extension.rb new file mode 100644 index 00000000000..58c5ebc308f --- /dev/null +++ b/plugins/chat/lib/chat/plugin_instance_extension.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Chat + module PluginInstanceExtension + def self.prepended(base) + DiscoursePluginRegistry.define_register(:chat_markdown_features, Set) + end + + def chat + ChatPluginApiExtensions + end + + module ChatPluginApiExtensions + def self.enable_markdown_feature(name) + DiscoursePluginRegistry.chat_markdown_features << name + end + end + end +end diff --git a/plugins/chat/lib/chat/post_notification_handler.rb b/plugins/chat/lib/chat/post_notification_handler.rb new file mode 100644 index 00000000000..449d6c27a22 --- /dev/null +++ b/plugins/chat/lib/chat/post_notification_handler.rb @@ -0,0 +1,42 @@ +# 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. +module Chat + class 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 +end diff --git a/plugins/chat/lib/chat/review_queue.rb b/plugins/chat/lib/chat/review_queue.rb new file mode 100644 index 00000000000..c5dabebe06d --- /dev/null +++ b/plugins/chat/lib/chat/review_queue.rb @@ -0,0 +1,211 @@ +# 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. + +module Chat + class ReviewQueue + 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 = + Chat::ReviewableMessage.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) + Chat::Publisher.publish_delete!(chat_message.chat_channel, chat_message) + else + enforce_auto_silence_threshold(reviewable) + Chat::Publisher.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 <= Chat::ReviewableMessage.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 = + Chat::Message + .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 = + Chat::TranscriptService.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 +end diff --git a/plugins/chat/lib/chat/reviewable_extension.rb b/plugins/chat/lib/chat/reviewable_extension.rb new file mode 100644 index 00000000000..e2218d4fe19 --- /dev/null +++ b/plugins/chat/lib/chat/reviewable_extension.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Chat + module ReviewableExtension + extend ActiveSupport::Concern + + prepended do + # the model used when loading type column + def self.sti_class_for(name) + return Chat::ReviewableMessage if name == "ReviewableChatMessage" + super(name) + end + + # the model used when loading target_type column + def self.polymorphic_class_for(name) + return Chat::Message if name == Chat::Message.sti_name + super(name) + end + + # the type column value when saving a Chat::ReviewableMessage + def self.sti_name + return "ReviewableChatMessage" if self.to_s == "Chat::ReviewableMessage" + super + end + end + end +end diff --git a/plugins/chat/lib/chat/secure_uploads_compatibility.rb b/plugins/chat/lib/chat/secure_uploads_compatibility.rb new file mode 100644 index 00000000000..1a63f0ff743 --- /dev/null +++ b/plugins/chat/lib/chat/secure_uploads_compatibility.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Chat + class 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 +end diff --git a/plugins/chat/lib/chat/seeder.rb b/plugins/chat/lib/chat/seeder.rb new file mode 100644 index 00000000000..b6853be92bb --- /dev/null +++ b/plugins/chat/lib/chat/seeder.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Chat + class Seeder + 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::ChannelMembershipManager.new(chat_channel).enforce_automatic_channel_memberships + chat_channel + end + end +end diff --git a/plugins/chat/lib/chat/slack_compatibility.rb b/plugins/chat/lib/chat/slack_compatibility.rb new file mode 100644 index 00000000000..1c8b628668c --- /dev/null +++ b/plugins/chat/lib/chat/slack_compatibility.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +## +# Processes slack-formatted text messages, as Mattermost does with +# Slack incoming webhook interoperability, for example links in the +# format and , and 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. +module Chat + class SlackCompatibility + MRKDWN_LINK_REGEX = Regexp.new(/(<[^\n<\|>]+>|<[^\n<\>]+>)/).freeze + + class << self + def process_text(text) + text = text.gsub("", "@here") + text = text.gsub("", "@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 +end diff --git a/plugins/chat/lib/chat/statistics.rb b/plugins/chat/lib/chat/statistics.rb new file mode 100644 index 00000000000..cc9a6b3f313 --- /dev/null +++ b/plugins/chat/lib/chat/statistics.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Chat + class Statistics + def self.about_messages + { + :last_day => Chat::Message.where("created_at > ?", 1.days.ago).count, + "7_days" => Chat::Message.where("created_at > ?", 7.days.ago).count, + "30_days" => Chat::Message.where("created_at > ?", 30.days.ago).count, + :previous_30_days => + Chat::Message.where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago).count, + :count => Chat::Message.count, + } + end + + def self.about_channels + { + :last_day => Chat::Channel.where(status: :open).where("created_at > ?", 1.days.ago).count, + "7_days" => Chat::Channel.where(status: :open).where("created_at > ?", 7.days.ago).count, + "30_days" => Chat::Channel.where(status: :open).where("created_at > ?", 30.days.ago).count, + :previous_30_days => + Chat::Channel + .where(status: :open) + .where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago) + .count, + :count => Chat::Channel.where(status: :open).count, + } + end + + def self.about_users + { + :last_day => Chat::Message.where("created_at > ?", 1.days.ago).distinct.count(:user_id), + "7_days" => Chat::Message.where("created_at > ?", 7.days.ago).distinct.count(:user_id), + "30_days" => Chat::Message.where("created_at > ?", 30.days.ago).distinct.count(:user_id), + :previous_30_days => + Chat::Message + .where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago) + .distinct + .count(:user_id), + :count => Chat::Message.distinct.count(:user_id), + } + end + + def self.monthly + start_of_month = Time.zone.now.beginning_of_month + { + messages: Chat::Message.where("created_at > ?", start_of_month).count, + channels: Chat::Channel.where(status: :open).where("created_at > ?", start_of_month).count, + users: Chat::Message.where("created_at > ?", start_of_month).distinct.count(:user_id), + } + end + end +end diff --git a/plugins/chat/lib/steps_inspector.rb b/plugins/chat/lib/chat/steps_inspector.rb similarity index 97% rename from plugins/chat/lib/steps_inspector.rb rename to plugins/chat/lib/chat/steps_inspector.rb index 001e7a9f773..965a0f7612c 100644 --- a/plugins/chat/lib/steps_inspector.rb +++ b/plugins/chat/lib/chat/steps_inspector.rb @@ -38,7 +38,7 @@ module Chat end def inspect - "#{" " * nesting_level}[#{type}] '#{name}' #{emoji}" + "#{" " * nesting_level}[#{type}] '#{name}' #{emoji}".rstrip end private diff --git a/plugins/chat/lib/chat/transcript_service.rb b/plugins/chat/lib/chat/transcript_service.rb new file mode 100644 index 00000000000..82f27dca428 --- /dev/null +++ b/plugins/chat/lib/chat/transcript_service.rb @@ -0,0 +1,179 @@ +# 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. +module Chat + class TranscriptService + CHAINED_ATTR = "chained=\"true\"" + MULTIQUOTE_ATTR = "multiQuote=\"true\"" + NO_LINK_ATTR = "noLink=\"true\"" + + class TranscriptBBCode + 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 = + TranscriptBBCode.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 = + TranscriptBBCode.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 ||= + Chat::Message + .includes(:user, upload_references: :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 +end diff --git a/plugins/chat/lib/chat/user_email_extension.rb b/plugins/chat/lib/chat/user_email_extension.rb new file mode 100644 index 00000000000..366fc41bb32 --- /dev/null +++ b/plugins/chat/lib/chat/user_email_extension.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Chat + module 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| + Chat::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 +end diff --git a/plugins/chat/lib/chat/user_extension.rb b/plugins/chat/lib/chat/user_extension.rb new file mode 100644 index 00000000000..f4861d24a37 --- /dev/null +++ b/plugins/chat/lib/chat/user_extension.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Chat + module UserExtension + extend ActiveSupport::Concern + + prepended do + has_many :user_chat_channel_memberships, + class_name: "Chat::UserChatChannelMembership", + dependent: :destroy + has_many :chat_message_reactions, class_name: "Chat::MessageReaction", dependent: :destroy + has_many :chat_mentions, class_name: "Chat::Mention" + end + end +end diff --git a/plugins/chat/lib/chat/user_notifications_extension.rb b/plugins/chat/lib/chat/user_notifications_extension.rb new file mode 100644 index 00000000000..c0c87a06df2 --- /dev/null +++ b/plugins/chat/lib/chat/user_notifications_extension.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +module Chat + module UserNotificationsExtension + def chat_summary(user, opts) + guardian = Guardian.new(user) + return unless guardian.can_chat? + + @messages = + Chat::Message + .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 AND cm.notification_id IS NOT NULL", + ) + .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 = 'DirectMessage') + ) + SQL + .to_a + + return if @messages.empty? + @grouped_messages = @messages.group_by { |message| message.chat_channel } + @grouped_messages = + @grouped_messages.select { |channel, _| guardian.can_join_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) + all_channels = grouped_messages.keys + grouped_channels = all_channels.partition { |c| !c.direct_message_channel? } + channels = grouped_channels.first + + dm_messages = grouped_channels.last.flat_map { |c| grouped_messages[c] } + dm_users = dm_messages.sort_by(&:created_at).uniq { |m| m.user_id }.map(&:user) + + # Prioritize messages from regular channels over direct messages + if channels.any? + channel_notification_text( + channels.sort_by { |channel| [channel.last_message_sent_at, channel.created_at] }, + dm_users, + ) + else + direct_message_notification_text(dm_users) + end + end + + private + + def channel_notification_text(channels, dm_users) + total_count = channels.size + dm_users.size + + if total_count > 2 + I18n.t( + "user_notifications.chat_summary.subject.chat_channel_more", + email_prefix: @email_prefix, + channel: channels.first.title, + count: total_count - 1, + ) + elsif channels.size == 1 && dm_users.size == 0 + I18n.t( + "user_notifications.chat_summary.subject.chat_channel_1", + email_prefix: @email_prefix, + channel: channels.first.title, + ) + elsif channels.size == 1 && dm_users.size == 1 + I18n.t( + "user_notifications.chat_summary.subject.chat_channel_and_direct_message", + email_prefix: @email_prefix, + channel: channels.first.title, + username: dm_users.first.username, + ) + elsif channels.size == 2 + I18n.t( + "user_notifications.chat_summary.subject.chat_channel_2", + email_prefix: @email_prefix, + channel1: channels.first.title, + channel2: channels.second.title, + ) + end + end + + def direct_message_notification_text(dm_users) + case dm_users.size + when 1 + I18n.t( + "user_notifications.chat_summary.subject.direct_message_from_1", + email_prefix: @email_prefix, + username: dm_users.first.username, + ) + when 2 + I18n.t( + "user_notifications.chat_summary.subject.direct_message_from_2", + email_prefix: @email_prefix, + username1: dm_users.first.username, + username2: dm_users.second.username, + ) + else + I18n.t( + "user_notifications.chat_summary.subject.direct_message_from_more", + email_prefix: @email_prefix, + username: dm_users.first.username, + count: dm_users.size - 1, + ) + end + end + end +end diff --git a/plugins/chat/lib/chat/user_option_extension.rb b/plugins/chat/lib/chat/user_option_extension.rb new file mode 100644 index 00000000000..71e860ebb25 --- /dev/null +++ b/plugins/chat/lib/chat/user_option_extension.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Chat + module 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 + + def base.chat_header_indicator_preferences + @chat_header_indicator_preferences ||= { all_new: 0, dm_and_mentions: 1, never: 2 } + end + + base.enum :chat_email_frequency, base.chat_email_frequencies, prefix: "send_chat_email" + base.enum :chat_header_indicator_preference, base.chat_header_indicator_preferences + end + end +end diff --git a/plugins/chat/lib/chat_channel_archive_service.rb b/plugins/chat/lib/chat_channel_archive_service.rb deleted file mode 100644 index cf656ef4cac..00000000000 --- a/plugins/chat/lib/chat_channel_archive_service.rb +++ /dev/null @@ -1,311 +0,0 @@ -# frozen_string_literal: true - -## -# From time to time, site admins may choose to sunset a chat channel and archive -# the messages within. 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 - - class ArchiveValidationError < StandardError - attr_reader :errors - - def initialize(errors: []) - super - @errors = errors - end - end - - def self.create_archive_process(chat_channel:, acting_user:, topic_params:) - return if ChatChannelArchive.exists?(chat_channel: chat_channel) - - # Only need to validate topic params for a new topic, not an existing one. - if topic_params[:topic_id].blank? - valid, errors = - Chat::ChatChannelArchiveService.validate_topic_params( - Guardian.new(acting_user), - topic_params, - ) - - raise ArchiveValidationError.new(errors: errors) if !valid - end - - 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, - ) - chat_channel.chat_channel_archive - end - - def self.validate_topic_params(guardian, topic_params) - topic_creator = - TopicCreator.new( - Discourse.system_user, - guardian, - { - title: topic_params[:topic_title], - category: topic_params[:category_id], - tags: topic_params[:tags], - import_mode: true, - }, - ) - [topic_creator.valid?, topic_creator.errors.full_messages] - 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 - return if !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_message: err.message) - 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, - }, - ) - - if topic_creator.valid? - chat_channel_archive.update!(destination_topic: topic_creator.create) - else - Rails.logger.info("Destination topic for #{chat_channel_title} archive was not valid.") - notify_archiver( - :failed_no_topic, - error_message: topic_creator.errors.full_messages.join("\n"), - ) - end - end - - if chat_channel_archive.destination_topic.present? - 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, - ), - ) - end - else - Rails.logger.info("Topic already exists for #{chat_channel_title} archive.") - end - - if chat_channel_archive.destination_topic.present? - update_destination_topic_status - return true - end - - false - 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.new_topic? - 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_message: nil) - base_translation_params = { - channel_hashtag_or_name: channel_hashtag_or_name, - topic_title: chat_channel_archive.destination_topic&.title, - topic_url: chat_channel_archive.destination_topic&.url, - topic_validation_errors: result == :failed_no_topic ? error_message : nil, - } - - if result == :failed || result == :failed_no_topic - Discourse.warn_exception( - error_message, - 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) - message_translation_key = - case result - when :failed - :chat_channel_archive_failed - when :failed_no_topic - :chat_channel_archive_failed_no_topic - end - SystemMessage.create_from_system_user( - chat_channel_archive.archived_by, - message_translation_key, - 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 != :success ? :failed : :success, - 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 - - def channel_hashtag_or_name - if chat_channel.slug.present? && SiteSetting.enable_experimental_hashtag_autocomplete - return "##{chat_channel.slug}::channel" - end - chat_channel_title - end -end diff --git a/plugins/chat/lib/chat_channel_fetcher.rb b/plugins/chat/lib/chat_channel_fetcher.rb deleted file mode 100644 index 028e0f3643e..00000000000 --- a/plugins/chat/lib/chat_channel_fetcher.rb +++ /dev/null @@ -1,257 +0,0 @@ -# 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, exclude_dm_channels: false) - category_channel_sql = - Category - .post_create_allowed(guardian) - .joins( - "INNER JOIN chat_channels ON chat_channels.chatable_id = categories.id AND chat_channels.chatable_type = 'Category'", - ) - .select("chat_channels.id") - .to_sql - dm_channel_sql = "" - if !exclude_dm_channels - dm_channel_sql = <<~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 = 'DirectMessage' - 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 - - <<~SQL - -- secured category chat channels - #{category_channel_sql} - #{dm_channel_sql} - SQL - end - - def self.secured_public_channel_slug_lookup(guardian, slugs) - allowed_channel_ids = generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) - - ChatChannel - .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 (#{allowed_channel_ids})") - .where("chat_channels.slug IN (:slugs)", slugs: slugs) - .limit(1) - end - - def self.secured_public_channel_search(guardian, options = {}) - allowed_channel_ids = generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) - - channels = ChatChannel.includes(chatable: [:topic_only_relative_url]) - channels = channels.includes(:chat_channel_archive) if options[:include_archives] - - channels = - channels - .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 (#{allowed_channel_ids})") - - channels = channels.where(status: options[:status]) if options[:status].present? - - if options[:filter].present? - category_filter = - (options[:filter_on_category_name] ? "OR categories.name ILIKE :filter" : "") - - sql = - "chat_channels.name ILIKE :filter OR chat_channels.slug ILIKE :filter #{category_filter}" - if options[:match_filter_on_starts_with] - filter_sql = "#{options[:filter].downcase}%" - else - filter_sql = "%#{options[:filter].downcase}%" - end - - channels = - channels.where(sql, filter: filter_sql).order("chat_channels.name ASC, categories.name ASC") - end - - if options.key?(:slugs) - channels = channels.where("chat_channels.slug IN (:slugs)", slugs: options[:slugs]) - 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.merge(include_archives: true, filter_on_category_name: true), - ) - - 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: "DirectMessage") - .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_join_chat_channel?(chat_channel) - chat_channel - end -end diff --git a/plugins/chat/lib/chat_channel_hashtag_data_source.rb b/plugins/chat/lib/chat_channel_hashtag_data_source.rb deleted file mode 100644 index 5c4e31cd867..00000000000 --- a/plugins/chat/lib/chat_channel_hashtag_data_source.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatChannelHashtagDataSource - def self.icon - "comment" - end - - def self.type - "channel" - end - - def self.channel_to_hashtag_item(guardian, channel) - HashtagAutocompleteService::HashtagItem.new.tap do |item| - item.text = channel.title - item.description = channel.description - item.slug = channel.slug - item.icon = icon - item.relative_url = channel.relative_url - item.type = "channel" - end - end - - def self.lookup(guardian, slugs) - if SiteSetting.enable_experimental_hashtag_autocomplete - return [] if !guardian.can_chat? - Chat::ChatChannelFetcher - .secured_public_channel_slug_lookup(guardian, slugs) - .map { |channel| channel_to_hashtag_item(guardian, channel) } - else - [] - end - end - - def self.search( - guardian, - term, - limit, - condition = HashtagAutocompleteService.search_conditions[:contains] - ) - if SiteSetting.enable_experimental_hashtag_autocomplete - return [] if !guardian.can_chat? - Chat::ChatChannelFetcher - .secured_public_channel_search( - guardian, - filter: term, - limit: limit, - exclude_dm_channels: true, - match_filter_on_starts_with: - condition == HashtagAutocompleteService.search_conditions[:starts_with], - ) - .map { |channel| channel_to_hashtag_item(guardian, channel) } - else - [] - end - end - - def self.search_sort(search_results, _) - search_results.sort_by { |result| result.text.downcase } - end - - def self.search_without_term(guardian, limit) - if SiteSetting.enable_experimental_hashtag_autocomplete - return [] if !guardian.can_chat? - allowed_channel_ids_sql = - Chat::ChatChannelFetcher.generate_allowed_channel_ids_sql( - guardian, - exclude_dm_channels: true, - ) - ChatChannel - .joins( - "INNER JOIN user_chat_channel_memberships - ON user_chat_channel_memberships.chat_channel_id = chat_channels.id - AND user_chat_channel_memberships.user_id = #{guardian.user.id} - AND user_chat_channel_memberships.following = true", - ) - .where("chat_channels.id IN (#{allowed_channel_ids_sql})") - .order(messages_count: :desc) - .limit(limit) - .map { |channel| channel_to_hashtag_item(guardian, channel) } - else - [] - end - end -end diff --git a/plugins/chat/lib/chat_channel_membership_manager.rb b/plugins/chat/lib/chat_channel_membership_manager.rb deleted file mode 100644 index 5947f23d7a4..00000000000 --- a/plugins/chat/lib/chat_channel_membership_manager.rb +++ /dev/null @@ -1,79 +0,0 @@ -# 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 diff --git a/plugins/chat/lib/chat_mailer.rb b/plugins/chat/lib/chat_mailer.rb deleted file mode 100644 index 7600a25fe4c..00000000000 --- a/plugins/chat/lib/chat_mailer.rb +++ /dev/null @@ -1,63 +0,0 @@ -# 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 - - users = - User - .joins(:user_option) - .where(user_options: { chat_enabled: true, chat_email_frequency: when_away_frequency }) - .where("users.last_seen_at < ?", 15.minutes.ago) - - if !allowed_group_ids.include?(Group::AUTO_GROUPS[:everyone]) - users = users.joins(:groups).where(groups: { id: allowed_group_ids }) - end - - users - .select("users.id", "ARRAY_AGG(ARRAY[uccm.id, c_msg.id]) AS memberships_with_unread_messages") - .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 = 'DirectMessage') - ) - SQL - .group("users.id, uccm.user_id") - end -end diff --git a/plugins/chat/lib/chat_message_bookmarkable.rb b/plugins/chat/lib/chat_message_bookmarkable.rb deleted file mode 100644 index 09353508701..00000000000 --- a/plugins/chat/lib/chat_message_bookmarkable.rb +++ /dev/null @@ -1,69 +0,0 @@ -# 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_join_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_join_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 diff --git a/plugins/chat/lib/chat_message_creator.rb b/plugins/chat/lib/chat_message_creator.rb deleted file mode 100644 index 6cd55f2fedb..00000000000 --- a/plugins/chat/lib/chat_message_creator.rb +++ /dev/null @@ -1,203 +0,0 @@ -# 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, - thread_id: nil, - user:, - content:, - staged_id: nil, - incoming_chat_webhook: nil, - upload_ids: nil - ) - @chat_channel = chat_channel - @user = user - @guardian = Guardian.new(user) - - # NOTE: We confirm this exists and the user can access it in the ChatController, - # but in future the checks should be here - @in_reply_to_id = in_reply_to_id - @content = content - @staged_id = staged_id - @incoming_chat_webhook = incoming_chat_webhook - @upload_ids = upload_ids || [] - @thread_id = thread_id - @error = nil - - @chat_message = - ChatMessage.new( - chat_channel: @chat_channel, - user_id: @user.id, - last_editor_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?) - validate_reply_chain! - validate_existing_thread! - @chat_message.thread_id = @existing_thread&.id - @chat_message.cook - @chat_message.save! - create_chat_webhook_event - create_thread - @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, - ) - @chat_channel.touch(:last_message_sent_at) - DiscourseEvent.trigger(:chat_message_created, @chat_message, @chat_channel, @user) - 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.#{@chat_channel.status}"), - ) - end - end - - def validate_reply_chain! - return if @in_reply_to_id.blank? - - @original_message_id = DB.query_single(<<~SQL).last - WITH RECURSIVE original_message_finder( id, in_reply_to_id ) - AS ( - -- start with the message id we want to find the parents of - SELECT id, in_reply_to_id - FROM chat_messages - WHERE id = #{@in_reply_to_id} - - UNION ALL - - -- get the chain of direct parents of the message - -- following in_reply_to_id - SELECT cm.id, cm.in_reply_to_id - FROM original_message_finder rm - JOIN chat_messages cm ON rm.in_reply_to_id = cm.id - ) - SELECT id FROM original_message_finder - - -- this makes it so only the root parent ID is returned, we can - -- exclude this to return all parents in the chain - WHERE in_reply_to_id IS NULL; - SQL - - if @original_message_id.blank? - raise StandardError.new(I18n.t("chat.errors.original_message_not_found")) - end - - @original_message = ChatMessage.with_deleted.find_by(id: @original_message_id) - if @original_message&.trashed? - raise StandardError.new(I18n.t("chat.errors.original_message_not_found")) - end - end - - def validate_existing_thread! - return if @thread_id.blank? - @existing_thread = ChatThread.find(@thread_id) - - if @existing_thread.channel_id != @chat_channel.id - raise StandardError.new(I18n.t("chat.errors.thread_invalid_for_channel")) - end - - reply_to_thread_mismatch = - @chat_message.in_reply_to&.thread_id && - @chat_message.in_reply_to.thread_id != @existing_thread.id - original_message_has_no_thread = @original_message && @original_message.thread_id.blank? - original_message_thread_mismatch = - @original_message && @original_message.thread_id != @existing_thread.id - if reply_to_thread_mismatch || original_message_has_no_thread || - original_message_thread_mismatch - raise StandardError.new(I18n.t("chat.errors.thread_does_not_match_parent")) - 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 - - def create_thread - return if @in_reply_to_id.blank? - return if @chat_message.thread_id.present? - - thread = - @original_message.thread || - ChatThread.create!( - original_message: @chat_message.in_reply_to, - original_message_user: @chat_message.in_reply_to.user, - channel: @chat_message.chat_channel, - ) - - # NOTE: We intentionally do not try to correct thread IDs within the chain - # if they are incorrect, and only set the thread ID of messages where the - # thread ID is NULL. In future we may want some sync/background job to correct - # any inconsistencies. - DB.exec(<<~SQL) - WITH RECURSIVE thread_updater AS ( - SELECT cm.id, cm.in_reply_to_id - FROM chat_messages cm - WHERE cm.in_reply_to_id IS NULL AND cm.id = #{@original_message_id} - - UNION ALL - - SELECT cm.id, cm.in_reply_to_id - FROM chat_messages cm - JOIN thread_updater ON cm.in_reply_to_id = thread_updater.id - ) - UPDATE chat_messages - SET thread_id = #{thread.id} - FROM thread_updater - WHERE thread_id IS NULL AND chat_messages.id = thread_updater.id - SQL - - @chat_message.thread_id = thread.id - end -end diff --git a/plugins/chat/lib/chat_message_mentions.rb b/plugins/chat/lib/chat_message_mentions.rb deleted file mode 100644 index fd032301f89..00000000000 --- a/plugins/chat/lib/chat_message_mentions.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatMessageMentions - def initialize(message) - @message = message - - mentions = parse_mentions(message) - group_mentions = parse_group_mentions(message) - - @has_global_mention = mentions.include?("@all") - @has_here_mention = mentions.include?("@here") - @parsed_direct_mentions = normalize(mentions) - @parsed_group_mentions = normalize(group_mentions) - end - - attr_accessor :has_global_mention, - :has_here_mention, - :parsed_direct_mentions, - :parsed_group_mentions - - def all_mentioned_users_ids - @all_mentioned_users_ids ||= - begin - user_ids = global_mentions.pluck(:id) - user_ids.concat(direct_mentions.pluck(:id)) - user_ids.concat(group_mentions.pluck(:id)) - user_ids.concat(here_mentions.pluck(:id)) - user_ids.uniq! - user_ids - end - end - - def global_mentions - return User.none unless @has_global_mention - channel_members.where.not(username_lower: @parsed_direct_mentions) - end - - def direct_mentions - chat_users.where(username_lower: @parsed_direct_mentions) - end - - def group_mentions - chat_users.includes(:groups).joins(:groups).where(groups: mentionable_groups) - end - - def here_mentions - return User.none unless @has_here_mention - - channel_members - .where("last_seen_at > ?", 5.minutes.ago) - .where.not(username_lower: @parsed_direct_mentions) - end - - def mentionable_groups - @mentionable_groups ||= - Group.mentionable(@message.user, include_public: false).where(id: visible_groups.map(&:id)) - end - - def visible_groups - @visible_groups ||= - Group.where("LOWER(name) IN (?)", @parsed_group_mentions).visible_groups(@message.user) - end - - private - - def channel_members - chat_users.where( - user_chat_channel_memberships: { - following: true, - chat_channel_id: @message.chat_channel.id, - }, - ) - end - - def chat_users - User - .includes(:user_chat_channel_memberships, :group_users) - .distinct - .joins("LEFT OUTER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id") - .joins(:user_option) - .real - .where(user_options: { chat_enabled: true }) - .where.not(username_lower: @message.user.username.downcase) - end - - def parse_mentions(message) - Nokogiri::HTML5.fragment(message.cooked).css(".mention").map(&:text) - end - - def parse_group_mentions(message) - Nokogiri::HTML5.fragment(message.cooked).css(".mention-group").map(&:text) - end - - def normalize(mentions) - mentions.reduce([]) do |memo, mention| - %w[@here @all].include?(mention.downcase) ? memo : (memo << mention[1..-1].downcase) - end - end -end diff --git a/plugins/chat/lib/chat_message_processor.rb b/plugins/chat/lib/chat_message_processor.rb deleted file mode 100644 index bf2a621d920..00000000000 --- a/plugins/chat/lib/chat_message_processor.rb +++ /dev/null @@ -1,33 +0,0 @@ -# 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, user_id: chat_message.last_editor_id) - @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 diff --git a/plugins/chat/lib/chat_message_rate_limiter.rb b/plugins/chat/lib/chat_message_rate_limiter.rb deleted file mode 100644 index 2706e4e5f64..00000000000 --- a/plugins/chat/lib/chat_message_rate_limiter.rb +++ /dev/null @@ -1,49 +0,0 @@ -# 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 if silenced_for_minutes.zero? - - UserSilencer.silence( - @user, - Discourse.system_user, - silenced_till: silenced_for_minutes.minutes.from_now, - reason: I18n.t("chat.errors.rate_limit_exceeded"), - ) - end -end diff --git a/plugins/chat/lib/chat_message_reactor.rb b/plugins/chat/lib/chat_message_reactor.rb deleted file mode 100644 index 8815fa135e3..00000000000 --- a/plugins/chat/lib/chat_message_reactor.rb +++ /dev/null @@ -1,83 +0,0 @@ -# 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_join_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) - - reaction = nil - ActiveRecord::Base.transaction do - enforce_channel_membership! - reaction = create_reaction(message, react_action, emoji) - end - - publish_reaction(message, react_action, emoji) - - reaction - 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.#{@chat_channel.status}", - ) - 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 diff --git a/plugins/chat/lib/chat_message_updater.rb b/plugins/chat/lib/chat_message_updater.rb deleted file mode 100644 index 43bb028c40d..00000000000 --- a/plugins/chat/lib/chat_message_updater.rb +++ /dev/null @@ -1,95 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatMessageUpdater - attr_reader :error - - def self.update(opts) - instance = new(**opts) - instance.update - instance - end - - def initialize(guardian:, chat_message:, new_content:, upload_ids: nil) - @guardian = guardian - @user = guardian.user - @chat_message = chat_message - @old_message_content = chat_message.message - @chat_channel = @chat_message.chat_channel - @new_content = new_content - @upload_ids = upload_ids - @error = nil - end - - def update - begin - validate_channel_status! - @guardian.ensure_can_edit_chat!(@chat_message) - @chat_message.message = @new_content - @chat_message.last_editor_id = @user.id - 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! - @chat_message.reload - 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) - DiscourseEvent.trigger(:chat_message_edited, @chat_message, @chat_channel, @user) - 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.#{@chat_channel.status}"), - ) - 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] - - DB.exec("DELETE FROM chat_uploads WHERE chat_message_id = #{@chat_message.id}") - UploadReference.where(target: @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, - user_id: @user.id, - ) - end -end diff --git a/plugins/chat/lib/chat_notifier.rb b/plugins/chat/lib/chat_notifier.rb deleted file mode 100644 index 0f8ffea4e11..00000000000 --- a/plugins/chat/lib/chat_notifier.rb +++ /dev/null @@ -1,315 +0,0 @@ -# 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:) - Jobs.enqueue( - :send_message_notifications, - chat_message_id: chat_message.id, - timestamp: timestamp.iso8601(6), - reason: "edit", - ) - end - - def notify_new(chat_message:, timestamp:) - Jobs.enqueue( - :send_message_notifications, - chat_message_id: chat_message.id, - timestamp: timestamp.iso8601(6), - reason: "new", - ) - end - end - - def initialize(chat_message, timestamp) - @chat_message = chat_message - @timestamp = timestamp - @chat_channel = @chat_message.chat_channel - @user = @chat_message.user - @mentions = Chat::ChatMessageMentions.new(chat_message) - end - - ### Public API - - def notify_new - if @mentions.all_mentioned_users_ids.present? - @chat_message.create_mentions(@mentions.all_mentioned_users_ids) - end - - to_notify = list_users_to_notify - 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(to_notify) - - notify_mentioned_users(to_notify) - notify_watching_users(except: mentioned_user_ids << @user.id) - - to_notify - end - - def notify_edit - @chat_message.update_mentions(@mentions.all_mentioned_users_ids) - - 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 - 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(to_notify) - - notify_mentioned_users(to_notify, already_notified_user_ids: already_notified_user_ids) - - to_notify - end - - private - - def list_users_to_notify - mentions_count = - @mentions.parsed_direct_mentions.length + @mentions.parsed_group_mentions.length - mentions_count += 1 if @mentions.has_global_mention - mentions_count += 1 if @mentions.has_here_mention - - skip_notifications = mentions_count > SiteSetting.max_mentions_per_chat_message - - {}.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, skip_notifications) - expand_group_mentions(to_notify, already_covered_ids, skip_notifications) - expand_here_mention(to_notify, already_covered_ids, skip_notifications) - expand_global_mention(to_notify, already_covered_ids, skip_notifications) - - filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids) - - to_notify[:all_mentioned_user_ids] = already_covered_ids - end - end - - def expand_global_mention(to_notify, already_covered_ids, skip) - has_all_mention = @mentions.has_global_mention - - if has_all_mention && @chat_channel.allow_channel_wide_mentions && !skip - to_notify[:global_mentions] = @mentions - .global_mentions - .not_suspended - .where(user_options: { ignore_channel_wide_mention: [false, nil] }) - .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, skip) - has_here_mention = @mentions.has_here_mention - - if has_here_mention && @chat_channel.allow_channel_wide_mentions && !skip - to_notify[:here_mentions] = @mentions - .here_mentions - .not_suspended - .where(user_options: { ignore_channel_wide_mention: [false, nil] }) - .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? && guardian.can_join_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, skip) - if skip - direct_mentions = [] - else - direct_mentions = @mentions.direct_mentions.not_suspended.where.not(id: already_covered_ids) - end - - 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 expand_group_mentions(to_notify, already_covered_ids, skip) - return [] if skip || @mentions.visible_groups.empty? - - reached_by_group = - @mentions - .group_mentions - .not_suspended - .where("user_count <= ?", SiteSetting.max_users_notified_per_group_mention) - .where.not(id: already_covered_ids) - - too_many_members, mentionable = - @mentions.mentionable_groups.partition do |group| - group.user_count > SiteSetting.max_users_notified_per_group_mention - end - - mentions_disabled = @mentions.visible_groups - @mentions.mentionable_groups - to_notify[:group_mentions_disabled] = mentions_disabled - to_notify[:too_many_members] = too_many_members - mentionable.each { |g| to_notify[g.name.downcase] = [] } - - 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 = - @mentions.parsed_group_mentions & mentionable.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 - already_covered_ids << user.id - end - - 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(to_notify) - inaccessible = - to_notify.extract!( - :unreachable, - :welcome_to_join, - :too_many_members, - :group_mentions_disabled, - ) - return if inaccessible.values.all?(&:blank?) - - ChatPublisher.publish_inaccessible_mentions( - @user.id, - @chat_message, - inaccessible[:unreachable].to_a, - inaccessible[:welcome_to_join].to_a, - inaccessible[:too_many_members].to_a, - inaccessible[:group_mentions_disabled].to_a, - ) - 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. - def filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids) - screen_targets = already_covered_ids.concat(to_notify[:welcome_to_join].map(&:id)) - - return if screen_targets.blank? - - screener = UserCommScreener.new(acting_user: @user, target_user_ids: screen_targets) - to_notify - .except(:unreachable, :welcome_to_join) - .each do |key, user_ids| - to_notify[key] = user_ids.reject { |user_id| screener.ignoring_or_muting_actor?(user_id) } - end - - # :welcome_to_join contains users because it's serialized by MB. - to_notify[:welcome_to_join] = to_notify[:welcome_to_join].reject do |user| - screener.ignoring_or_muting_actor?(user.id) - end - - already_covered_ids.reject! do |already_covered| - screener.ignoring_or_muting_actor?(already_covered) - end - 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, - }, - ) - end - - def notify_watching_users(except: []) - Jobs.enqueue( - :chat_notify_watching, - { chat_message_id: @chat_message.id, except_user_ids: except, timestamp: @timestamp }, - ) - end -end diff --git a/plugins/chat/lib/chat_review_queue.rb b/plugins/chat/lib/chat_review_queue.rb deleted file mode 100644 index 4b0392e1511..00000000000 --- a/plugins/chat/lib/chat_review_queue.rb +++ /dev/null @@ -1,208 +0,0 @@ -# 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 diff --git a/plugins/chat/lib/chat_seeder.rb b/plugins/chat/lib/chat_seeder.rb deleted file mode 100644 index 79d8dc23bda..00000000000 --- a/plugins/chat/lib/chat_seeder.rb +++ /dev/null @@ -1,28 +0,0 @@ -# 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 diff --git a/plugins/chat/lib/chat_statistics.rb b/plugins/chat/lib/chat_statistics.rb deleted file mode 100644 index ab79fcf1110..00000000000 --- a/plugins/chat/lib/chat_statistics.rb +++ /dev/null @@ -1,51 +0,0 @@ -# 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 diff --git a/plugins/chat/lib/chat_transcript_service.rb b/plugins/chat/lib/chat_transcript_service.rb deleted file mode 100644 index f61421d35f6..00000000000 --- a/plugins/chat/lib/chat_transcript_service.rb +++ /dev/null @@ -1,177 +0,0 @@ -# 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, upload_references: :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 diff --git a/plugins/chat/lib/direct_message_channel_creator.rb b/plugins/chat/lib/direct_message_channel_creator.rb deleted file mode 100644 index 505801d42fe..00000000000 --- a/plugins/chat/lib/direct_message_channel_creator.rb +++ /dev/null @@ -1,130 +0,0 @@ -# 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_message = DirectMessage.for_user_ids(target_users.map(&:id)) - if direct_message - chat_channel = ChatChannel.find_by!(chatable: direct_message) - else - enforce_max_direct_message_users!(acting_user, target_users) - ensure_actor_can_communicate!(acting_user, target_users) - direct_message = DirectMessage.create!(user_ids: target_users.map(&:id)) - chat_channel = direct_message.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.enforce_max_direct_message_users!(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 } - - if !acting_user.staff? && target_users.size > SiteSetting.chat_max_direct_message_users - if SiteSetting.chat_max_direct_message_users == 0 - raise NotAllowed.new(I18n.t("chat.errors.over_chat_max_direct_message_users_allow_self")) - else - raise NotAllowed.new( - I18n.t( - "chat.errors.over_chat_max_direct_message_users", - count: SiteSetting.chat_max_direct_message_users + 1, # +1 for the acting_user - ), - ) - end - end - end - - 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 diff --git a/plugins/chat/lib/discourse_dev/direct_channel.rb b/plugins/chat/lib/discourse_dev/direct_channel.rb deleted file mode 100644 index 4ee6e835fe2..00000000000 --- a/plugins/chat/lib/discourse_dev/direct_channel.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require "discourse_dev/record" -require "faker" - -module DiscourseDev - class DirectChannel < Record - def initialize - super(::DirectMessage, 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 diff --git a/plugins/chat/lib/discourse_dev/message.rb b/plugins/chat/lib/discourse_dev/message.rb deleted file mode 100644 index 6cd72225a11..00000000000 --- a/plugins/chat/lib/discourse_dev/message.rb +++ /dev/null @@ -1,30 +0,0 @@ -# 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: "DirectMessage").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 diff --git a/plugins/chat/lib/discourse_dev/public_channel.rb b/plugins/chat/lib/discourse_dev/public_channel.rb deleted file mode 100644 index cb9c672caa9..00000000000 --- a/plugins/chat/lib/discourse_dev/public_channel.rb +++ /dev/null @@ -1,44 +0,0 @@ -# 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 diff --git a/plugins/chat/lib/duplicate_message_validator.rb b/plugins/chat/lib/duplicate_message_validator.rb deleted file mode 100644 index 7b094692ff4..00000000000 --- a/plugins/chat/lib/duplicate_message_validator.rb +++ /dev/null @@ -1,46 +0,0 @@ -# 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 diff --git a/plugins/chat/lib/extensions/category_extension.rb b/plugins/chat/lib/extensions/category_extension.rb deleted file mode 100644 index d12ce387645..00000000000 --- a/plugins/chat/lib/extensions/category_extension.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Chat::CategoryExtension - extend ActiveSupport::Concern - - include Chatable - - prepended { has_one :category_channel, as: :chatable, dependent: :destroy } - - def cannot_delete_reason - return I18n.t("category.cannot_delete.has_chat_channels") if category_channel - super - end - - def deletable_for_chat? - return true if !category_channel - category_channel.chat_messages_empty? - end -end diff --git a/plugins/chat/lib/extensions/user_email_extension.rb b/plugins/chat/lib/extensions/user_email_extension.rb deleted file mode 100644 index 6742dccbe37..00000000000 --- a/plugins/chat/lib/extensions/user_email_extension.rb +++ /dev/null @@ -1,15 +0,0 @@ -# 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 diff --git a/plugins/chat/lib/extensions/user_extension.rb b/plugins/chat/lib/extensions/user_extension.rb deleted file mode 100644 index b4c041d4d8b..00000000000 --- a/plugins/chat/lib/extensions/user_extension.rb +++ /dev/null @@ -1,11 +0,0 @@ -# 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 diff --git a/plugins/chat/lib/extensions/user_notifications_extension.rb b/plugins/chat/lib/extensions/user_notifications_extension.rb deleted file mode 100644 index 93d3039d705..00000000000 --- a/plugins/chat/lib/extensions/user_notifications_extension.rb +++ /dev/null @@ -1,142 +0,0 @@ -# frozen_string_literal: true - -module Chat::UserNotificationsExtension - def chat_summary(user, opts) - guardian = Guardian.new(user) - return unless guardian.can_chat? - - @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 AND cm.notification_id IS NOT NULL", - ) - .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 = 'DirectMessage') - ) - SQL - .to_a - - return if @messages.empty? - @grouped_messages = @messages.group_by { |message| message.chat_channel } - @grouped_messages = - @grouped_messages.select { |channel, _| guardian.can_join_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) - all_channels = grouped_messages.keys - grouped_channels = all_channels.partition { |c| !c.direct_message_channel? } - channels = grouped_channels.first - - dm_messages = grouped_channels.last.flat_map { |c| grouped_messages[c] } - dm_users = dm_messages.sort_by(&:created_at).uniq { |m| m.user_id }.map(&:user) - - # Prioritize messages from regular channels over direct messages - if channels.any? - channel_notification_text( - channels.sort_by { |channel| [channel.last_message_sent_at, channel.created_at] }, - dm_users, - ) - else - direct_message_notification_text(dm_users) - end - end - - private - - def channel_notification_text(channels, dm_users) - total_count = channels.size + dm_users.size - - if total_count > 2 - I18n.t( - "user_notifications.chat_summary.subject.chat_channel_more", - email_prefix: @email_prefix, - channel: channels.first.title, - count: total_count - 1, - ) - elsif channels.size == 1 && dm_users.size == 0 - I18n.t( - "user_notifications.chat_summary.subject.chat_channel_1", - email_prefix: @email_prefix, - channel: channels.first.title, - ) - elsif channels.size == 1 && dm_users.size == 1 - I18n.t( - "user_notifications.chat_summary.subject.chat_channel_and_direct_message", - email_prefix: @email_prefix, - channel: channels.first.title, - username: dm_users.first.username, - ) - elsif channels.size == 2 - I18n.t( - "user_notifications.chat_summary.subject.chat_channel_2", - email_prefix: @email_prefix, - channel1: channels.first.title, - channel2: channels.second.title, - ) - end - end - - def direct_message_notification_text(dm_users) - case dm_users.size - when 1 - I18n.t( - "user_notifications.chat_summary.subject.direct_message_from_1", - email_prefix: @email_prefix, - username: dm_users.first.username, - ) - when 2 - I18n.t( - "user_notifications.chat_summary.subject.direct_message_from_2", - email_prefix: @email_prefix, - username1: dm_users.first.username, - username2: dm_users.second.username, - ) - else - I18n.t( - "user_notifications.chat_summary.subject.direct_message_from_more", - email_prefix: @email_prefix, - username: dm_users.first.username, - count: dm_users.size - 1, - ) - end - end -end diff --git a/plugins/chat/lib/extensions/user_option_extension.rb b/plugins/chat/lib/extensions/user_option_extension.rb deleted file mode 100644 index 467f1a84a67..00000000000 --- a/plugins/chat/lib/extensions/user_option_extension.rb +++ /dev/null @@ -1,23 +0,0 @@ -# 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 - - def base.chat_header_indicator_preferences - @chat_header_indicator_preferences ||= { all_new: 0, dm_and_mentions: 1, never: 2 } - end - - base.enum :chat_email_frequency, base.chat_email_frequencies, prefix: "send_chat_email" - base.enum :chat_header_indicator_preference, base.chat_header_indicator_preferences - end -end diff --git a/plugins/chat/lib/guardian_extensions.rb b/plugins/chat/lib/guardian_extensions.rb deleted file mode 100644 index e57f7c5927b..00000000000 --- a/plugins/chat/lib/guardian_extensions.rb +++ /dev/null @@ -1,189 +0,0 @@ -# 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? - return false if anonymous? - @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? - - # FIXME: This logic shouldn't be handled in guardian - 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_preview_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_join_chat_channel?(chat_channel) - return false if anonymous? - can_preview_chat_channel?(chat_channel) && - (chat_channel.direct_message_channel? || can_post_in_category?(chat_channel.chatable)) - end - - def can_flag_chat_messages? - return false if @user.silenced? - return true if @user.staff? - - @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_join_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 - when Category - return can_see_category?(chatable) - when DirectMessage - 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.deletable_for_chat? - end -end diff --git a/plugins/chat/lib/message_mover.rb b/plugins/chat/lib/message_mover.rb deleted file mode 100644 index 767d5175a47..00000000000 --- a/plugins/chat/lib/message_mover.rb +++ /dev/null @@ -1,242 +0,0 @@ -# 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. -# -# Reply chains are a little complex. No reply chains are preserved when moving -# messages into a new channel. Remaining messages that referenced moved ones -# have their in_reply_to_id cleared so the data makes sense. -# -# Threads are even more complex. No threads are preserved when moving messages -# into a new channel, they end up as just a flat series of messages that are -# not in a chain. If the original message of a thread and N other messages -# in that thread, then any messages left behind just get placed into a new -# thread. Message moving will be disabled in the thread UI while -# enable_experimental_chat_threaded_discussions is present, its too complicated -# to have end users reason about for now, and we may want a standalone -# "Move Thread" UI later on. -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 - update_reply_references - update_thread_references - end - - add_moved_placeholder(destination_channel, moved_messages.first) - moved_messages - end - - private - - def find_messages(message_ids, channel) - ChatMessage - .includes(thread: %i[original_message original_message_user]) - .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, last_editor_id, message, cooked, cooked_version, created_at, updated_at - ) - SELECT :destination_channel_id, - user_id, - last_editor_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 upload_references uref - SET target_id = mm.new_chat_message_id - FROM moved_chat_messages mm - WHERE uref.target_id = mm.old_chat_message_id AND uref.target_type = 'ChatMessage' - 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 - # We do this so @source_messages is not nulled out, which is the - # case when using update_all here. - DB.exec(<<~SQL, source_message_ids: @source_message_ids, deleted_by_id: @acting_user.id) - UPDATE chat_messages - SET deleted_at = NOW(), deleted_by_id = :deleted_by_id - WHERE id IN (:source_message_ids) - SQL - 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 - - def update_reply_references - DB.exec(<<~SQL, deleted_reply_to_ids: @source_message_ids) - UPDATE chat_messages - SET in_reply_to_id = NULL - WHERE in_reply_to_id IN (:deleted_reply_to_ids) - SQL - end - - def update_thread_references - threads_to_update = [] - @source_messages - .select { |message| message.thread_id.present? } - .each do |message_with_thread| - # If one of the messages we are moving is the original message in a thread, - # then all the remaining messages for that thread must be moved to a new one, - # otherwise they will be pointing to a thread in a different channel. - if message_with_thread.thread.original_message_id == message_with_thread.id - threads_to_update << message_with_thread.thread - end - end - - threads_to_update.each do |thread| - # NOTE: We may want to do something different with the old empty thread at some - # point when we add an explicit thread move UI, for now we can just delete it, - # since it will not contain any important data. - if thread.chat_messages.empty? - thread.destroy! - next - end - - ChatThread.transaction do - original_message = thread.chat_messages.first - new_thread = - ChatThread.create!( - original_message: original_message, - original_message_user: original_message.user, - channel: @source_channel, - ) - thread.chat_messages.update_all(thread_id: new_thread.id) - end - end - end -end diff --git a/plugins/chat/lib/post_notification_handler.rb b/plugins/chat/lib/post_notification_handler.rb deleted file mode 100644 index beefe24ab73..00000000000 --- a/plugins/chat/lib/post_notification_handler.rb +++ /dev/null @@ -1,40 +0,0 @@ -# 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 diff --git a/plugins/chat/lib/secure_uploads_compatibility.rb b/plugins/chat/lib/secure_uploads_compatibility.rb deleted file mode 100644 index 6fd898f10bc..00000000000 --- a/plugins/chat/lib/secure_uploads_compatibility.rb +++ /dev/null @@ -1,23 +0,0 @@ -# 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 diff --git a/plugins/chat/lib/service_runner.rb b/plugins/chat/lib/service_runner.rb index 21084cedd42..b82ff5c9dcf 100644 --- a/plugins/chat/lib/service_runner.rb +++ b/plugins/chat/lib/service_runner.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true # -# = Chat::ServiceRunner +# = ServiceRunner # # This class is to be used via its helper +with_service+ in any class. Its # main purpose is to ease how actions can be run upon a service completion. @@ -45,7 +45,8 @@ # The only exception to this being +on_failure+ as it will always be executed # last. # -class Chat::ServiceRunner + +class ServiceRunner # @!visibility private NULL_RESULT = OpenStruct.new(failure?: false) # @!visibility private @@ -70,7 +71,7 @@ class Chat::ServiceRunner @actions = {} end - # @param service [Class] a class including {Chat::Service::Base} + # @param service [Class] a class including {Service::Base} # @param block [Proc] a block containing the steps to match on # @return [void] def self.call(service, object, **dependencies, &block) diff --git a/plugins/chat/lib/slack_compatibility.rb b/plugins/chat/lib/slack_compatibility.rb deleted file mode 100644 index 106af32caf3..00000000000 --- a/plugins/chat/lib/slack_compatibility.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -## -# Processes slack-formatted text messages, as Mattermost does with -# Slack incoming webhook interoperability, for example links in the -# format and , and 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") - text = text.gsub("", "@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 diff --git a/plugins/chat/lib/tasks/chat.rake b/plugins/chat/lib/tasks/chat.rake deleted file mode 100644 index a53e1b319cc..00000000000 --- a/plugins/chat/lib/tasks/chat.rake +++ /dev/null @@ -1,23 +0,0 @@ -# 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 diff --git a/plugins/chat/lib/tasks/chat_message.rake b/plugins/chat/lib/tasks/chat_message.rake index 603722e4ba4..316033fcd21 100644 --- a/plugins/chat/lib/tasks/chat_message.rake +++ b/plugins/chat/lib/tasks/chat_message.rake @@ -15,7 +15,7 @@ end def rebake_uncooked_chat_messages puts "Rebaking uncooked chat messages on #{RailsMultisite::ConnectionManagement.current_db}" - uncooked = ChatMessage.uncooked + uncooked = Chat::Message.uncooked rebaked = 0 total = uncooked.count @@ -100,7 +100,7 @@ task "chat:make_channel_to_test_archiving", [:user_for_membership] => :environme raw: "This is some cool first post for archive stuff", ) chat_channel = - ChatChannel.create( + Chat::Channel.create( chatable: topic, chatable_type: "Topic", name: "testing channel for archiving #{SecureRandom.hex(4)}", @@ -112,12 +112,13 @@ task "chat:make_channel_to_test_archiving", [:user_for_membership] => :environme users = [make_test_user, make_test_user, make_test_user] - ChatChannel.transaction do + Chat::Channel.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 = + Chat::Message.new(message: messages.sample, user: users.sample, chat_channel: chat_channel) cm.cook cm.save! end @@ -125,7 +126,7 @@ task "chat:make_channel_to_test_archiving", [:user_for_membership] => :environme puts "message creation done" puts "took #{Time.now - start_time} seconds" - UserChatChannelMembership.create( + Chat::UserChatChannelMembership.create( chat_channel: chat_channel, last_read_message_id: 0, user: User.find_by(username: user_for_membership), diff --git a/plugins/chat/lib/validators/chat_allow_uploads_validator.rb b/plugins/chat/lib/validators/chat_allow_uploads_validator.rb deleted file mode 100644 index bd7bbd4b020..00000000000 --- a/plugins/chat/lib/validators/chat_allow_uploads_validator.rb +++ /dev/null @@ -1,22 +0,0 @@ -# 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 diff --git a/plugins/chat/lib/validators/chat_default_channel_validator.rb b/plugins/chat/lib/validators/chat_default_channel_validator.rb deleted file mode 100644 index 917663fcfea..00000000000 --- a/plugins/chat/lib/validators/chat_default_channel_validator.rb +++ /dev/null @@ -1,15 +0,0 @@ -# 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 diff --git a/plugins/chat/lib/validators/direct_message_enabled_groups_validator.rb b/plugins/chat/lib/validators/direct_message_enabled_groups_validator.rb deleted file mode 100644 index bcd54905124..00000000000 --- a/plugins/chat/lib/validators/direct_message_enabled_groups_validator.rb +++ /dev/null @@ -1,15 +0,0 @@ -# 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 diff --git a/plugins/chat/plugin.rb b/plugins/chat/plugin.rb index 29f7347f35c..1f2f5b81d97 100644 --- a/plugins/chat/plugin.rb +++ b/plugins/chat/plugin.rb @@ -9,74 +9,16 @@ enabled_site_setting :chat_enabled -register_asset "stylesheets/mixins/chat-scrollbar.scss" -register_asset "stylesheets/common/core-extensions.scss" -register_asset "stylesheets/common/chat-emoji-picker.scss" -register_asset "stylesheets/common/chat-channel-card.scss" -register_asset "stylesheets/common/create-channel-modal.scss" -register_asset "stylesheets/common/dc-filter-input.scss" -register_asset "stylesheets/common/common.scss" -register_asset "stylesheets/common/chat-browse.scss" -register_asset "stylesheets/common/chat-drawer.scss" -register_asset "stylesheets/common/chat-index.scss" -register_asset "stylesheets/mobile/chat-index.scss", :mobile -register_asset "stylesheets/desktop/chat-index-full-page.scss", :desktop -register_asset "stylesheets/desktop/chat-index-drawer.scss", :desktop -register_asset "stylesheets/common/chat-channel-preview-card.scss" -register_asset "stylesheets/common/chat-channel-info.scss" -register_asset "stylesheets/common/chat-draft-channel.scss" -register_asset "stylesheets/common/chat-tabs.scss" -register_asset "stylesheets/common/chat-form.scss" -register_asset "stylesheets/common/d-progress-bar.scss" -register_asset "stylesheets/common/incoming-chat-webhooks.scss" -register_asset "stylesheets/mobile/chat-message.scss", :mobile -register_asset "stylesheets/desktop/chat-message.scss", :desktop -register_asset "stylesheets/common/chat-channel-title.scss" -register_asset "stylesheets/desktop/chat-channel-title.scss", :desktop -register_asset "stylesheets/common/full-page-chat-header.scss" -register_asset "stylesheets/common/chat-reply.scss" -register_asset "stylesheets/common/chat-message.scss" -register_asset "stylesheets/common/chat-message-left-gutter.scss" -register_asset "stylesheets/common/chat-message-info.scss" -register_asset "stylesheets/common/chat-composer-inline-button.scss" -register_asset "stylesheets/common/chat-replying-indicator.scss" -register_asset "stylesheets/common/chat-composer.scss" -register_asset "stylesheets/desktop/chat-composer.scss", :desktop -register_asset "stylesheets/mobile/chat-composer.scss", :mobile -register_asset "stylesheets/common/direct-message-creator.scss" -register_asset "stylesheets/common/chat-message-collapser.scss" -register_asset "stylesheets/common/chat-message-images.scss" -register_asset "stylesheets/common/chat-transcript.scss" -register_asset "stylesheets/common/chat-composer-dropdown.scss" -register_asset "stylesheets/common/chat-retention-reminder.scss" -register_asset "stylesheets/common/chat-composer-uploads.scss" -register_asset "stylesheets/desktop/chat-composer-uploads.scss", :desktop -register_asset "stylesheets/common/chat-composer-upload.scss" -register_asset "stylesheets/common/chat-selection-manager.scss" -register_asset "stylesheets/mobile/chat-selection-manager.scss", :mobile -register_asset "stylesheets/common/chat-channel-selector-modal.scss" -register_asset "stylesheets/mobile/mobile.scss", :mobile -register_asset "stylesheets/desktop/desktop.scss", :desktop -register_asset "stylesheets/sidebar-extensions.scss" -register_asset "stylesheets/desktop/sidebar-extensions.scss", :desktop -register_asset "stylesheets/common/chat-message-actions.scss" -register_asset "stylesheets/desktop/chat-message-actions.scss", :desktop -register_asset "stylesheets/mobile/chat-message-actions.scss", :mobile -register_asset "stylesheets/common/chat-message-separator.scss" -register_asset "stylesheets/common/chat-onebox.scss" -register_asset "stylesheets/common/chat-skeleton.scss" register_asset "stylesheets/colors.scss", :color_definitions -register_asset "stylesheets/common/reviewable-chat-message.scss" -register_asset "stylesheets/common/chat-mention-warnings.scss" -register_asset "stylesheets/common/chat-channel-settings-saved-indicator.scss" -register_asset "stylesheets/common/chat-thread.scss" -register_asset "stylesheets/common/chat-side-panel.scss" +register_asset "stylesheets/mixins/index.scss" +register_asset "stylesheets/common/index.scss" +register_asset "stylesheets/desktop/index.scss", :desktop +register_asset "stylesheets/mobile/index.scss", :mobile register_svg_icon "comments" register_svg_icon "comment-slash" register_svg_icon "hashtag" register_svg_icon "lock" - register_svg_icon "file-audio" register_svg_icon "file-video" register_svg_icon "file-image" @@ -84,178 +26,17 @@ register_svg_icon "file-image" # route: /admin/plugins/chat add_admin_route "chat.admin.title", "chat" -# Site setting validators must be loaded before initialize -require_relative "lib/validators/chat_default_channel_validator.rb" -require_relative "lib/validators/chat_allow_uploads_validator.rb" -require_relative "lib/validators/direct_message_enabled_groups_validator.rb" -require_relative "app/core_ext/plugin_instance.rb" - GlobalSetting.add_default(:allow_unsecure_chat_uploads, false) +module ::Chat + PLUGIN_NAME = "chat" +end + +require_relative "lib/chat/engine" + after_initialize do - # Namespace for classes and modules parts of chat plugin - module ::Chat - PLUGIN_NAME = "chat" - HAS_CHAT_ENABLED = "has_chat_enabled" - - class Engine < ::Rails::Engine - engine_name PLUGIN_NAME - isolate_namespace Chat - end - - def self.allowed_group_ids - SiteSetting.chat_allowed_groups_map - end - - def self.onebox_template - @onebox_template ||= - begin - path = "#{Rails.root}/plugins/chat/lib/onebox/templates/discourse_chat.mustache" - File.read(path) - end - end - end - register_seedfu_fixtures(Rails.root.join("plugins", "chat", "db", "fixtures")) - load File.expand_path( - "../app/controllers/admin/admin_incoming_chat_webhooks_controller.rb", - __FILE__, - ) - load File.expand_path("../app/helpers/with_service_helper.rb", __FILE__) - load File.expand_path("../app/controllers/chat_base_controller.rb", __FILE__) - load File.expand_path("../app/controllers/chat_controller.rb", __FILE__) - load File.expand_path("../app/controllers/emojis_controller.rb", __FILE__) - load File.expand_path("../app/controllers/direct_messages_controller.rb", __FILE__) - load File.expand_path("../app/controllers/incoming_chat_webhooks_controller.rb", __FILE__) - load File.expand_path("../app/models/concerns/chatable.rb", __FILE__) - load File.expand_path("../app/models/deleted_chat_user.rb", __FILE__) - load File.expand_path("../app/models/user_chat_channel_membership.rb", __FILE__) - load File.expand_path("../app/models/chat_channel.rb", __FILE__) - load File.expand_path("../app/models/chat_channel_archive.rb", __FILE__) - load File.expand_path("../app/models/chat_draft.rb", __FILE__) - load File.expand_path("../app/models/chat_message.rb", __FILE__) - load File.expand_path("../app/models/chat_message_reaction.rb", __FILE__) - load File.expand_path("../app/models/chat_message_revision.rb", __FILE__) - load File.expand_path("../app/models/chat_mention.rb", __FILE__) - load File.expand_path("../app/models/chat_thread.rb", __FILE__) - load File.expand_path("../app/models/chat_upload.rb", __FILE__) - load File.expand_path("../app/models/chat_webhook_event.rb", __FILE__) - load File.expand_path("../app/models/direct_message_channel.rb", __FILE__) - load File.expand_path("../app/models/direct_message.rb", __FILE__) - load File.expand_path("../app/models/direct_message_user.rb", __FILE__) - load File.expand_path("../app/models/incoming_chat_webhook.rb", __FILE__) - load File.expand_path("../app/models/reviewable_chat_message.rb", __FILE__) - load File.expand_path("../app/models/chat_view.rb", __FILE__) - load File.expand_path("../app/models/category_channel.rb", __FILE__) - load File.expand_path("../app/serializers/chat_message_user_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/structured_channel_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_webhook_event_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_in_reply_to_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/base_chat_channel_membership_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/user_chat_channel_membership_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_message_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_channel_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_channel_index_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_channel_search_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_thread_original_message_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_thread_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_view_serializer.rb", __FILE__) - load File.expand_path( - "../app/serializers/user_with_custom_fields_and_status_serializer.rb", - __FILE__, - ) - load File.expand_path("../app/serializers/direct_message_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/incoming_chat_webhook_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/admin_chat_index_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/user_chat_message_bookmark_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/reviewable_chat_message_serializer.rb", __FILE__) - load File.expand_path("../app/services/base.rb", __FILE__) - load File.expand_path("../lib/chat_channel_fetcher.rb", __FILE__) - load File.expand_path("../lib/chat_channel_hashtag_data_source.rb", __FILE__) - load File.expand_path("../lib/chat_mailer.rb", __FILE__) - load File.expand_path("../lib/chat_message_creator.rb", __FILE__) - load File.expand_path("../lib/chat_message_processor.rb", __FILE__) - load File.expand_path("../lib/chat_message_updater.rb", __FILE__) - load File.expand_path("../lib/chat_message_rate_limiter.rb", __FILE__) - load File.expand_path("../lib/chat_message_reactor.rb", __FILE__) - load File.expand_path("../lib/chat_message_mentions.rb", __FILE__) - load File.expand_path("../lib/chat_notifier.rb", __FILE__) - load File.expand_path("../lib/chat_seeder.rb", __FILE__) - load File.expand_path("../lib/chat_statistics.rb", __FILE__) - load File.expand_path("../lib/chat_transcript_service.rb", __FILE__) - load File.expand_path("../lib/duplicate_message_validator.rb", __FILE__) - load File.expand_path("../lib/message_mover.rb", __FILE__) - load File.expand_path("../lib/chat_channel_membership_manager.rb", __FILE__) - load File.expand_path("../lib/chat_message_bookmarkable.rb", __FILE__) - load File.expand_path("../lib/chat_channel_archive_service.rb", __FILE__) - load File.expand_path("../lib/chat_review_queue.rb", __FILE__) - load File.expand_path("../lib/direct_message_channel_creator.rb", __FILE__) - load File.expand_path("../lib/guardian_extensions.rb", __FILE__) - load File.expand_path("../lib/extensions/user_option_extension.rb", __FILE__) - load File.expand_path("../lib/extensions/user_notifications_extension.rb", __FILE__) - load File.expand_path("../lib/extensions/user_email_extension.rb", __FILE__) - load File.expand_path("../lib/extensions/category_extension.rb", __FILE__) - load File.expand_path("../lib/extensions/user_extension.rb", __FILE__) - load File.expand_path("../lib/slack_compatibility.rb", __FILE__) - load File.expand_path("../lib/post_notification_handler.rb", __FILE__) - load File.expand_path("../lib/secure_uploads_compatibility.rb", __FILE__) - load File.expand_path("../lib/service_runner.rb", __FILE__) - load File.expand_path("../lib/steps_inspector.rb", __FILE__) - load File.expand_path("../app/jobs/regular/auto_manage_channel_memberships.rb", __FILE__) - load File.expand_path("../app/jobs/regular/auto_join_channel_batch.rb", __FILE__) - load File.expand_path("../app/jobs/regular/process_chat_message.rb", __FILE__) - load File.expand_path("../app/jobs/regular/chat_channel_archive.rb", __FILE__) - load File.expand_path("../app/jobs/regular/chat_channel_delete.rb", __FILE__) - load File.expand_path("../app/jobs/regular/chat_notify_mentioned.rb", __FILE__) - load File.expand_path("../app/jobs/regular/chat_notify_watching.rb", __FILE__) - load File.expand_path("../app/jobs/regular/update_channel_user_count.rb", __FILE__) - load File.expand_path("../app/jobs/regular/delete_user_messages.rb", __FILE__) - load File.expand_path("../app/jobs/regular/send_message_notifications.rb", __FILE__) - load File.expand_path("../app/jobs/scheduled/delete_old_chat_messages.rb", __FILE__) - load File.expand_path("../app/jobs/scheduled/update_user_counts_for_chat_channels.rb", __FILE__) - load File.expand_path("../app/jobs/scheduled/email_chat_notifications.rb", __FILE__) - load File.expand_path("../app/jobs/scheduled/auto_join_users.rb", __FILE__) - load File.expand_path("../app/jobs/scheduled/chat_periodical_updates.rb", __FILE__) - load File.expand_path("../app/jobs/service_job.rb", __FILE__) - load File.expand_path("../app/services/chat_publisher.rb", __FILE__) - load File.expand_path("../app/services/trash_channel.rb", __FILE__) - load File.expand_path("../app/services/update_channel.rb", __FILE__) - load File.expand_path("../app/services/update_channel_status.rb", __FILE__) - load File.expand_path("../app/services/chat_message_destroyer.rb", __FILE__) - load File.expand_path("../app/services/update_user_last_read.rb", __FILE__) - load File.expand_path("../app/services/lookup_thread.rb", __FILE__) - load File.expand_path("../app/controllers/api_controller.rb", __FILE__) - load File.expand_path("../app/controllers/api/chat_channels_controller.rb", __FILE__) - load File.expand_path("../app/controllers/api/chat_current_user_channels_controller.rb", __FILE__) - load File.expand_path( - "../app/controllers/api/chat_channels_current_user_membership_controller.rb", - __FILE__, - ) - load File.expand_path("../app/controllers/api/chat_channels_memberships_controller.rb", __FILE__) - load File.expand_path( - "../app/controllers/api/chat_channels_messages_moves_controller.rb", - __FILE__, - ) - load File.expand_path("../app/controllers/api/chat_channels_archives_controller.rb", __FILE__) - load File.expand_path("../app/controllers/api/chat_channels_status_controller.rb", __FILE__) - load File.expand_path( - "../app/controllers/api/chat_channels_current_user_notifications_settings_controller.rb", - __FILE__, - ) - load File.expand_path("../app/controllers/api/category_chatables_controller.rb", __FILE__) - load File.expand_path("../app/controllers/api/hints_controller.rb", __FILE__) - load File.expand_path("../app/controllers/api/chat_channel_threads_controller.rb", __FILE__) - load File.expand_path("../app/controllers/api/chat_chatables_controller.rb", __FILE__) - load File.expand_path("../app/queries/chat_channel_unreads_query.rb", __FILE__) - load File.expand_path("../app/queries/chat_channel_memberships_query.rb", __FILE__) - - if Discourse.allow_dev_populate? - load File.expand_path("../lib/discourse_dev/public_channel.rb", __FILE__) - load File.expand_path("../lib/discourse_dev/direct_channel.rb", __FILE__) - load File.expand_path("../lib/discourse_dev/message.rb", __FILE__) - end - UserNotifications.append_view_path(File.expand_path("../app/views", __FILE__)) register_category_custom_field_type(Chat::HAS_CHAT_ENABLED, :boolean) @@ -267,7 +48,7 @@ after_initialize do UserUpdater::OPTION_ATTR.push(:chat_email_frequency) UserUpdater::OPTION_ATTR.push(:chat_header_indicator_preference) - register_reviewable_type ReviewableChatMessage + register_reviewable_type Chat::ReviewableMessage reloadable_patch do |plugin| ReviewableScore.add_new_types([:needs_review]) @@ -278,21 +59,24 @@ after_initialize do UserNotifications.prepend Chat::UserNotificationsExtension UserOption.prepend Chat::UserOptionExtension Category.prepend Chat::CategoryExtension + Reviewable.prepend Chat::ReviewableExtension + Bookmark.prepend Chat::BookmarkExtension User.prepend Chat::UserExtension Jobs::UserEmail.prepend Chat::UserEmailExtension + Plugin::Instance.prepend Chat::PluginInstanceExtension end if Oneboxer.respond_to?(:register_local_handler) Oneboxer.register_local_handler("chat/chat") do |url, route| if route[:message_id].present? - message = ChatMessage.find_by(id: route[:message_id]) + message = Chat::Message.find_by(id: route[:message_id]) next if !message chat_channel = message.chat_channel user = message.user next if !chat_channel || !user else - chat_channel = ChatChannel.find_by(id: route[:channel_id]) + chat_channel = Chat::Channel.find_by(id: route[:channel_id]) next if !chat_channel end @@ -346,7 +130,7 @@ after_initialize do if InlineOneboxer.respond_to?(:register_local_handler) InlineOneboxer.register_local_handler("chat/chat") do |url, route| if route[:message_id].present? - message = ChatMessage.find_by(id: route[:message_id]) + message = Chat::Message.find_by(id: route[:message_id]) next if !message chat_channel = message.chat_channel @@ -361,7 +145,7 @@ after_initialize do username: user.username, ) else - chat_channel = ChatChannel.find_by(id: route[:channel_id]) + chat_channel = Chat::Channel.find_by(id: route[:channel_id]) next if !chat_channel title = @@ -378,12 +162,12 @@ after_initialize do if respond_to?(:register_upload_in_use) register_upload_in_use do |upload| - ChatMessage.where( + Chat::Message.where( "message LIKE ? OR message LIKE ?", "%#{upload.sha1}%", "%#{upload.base62_sha1}%", ).exists? || - ChatDraft.where( + Chat::Draft.where( "data LIKE ? OR data LIKE ?", "%#{upload.sha1}%", "%#{upload.base62_sha1}%", @@ -425,7 +209,7 @@ after_initialize do add_to_serializer(:current_user, :needs_dm_retention_reminder) { true } add_to_serializer(:current_user, :has_joinable_public_channels) do - Chat::ChatChannelFetcher.secured_public_channel_search( + Chat::ChannelFetcher.secured_public_channel_search( self.scope, following: false, limit: 1, @@ -434,8 +218,8 @@ after_initialize do end add_to_serializer(:current_user, :chat_channels) do - structured = Chat::ChatChannelFetcher.structured(self.scope) - ChatChannelIndexSerializer.new(structured, scope: self.scope, root: false).as_json + structured = Chat::ChannelFetcher.structured(self.scope) + Chat::ChannelIndexSerializer.new(structured, scope: self.scope, root: false).as_json end add_to_serializer(:current_user, :include_needs_channel_retention_reminder?) do @@ -450,7 +234,7 @@ after_initialize do end add_to_serializer(:current_user, :chat_drafts) do - ChatDraft + Chat::Draft .where(user_id: object.id) .order(updated_at: :desc) .limit(20) @@ -519,7 +303,7 @@ after_initialize do register_presence_channel_prefix("chat-reply") do |channel_name| if chat_channel_id = channel_name[%r{/chat-reply/(\d+)}, 1] - chat_channel = ChatChannel.find(chat_channel_id) + chat_channel = Chat::Channel.find(chat_channel_id) PresenceChannel::Config.new.tap do |config| config.allowed_group_ids = chat_channel.allowed_group_ids @@ -553,27 +337,27 @@ after_initialize do on(:user_seen) do |user| if user.last_seen_at == user.first_seen_at - ChatChannel + Chat::Channel .where(auto_join_users: true) .each do |channel| - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) + Chat::ChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) end end end on(:user_confirmed_email) do |user| if user.active? - ChatChannel + Chat::Channel .where(auto_join_users: true) .each do |channel| - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) + Chat::ChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) end end end on(:user_added_to_group) do |user, group| channels_to_add = - ChatChannel + Chat::Channel .distinct .where(auto_join_users: true, chatable_type: "Category") .joins( @@ -582,7 +366,7 @@ after_initialize do .where(category_groups: { group_id: group.id }) channels_to_add.each do |channel| - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) + Chat::ChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) end end @@ -590,116 +374,25 @@ after_initialize do # TODO(roman): remove early return after 2.9 release. # There's a bug on core where this event is triggered with an `#update` result (true/false) return if !category.is_a?(Category) - category_channel = ChatChannel.find_by(auto_join_users: true, chatable: category) + category_channel = Chat::Channel.find_by(auto_join_users: true, chatable: category) if category_channel - Chat::ChatChannelMembershipManager.new(category_channel).enforce_automatic_channel_memberships - end - end - - Chat::Engine.routes.draw do - namespace :api, defaults: { format: :json } do - get "/chatables" => "chat_chatables#index" - get "/channels" => "chat_channels#index" - get "/channels/me" => "chat_current_user_channels#index" - post "/channels" => "chat_channels#create" - delete "/channels/:channel_id" => "chat_channels#destroy" - put "/channels/:channel_id" => "chat_channels#update" - get "/channels/:channel_id" => "chat_channels#show" - put "/channels/:channel_id/status" => "chat_channels_status#update" - post "/channels/:channel_id/messages/moves" => "chat_channels_messages_moves#create" - post "/channels/:channel_id/archives" => "chat_channels_archives#create" - get "/channels/:channel_id/memberships" => "chat_channels_memberships#index" - delete "/channels/:channel_id/memberships/me" => - "chat_channels_current_user_membership#destroy" - post "/channels/:channel_id/memberships/me" => "chat_channels_current_user_membership#create" - put "/channels/:channel_id/notifications-settings/me" => - "chat_channels_current_user_notifications_settings#update" - - # Category chatables controller hints. Only used by staff members, we don't want to leak category permissions. - get "/category-chatables/:id/permissions" => "category_chatables#permissions", - :format => :json, - :constraints => StaffConstraint.new - - # Hints for JIT warnings. - get "/mentions/groups" => "hints#check_group_mentions", :format => :json - - get "/channels/:channel_id/threads/:thread_id" => "chat_channel_threads#show" - end - - # direct_messages_controller routes - get "/direct_messages" => "direct_messages#index" - post "/direct_messages/create" => "direct_messages#create" - - # incoming_webhooks_controller routes - post "/hooks/:key" => "incoming_chat_webhooks#create_message" - - # incoming_webhooks_controller routes - post "/hooks/:key/slack" => "incoming_chat_webhooks#create_message_slack_compatible" - - # chat_controller routes - get "/" => "chat#respond" - get "/browse" => "chat#respond" - get "/browse/all" => "chat#respond" - get "/browse/closed" => "chat#respond" - get "/browse/open" => "chat#respond" - get "/browse/archived" => "chat#respond" - get "/draft-channel" => "chat#respond" - post "/enable" => "chat#enable_chat" - post "/disable" => "chat#disable_chat" - post "/dismiss-retention-reminder" => "chat#dismiss_retention_reminder" - get "/:chat_channel_id/messages" => "chat#messages" - get "/message/:message_id" => "chat#message_link" - put ":chat_channel_id/edit/:message_id" => "chat#edit_message" - put ":chat_channel_id/react/:message_id" => "chat#react" - delete "/:chat_channel_id/:message_id" => "chat#delete" - put "/:chat_channel_id/:message_id/rebake" => "chat#rebake" - post "/:chat_channel_id/:message_id/flag" => "chat#flag" - post "/:chat_channel_id/quote" => "chat#quote_messages" - put "/:chat_channel_id/restore/:message_id" => "chat#restore" - get "/lookup/:message_id" => "chat#lookup_message" - put "/:chat_channel_id/read/:message_id" => "chat#update_user_last_read" - put "/user_chat_enabled/:user_id" => "chat#set_user_chat_status" - put "/:chat_channel_id/invite" => "chat#invite_users" - post "/drafts" => "chat#set_draft" - post "/:chat_channel_id" => "chat#create_message" - put "/flag" => "chat#flag" - get "/emojis" => "emojis#index" - - base_c_route = "/c/:channel_title/:channel_id" - get base_c_route => "chat#respond", :as => "channel" - get "#{base_c_route}/:message_id" => "chat#respond" - - %w[info info/about info/members info/settings].each do |route| - get "#{base_c_route}/#{route}" => "chat#respond" - end - - # /channel -> /c redirects - get "/channel/:channel_id", to: redirect("/chat/c/-/%{channel_id}") - - get "#{base_c_route}/t/:thread_id" => "chat#respond" - - base_channel_route = "/channel/:channel_id/:channel_title" - redirect_base = "/chat/c/%{channel_title}/%{channel_id}" - - get base_channel_route, to: redirect(redirect_base) - - %w[info info/about info/members info/settings].each do |route| - get "#{base_channel_route}/#{route}", to: redirect("#{redirect_base}/#{route}") + Chat::ChannelMembershipManager.new(category_channel).enforce_automatic_channel_memberships end end Discourse::Application.routes.append do mount ::Chat::Engine, at: "/chat" - get "/admin/plugins/chat" => "chat/admin_incoming_chat_webhooks#index", + + get "/admin/plugins/chat" => "chat/admin/incoming_webhooks#index", :constraints => StaffConstraint.new - post "/admin/plugins/chat/hooks" => "chat/admin_incoming_chat_webhooks#create", + post "/admin/plugins/chat/hooks" => "chat/admin/incoming_webhooks#create", :constraints => StaffConstraint.new put "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" => - "chat/admin_incoming_chat_webhooks#update", + "chat/admin/incoming_webhooks#update", :constraints => StaffConstraint.new delete "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" => - "chat/admin_incoming_chat_webhooks#destroy", + "chat/admin/incoming_webhooks#destroy", :constraints => StaffConstraint.new get "u/:username/preferences/chat" => "users#preferences", :constraints => { @@ -719,12 +412,12 @@ after_initialize do script do |context, fields, automation| sender = User.find_by(username: fields.dig("sender", "value")) || Discourse.system_user - channel = ChatChannel.find_by(id: fields.dig("chat_channel_id", "value")) + channel = Chat::Channel.find_by(id: fields.dig("chat_channel_id", "value")) placeholders = { channel_name: channel.title(sender) }.merge(context["placeholders"] || {}) creator = - Chat::ChatMessageCreator.create( + Chat::MessageCreator.create( chat_channel: channel, user: sender, content: utils.apply_placeholders(fields.dig("message", "value"), placeholders), @@ -748,11 +441,7 @@ after_initialize do fragment.css(".chat-summary-content").each { |element| element[:dm] = "body" } end - # TODO(roman): Remove `respond_to?` after 2.9 release - if respond_to?(:register_email_unsubscriber) - load File.expand_path("../lib/email_controller_helper/chat_summary_unsubscriber.rb", __FILE__) - register_email_unsubscriber("chat_summary", EmailControllerHelper::ChatSummaryUnsubscriber) - end + register_email_unsubscriber("chat_summary", EmailControllerHelper::ChatSummaryUnsubscriber) register_about_stat_group("chat_messages", show_in_ui: true) { Chat::Statistics.about_messages } @@ -761,23 +450,23 @@ after_initialize do register_about_stat_group("chat_users") { Chat::Statistics.about_users } # Make sure to update spec/system/hashtag_autocomplete_spec.rb when changing this. - register_hashtag_data_source(Chat::ChatChannelHashtagDataSource) + register_hashtag_data_source(Chat::ChannelHashtagDataSource) register_hashtag_type_priority_for_context("channel", "chat-composer", 200) register_hashtag_type_priority_for_context("category", "chat-composer", 100) register_hashtag_type_priority_for_context("tag", "chat-composer", 50) register_hashtag_type_priority_for_context("channel", "topic-composer", 10) Site.markdown_additional_options["chat"] = { - limited_pretty_text_features: ChatMessage::MARKDOWN_FEATURES, - limited_pretty_text_markdown_rules: ChatMessage::MARKDOWN_IT_RULES, + limited_pretty_text_features: Chat::Message::MARKDOWN_FEATURES, + limited_pretty_text_markdown_rules: Chat::Message::MARKDOWN_IT_RULES, hashtag_configurations: HashtagAutocompleteService.contexts_with_ordered_types, } register_user_destroyer_on_content_deletion_callback( - Proc.new { |user| Jobs.enqueue(:delete_user_messages, user_id: user.id) }, + Proc.new { |user| Jobs.enqueue(Jobs::Chat::DeleteUserMessages, user_id: user.id) }, ) - register_bookmarkable(ChatMessageBookmarkable) + register_bookmarkable(Chat::MessageBookmarkable) end if Rails.env == "test" diff --git a/plugins/chat/spec/components/chat_mailer_spec.rb b/plugins/chat/spec/components/chat/mailer_spec.rb similarity index 99% rename from plugins/chat/spec/components/chat_mailer_spec.rb rename to plugins/chat/spec/components/chat/mailer_spec.rb index 10a0669f3a1..cfc06500e2d 100644 --- a/plugins/chat/spec/components/chat_mailer_spec.rb +++ b/plugins/chat/spec/components/chat/mailer_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatMailer do +describe Chat::Mailer do fab!(:chatters_group) { Fabricate(:group) } fab!(:sender) { Fabricate(:user, group_ids: [chatters_group.id]) } fab!(:user_1) { Fabricate(:user, group_ids: [chatters_group.id], last_seen_at: 15.minutes.ago) } diff --git a/plugins/chat/spec/components/chat_message_creator_spec.rb b/plugins/chat/spec/components/chat/message_creator_spec.rb similarity index 86% rename from plugins/chat/spec/components/chat_message_creator_spec.rb rename to plugins/chat/spec/components/chat/message_creator_spec.rb index 2c9e01de7e4..703bdc9a493 100644 --- a/plugins/chat/spec/components/chat_message_creator_spec.rb +++ b/plugins/chat/spec/components/chat/message_creator_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatMessageCreator do +describe Chat::MessageCreator do fab!(:admin1) { Fabricate(:admin) } fab!(:admin2) { Fabricate(:admin) } fab!(:user1) { Fabricate(:user, group_ids: [Group::AUTO_GROUPS[:everyone]]) } @@ -55,11 +55,7 @@ describe Chat::ChatMessageCreator do it "errors when length is less than `chat_minimum_message_length`" do SiteSetting.chat_minimum_message_length = 10 creator = - Chat::ChatMessageCreator.create( - chat_channel: public_chat_channel, - user: user1, - content: "2 short", - ) + described_class.create(chat_channel: public_chat_channel, user: user1, content: "2 short") expect(creator.failed?).to eq(true) expect(creator.error.message).to match( I18n.t( @@ -72,7 +68,7 @@ describe Chat::ChatMessageCreator do it "errors when length is greater than `chat_maximum_message_length`" do SiteSetting.chat_maximum_message_length = 100 creator = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "a really long and in depth message that is just too detailed" * 100, @@ -87,29 +83,29 @@ describe Chat::ChatMessageCreator do upload = Fabricate(:upload, user: user1) SiteSetting.chat_minimum_message_length = 10 expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "2 short", upload_ids: [upload.id], ) - }.to change { ChatMessage.count }.by(1) + }.to change { Chat::Message.count }.by(1) end it "creates messages for users who can see the channel" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", ) - }.to change { ChatMessage.count }.by(1) + }.to change { Chat::Message.count }.by(1) end it "updates the channel’s last message date" do previous_last_message_sent_at = public_chat_channel.last_message_sent_at - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -120,7 +116,7 @@ describe Chat::ChatMessageCreator do it "sets the last_editor_id to the user who created the message" do message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -131,7 +127,7 @@ describe Chat::ChatMessageCreator do it "publishes a DiscourseEvent for new messages" do events = DiscourseEvent.track_events do - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -142,7 +138,7 @@ describe Chat::ChatMessageCreator do it "creates mentions and mention notifications for public chat" do message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: @@ -167,7 +163,7 @@ describe Chat::ChatMessageCreator do it "mentions are case insensitive" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "Hey @#{user2.username.upcase}", @@ -177,23 +173,20 @@ describe Chat::ChatMessageCreator do it "notifies @all properly" do expect { - Chat::ChatMessageCreator.create( - chat_channel: public_chat_channel, - user: user1, - content: "@all", - ) - }.to change { ChatMention.count }.by(4) + described_class.create(chat_channel: public_chat_channel, user: user1, content: "@all") + }.to change { Chat::Mention.count }.by(4) - UserChatChannelMembership.where(user: user2, chat_channel: public_chat_channel).update_all( - following: false, - ) + Chat::UserChatChannelMembership.where( + user: user2, + chat_channel: public_chat_channel, + ).update_all(following: false) expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "again! @all", ) - }.to change { ChatMention.count }.by(3) + }.to change { Chat::Mention.count }.by(3) end it "notifies @here properly" do @@ -203,18 +196,14 @@ describe Chat::ChatMessageCreator do user2.update(last_seen_at: Time.now) user3.update(last_seen_at: Time.now) expect { - Chat::ChatMessageCreator.create( - chat_channel: public_chat_channel, - user: user1, - content: "@here", - ) - }.to change { ChatMention.count }.by(2) + described_class.create(chat_channel: public_chat_channel, user: user1, content: "@here") + }.to change { Chat::Mention.count }.by(2) end it "doesn't sent double notifications when '@here' is mentioned" do user2.update(last_seen_at: Time.now) expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "@here @#{user2.username}", @@ -229,7 +218,7 @@ describe Chat::ChatMessageCreator do user2.update(last_seen_at: 1.year.ago) user3.update(last_seen_at: 1.year.ago) expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "@here plus @#{user3.username}", @@ -239,7 +228,7 @@ describe Chat::ChatMessageCreator do it "doesn't create mention notifications for users without a membership record" do message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hello @#{user_without_memberships.username}", @@ -254,7 +243,7 @@ describe Chat::ChatMessageCreator do SiteSetting.chat_allowed_groups = new_group.id message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hi @#{user2.username} @#{user3.username}", @@ -271,7 +260,7 @@ describe Chat::ChatMessageCreator do user2.user_option.update(chat_enabled: false) message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hi @#{user2.username}", @@ -283,7 +272,7 @@ describe Chat::ChatMessageCreator do it "creates only mention notifications for users with access in private chat" do message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: direct_message_channel, user: user1, content: "hello there @#{user2.username} and @#{user3.username}", @@ -299,7 +288,7 @@ describe Chat::ChatMessageCreator do it "creates a mention for group users even if they're not participating in private chat" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: direct_message_channel, user: user1, content: "hello there @#{user_group.name}", @@ -309,7 +298,7 @@ describe Chat::ChatMessageCreator do it "creates a mention notifications only for group users that are participating in private chat" do message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: direct_message_channel, user: user1, content: "hello there @#{user_group.name}", @@ -323,8 +312,8 @@ describe Chat::ChatMessageCreator do end it "publishes inaccessible mentions when user isn't aren't a part of the channel" do - ChatPublisher.expects(:publish_inaccessible_mentions).once - Chat::ChatMessageCreator.create( + Chat::Publisher.expects(:publish_inaccessible_mentions).once + described_class.create( chat_channel: public_chat_channel, user: admin1, content: "hello @#{user4.username}", @@ -333,8 +322,8 @@ describe Chat::ChatMessageCreator do it "publishes inaccessible mentions when user doesn't have chat access" do SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] - ChatPublisher.expects(:publish_inaccessible_mentions).once - Chat::ChatMessageCreator.create( + Chat::Publisher.expects(:publish_inaccessible_mentions).once + described_class.create( chat_channel: public_chat_channel, user: admin1, content: "hello @#{user3.username}", @@ -342,8 +331,8 @@ describe Chat::ChatMessageCreator do end it "doesn't publish inaccessible mentions when user is following channel" do - ChatPublisher.expects(:publish_inaccessible_mentions).never - Chat::ChatMessageCreator.create( + Chat::Publisher.expects(:publish_inaccessible_mentions).never + described_class.create( chat_channel: public_chat_channel, user: admin1, content: "hello @#{admin2.username}", @@ -354,7 +343,7 @@ describe Chat::ChatMessageCreator do user2.update(suspended_till: Time.now + 10.years) message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: direct_message_channel, user: user1, content: "hello @#{user2.username}", @@ -368,7 +357,7 @@ describe Chat::ChatMessageCreator do user2.update(suspended_till: Time.now + 10.years) message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: direct_message_channel, user: user1, content: "hello @#{user2.username}", @@ -383,7 +372,7 @@ describe Chat::ChatMessageCreator do it "when mentioning @all creates a mention without notification" do message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hi! @all", @@ -398,7 +387,7 @@ describe Chat::ChatMessageCreator do user2.update(last_seen_at: Time.now) message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "@here", @@ -419,7 +408,7 @@ describe Chat::ChatMessageCreator do it "links the message that the user is replying to" do message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -433,13 +422,13 @@ describe Chat::ChatMessageCreator do message = nil expect { message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", in_reply_to_id: reply_message.id, ).chat_message - }.to change { ChatThread.count }.by(1) + }.to change { Chat::Thread.count }.by(1) expect(message.reload.thread).not_to eq(nil) expect(message.in_reply_to.thread).to eq(message.thread) @@ -454,13 +443,13 @@ describe Chat::ChatMessageCreator do message = nil expect { message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", thread_id: existing_thread.id, ).chat_message - }.not_to change { ChatThread.count } + }.not_to change { Chat::Thread.count } expect(message.reload.thread).to eq(existing_thread) end @@ -468,7 +457,7 @@ describe Chat::ChatMessageCreator do it "errors when the thread ID is for a different channel" do other_channel_thread = Fabricate(:chat_thread, channel: Fabricate(:chat_channel)) result = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -480,7 +469,7 @@ describe Chat::ChatMessageCreator do it "errors when the thread does not match the in_reply_to thread" do reply_message.update!(thread: existing_thread) result = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -493,7 +482,7 @@ describe Chat::ChatMessageCreator do it "errors when the root message does not have a thread ID" do reply_message.update!(thread: nil) result = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -519,7 +508,7 @@ describe Chat::ChatMessageCreator do it "raises an error when the root message has been trashed" do original_message.trash! result = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -530,7 +519,7 @@ describe Chat::ChatMessageCreator do it "uses the next message in the chain as the root when the root is deleted" do original_message.destroy! - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -576,16 +565,16 @@ describe Chat::ChatMessageCreator do end it "creates a thread and updates all the messages in the chain" do - thread_count = ChatThread.count + thread_count = Chat::Thread.count message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", in_reply_to_id: reply_message.id, ).chat_message - expect(ChatThread.count).to eq(thread_count + 1) + expect(Chat::Thread.count).to eq(thread_count + 1) expect(message.reload.thread).not_to eq(nil) expect(message.reload.in_reply_to.thread).to eq(message.thread) expect(old_message_1.reload.thread).to eq(message.thread) @@ -593,7 +582,7 @@ describe Chat::ChatMessageCreator do expect(old_message_3.reload.thread).to eq(message.thread) expect(message.thread.chat_messages.count).to eq(5) message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -603,7 +592,7 @@ describe Chat::ChatMessageCreator do context "when a thread already exists and the thread_id is passed in" do let!(:last_message) do - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -613,10 +602,10 @@ describe Chat::ChatMessageCreator do let!(:existing_thread) { last_message.reload.thread } it "does not create a new thread" do - thread_count = ChatThread.count + thread_count = Chat::Thread.count message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message again", @@ -624,7 +613,7 @@ describe Chat::ChatMessageCreator do thread_id: existing_thread.id, ).chat_message - expect(ChatThread.count).to eq(thread_count) + expect(Chat::Thread.count).to eq(thread_count) expect(message.reload.thread).to eq(existing_thread) expect(message.reload.in_reply_to.thread).to eq(existing_thread) expect(message.thread.chat_messages.count).to eq(6) @@ -633,7 +622,7 @@ describe Chat::ChatMessageCreator do it "errors when the thread does not match the root thread" do old_message_1.update!(thread: Fabricate(:chat_thread, channel: public_chat_channel)) result = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -646,7 +635,7 @@ describe Chat::ChatMessageCreator do it "errors when the root message does not have a thread ID" do old_message_1.update!(thread: nil) result = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -674,12 +663,12 @@ describe Chat::ChatMessageCreator do end xit "works" do - thread_count = ChatThread.count + thread_count = Chat::Thread.count message = nil puts Benchmark.measure { message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -687,7 +676,7 @@ describe Chat::ChatMessageCreator do ).chat_message } - expect(ChatThread.count).to eq(thread_count + 1) + expect(Chat::Thread.count).to eq(thread_count + 1) expect(message.reload.thread).not_to eq(nil) expect(message.reload.in_reply_to.thread).to eq(message.thread) expect(message.thread.chat_messages.count).to eq(1001) @@ -704,16 +693,16 @@ describe Chat::ChatMessageCreator do end it "does not change any messages in the chain, assumes they have the correct thread ID" do - thread_count = ChatThread.count + thread_count = Chat::Thread.count message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", in_reply_to_id: reply_message.id, ).chat_message - expect(ChatThread.count).to eq(thread_count) + expect(Chat::Thread.count).to eq(thread_count) expect(message.reload.thread).to eq(old_thread) expect(message.reload.in_reply_to.thread).to eq(old_thread) expect(old_message_1.reload.thread).to eq(old_thread) @@ -728,7 +717,7 @@ describe Chat::ChatMessageCreator do describe "group mentions" do it "creates chat mentions for group mentions where the group is mentionable" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hello @#{admin_group.name}", @@ -740,7 +729,7 @@ describe Chat::ChatMessageCreator do it "doesn't mention users twice if they are direct mentioned and group mentioned" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hello @#{admin_group.name} @#{admin1.username} and @#{admin2.username}", @@ -752,7 +741,7 @@ describe Chat::ChatMessageCreator do it "creates chat mentions for group mentions and direct mentions" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hello @#{admin_group.name} @#{user2.username}", @@ -764,7 +753,7 @@ describe Chat::ChatMessageCreator do it "creates chat mentions for group mentions and direct mentions" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hello @#{admin_group.name} @#{user_group.name}", @@ -779,40 +768,35 @@ describe Chat::ChatMessageCreator do it "doesn't create chat mentions for group mentions where the group is un-mentionable" do admin_group.update(mentionable_level: Group::ALIAS_LEVELS[:nobody]) expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hello @#{admin_group.name}", ) - }.not_to change { ChatMention.count } + }.not_to change { Chat::Mention.count } end end describe "push notifications" do before do - UserChatChannelMembership.where(user: user1, chat_channel: public_chat_channel).update( - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + Chat::UserChatChannelMembership.where( + user: user1, + chat_channel: public_chat_channel, + ).update( + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) PresenceChannel.clear_all! end it "sends a push notification to watching users who are not in chat" do PostAlerter.expects(:push_notification).once - Chat::ChatMessageCreator.create( - chat_channel: public_chat_channel, - user: user2, - content: "Beep boop", - ) + described_class.create(chat_channel: public_chat_channel, user: user2, content: "Beep boop") end it "does not send a push notification to watching users who are in chat" do PresenceChannel.new("/chat/online").present(user_id: user1.id, client_id: 1) PostAlerter.expects(:push_notification).never - Chat::ChatMessageCreator.create( - chat_channel: public_chat_channel, - user: user2, - content: "Beep boop", - ) + described_class.create(chat_channel: public_chat_channel, user: user2, content: "Beep boop") end end @@ -823,7 +807,7 @@ describe Chat::ChatMessageCreator do it "can attach 1 upload to a new message" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "Beep boop", @@ -836,7 +820,7 @@ describe Chat::ChatMessageCreator do it "can attach multiple uploads to a new message" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "Beep boop", @@ -849,7 +833,7 @@ describe Chat::ChatMessageCreator do it "filters out uploads that weren't uploaded by the user" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "Beep boop", @@ -861,7 +845,7 @@ describe Chat::ChatMessageCreator do it "doesn't attach uploads when `chat_allow_uploads` is false" do SiteSetting.chat_allow_uploads = false expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "Beep boop", @@ -875,15 +859,15 @@ describe Chat::ChatMessageCreator do end it "destroys draft after message was created" do - ChatDraft.create!(user: user1, chat_channel: public_chat_channel, data: "{}") + Chat::Draft.create!(user: user1, chat_channel: public_chat_channel, data: "{}") expect do - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "Hi @#{user2.username}", ) - end.to change { ChatDraft.count }.by(-1) + end.to change { Chat::Draft.count }.by(-1) end describe "watched words" do @@ -891,7 +875,7 @@ describe Chat::ChatMessageCreator do it "errors when a blocked word is present" do creator = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "bad word - #{watched_word.word}", @@ -905,11 +889,7 @@ describe Chat::ChatMessageCreator do describe "channel statuses" do def create_message(user) - Chat::ChatMessageCreator.create( - chat_channel: public_chat_channel, - user: user, - content: "test message", - ) + described_class.create(chat_channel: public_chat_channel, user: user, content: "test message") end context "when channel is closed" do @@ -924,7 +904,7 @@ describe Chat::ChatMessageCreator do end it "does not error when trying to create a message for staff" do - expect { create_message(admin1) }.to change { ChatMessage.count }.by(1) + expect { create_message(admin1) }.to change { Chat::Message.count }.by(1) end end diff --git a/plugins/chat/spec/components/chat_message_rate_limiter_spec.rb b/plugins/chat/spec/components/chat/message_rate_limiter_spec.rb similarity index 98% rename from plugins/chat/spec/components/chat_message_rate_limiter_spec.rb rename to plugins/chat/spec/components/chat/message_rate_limiter_spec.rb index fa73e927819..50d394ffa33 100644 --- a/plugins/chat/spec/components/chat_message_rate_limiter_spec.rb +++ b/plugins/chat/spec/components/chat/message_rate_limiter_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatMessageRateLimiter do +describe Chat::MessageRateLimiter do fab!(:user) { Fabricate(:user, trust_level: 3) } let(:limiter) { described_class.new(user) } diff --git a/plugins/chat/spec/components/chat_message_updater_spec.rb b/plugins/chat/spec/components/chat/message_updater_spec.rb similarity index 93% rename from plugins/chat/spec/components/chat_message_updater_spec.rb rename to plugins/chat/spec/components/chat/message_updater_spec.rb index edb101a3afa..356b24076d8 100644 --- a/plugins/chat/spec/components/chat_message_updater_spec.rb +++ b/plugins/chat/spec/components/chat/message_updater_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatMessageUpdater do +describe Chat::MessageUpdater do let(:guardian) { Guardian.new(user1) } fab!(:admin1) { Fabricate(:admin) } fab!(:admin2) { Fabricate(:admin) } @@ -36,7 +36,7 @@ describe Chat::ChatMessageUpdater do def create_chat_message(user, message, channel, upload_ids: nil) creator = - Chat::ChatMessageCreator.create( + Chat::MessageCreator.create( chat_channel: channel, user: user, in_reply_to_id: nil, @@ -53,7 +53,7 @@ describe Chat::ChatMessageUpdater do new_message = "2 short" updater = - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: new_message, @@ -75,7 +75,7 @@ describe Chat::ChatMessageUpdater do new_message = "2 long" * 100 updater = - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: new_message, @@ -92,7 +92,7 @@ describe Chat::ChatMessageUpdater do chat_message = create_chat_message(user1, og_message, public_chat_channel) new_message = "2 short" updater = - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: Guardian.new(Fabricate(:user)), chat_message: chat_message, new_content: new_message, @@ -105,7 +105,7 @@ describe Chat::ChatMessageUpdater do chat_message = create_chat_message(user1, "This will be changed", public_chat_channel) new_message = "Change to this!" - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: new_message, @@ -117,7 +117,7 @@ describe Chat::ChatMessageUpdater do chat_message = create_chat_message(user1, "This will be changed", public_chat_channel) events = DiscourseEvent.track_events do - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "Change to this!", @@ -129,7 +129,7 @@ describe Chat::ChatMessageUpdater do it "creates mention notifications for unmentioned users" do chat_message = create_chat_message(user1, "This will be changed", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: @@ -142,19 +142,19 @@ describe Chat::ChatMessageUpdater do message = "ping @#{user2.username} @#{user3.username}" chat_message = create_chat_message(user1, message, public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: message + " editedddd", ) - }.not_to change { ChatMention.count } + }.not_to change { Chat::Mention.count } end it "doesn't create mention notification for users without access" do message = "ping" chat_message = create_chat_message(user1, message, public_chat_channel) - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: message + " @#{user_without_memberships.username}", @@ -168,7 +168,7 @@ describe Chat::ChatMessageUpdater do chat_message = create_chat_message(user1, "ping @#{user2.username} @#{user3.username}", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "ping @#{user3.username}", @@ -179,7 +179,7 @@ describe Chat::ChatMessageUpdater do it "creates new, leaves existing, and removes old mentions all at once" do chat_message = create_chat_message(user1, "ping @#{user2.username} @#{user3.username}", public_chat_channel) - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "ping @#{user3.username} @#{user4.username}", @@ -193,7 +193,7 @@ describe Chat::ChatMessageUpdater do it "doesn't create mention notification in direct message for users without access" do message = create_chat_message(user1, "ping nobody", @direct_message_channel) - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: message, new_content: "ping @#{admin1.username}", @@ -207,12 +207,12 @@ describe Chat::ChatMessageUpdater do it "creates group mentions on update" do chat_message = create_chat_message(user1, "ping nobody", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "ping @#{admin_group.name}", ) - }.to change { ChatMention.where(chat_message: chat_message).count }.by(2) + }.to change { Chat::Mention.where(chat_message: chat_message).count }.by(2) expect(admin1.chat_mentions.where(chat_message: chat_message)).to be_present expect(admin2.chat_mentions.where(chat_message: chat_message)).to be_present @@ -221,7 +221,7 @@ describe Chat::ChatMessageUpdater do it "doesn't duplicate mentions when the user is already direct mentioned and then group mentioned" do chat_message = create_chat_message(user1, "ping @#{admin2.username}", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "ping @#{admin_group.name} @#{admin2.username}", @@ -232,12 +232,12 @@ describe Chat::ChatMessageUpdater do it "deletes old mentions when group mention is removed" do chat_message = create_chat_message(user1, "ping @#{admin_group.name}", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "ping nobody anymore!", ) - }.to change { ChatMention.where(chat_message: chat_message).count }.by(-2) + }.to change { Chat::Mention.where(chat_message: chat_message).count }.by(-2) expect(admin1.chat_mentions.where(chat_message: chat_message)).not_to be_present expect(admin2.chat_mentions.where(chat_message: chat_message)).not_to be_present @@ -248,7 +248,7 @@ describe Chat::ChatMessageUpdater do old_message = "It's a thrsday!" new_message = "It's a thursday!" chat_message = create_chat_message(user1, old_message, public_chat_channel) - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: new_message, @@ -282,7 +282,7 @@ describe Chat::ChatMessageUpdater do chat_message_2.update!(created_at: 20.seconds.ago) updater = - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message_1, new_content: "another different chat message here", @@ -302,7 +302,7 @@ describe Chat::ChatMessageUpdater do chat_message.update!(created_at: 30.seconds.ago) updater = - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "this is some chat message", @@ -326,7 +326,7 @@ describe Chat::ChatMessageUpdater do upload_ids: [upload1.id, upload2.id], ) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -354,7 +354,7 @@ describe Chat::ChatMessageUpdater do VALUES(#{upload2.id}, #{chat_message.id}, NOW(), NOW()) SQL expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -384,7 +384,7 @@ describe Chat::ChatMessageUpdater do VALUES(#{upload2.id}, #{chat_message.id}, NOW(), NOW()) SQL expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -398,7 +398,7 @@ describe Chat::ChatMessageUpdater do it "adds one upload if none exist" do chat_message = create_chat_message(user1, "something", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -412,7 +412,7 @@ describe Chat::ChatMessageUpdater do it "adds multiple uploads if none exist" do chat_message = create_chat_message(user1, "something", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -427,7 +427,7 @@ describe Chat::ChatMessageUpdater do chat_message = create_chat_message(user1, "something", public_chat_channel, upload_ids: [upload1.id]) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -442,7 +442,7 @@ describe Chat::ChatMessageUpdater do SiteSetting.chat_allow_uploads = false chat_message = create_chat_message(user1, "something", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -463,7 +463,7 @@ describe Chat::ChatMessageUpdater do upload_ids: [upload1.id, upload2.id], ) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -484,7 +484,7 @@ describe Chat::ChatMessageUpdater do ) SiteSetting.chat_minimum_message_length = 10 new_message = "hi :)" - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: new_message, @@ -500,7 +500,7 @@ describe Chat::ChatMessageUpdater do it "errors when a blocked word is present" do chat_message = create_chat_message(user1, "something", public_chat_channel) creator = - Chat::ChatMessageCreator.create( + Chat::MessageCreator.create( chat_channel: public_chat_channel, user: user1, content: "bad word - #{watched_word.word}", @@ -517,7 +517,7 @@ describe Chat::ChatMessageUpdater do def update_message(user) message.update(user: user) - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: Guardian.new(user), chat_message: message, new_content: "I guess this is different", diff --git a/plugins/chat/spec/components/chat_seeder_spec.rb b/plugins/chat/spec/components/chat/seeder_spec.rb similarity index 76% rename from plugins/chat/spec/components/chat_seeder_spec.rb rename to plugins/chat/spec/components/chat/seeder_spec.rb index e0a7c5222a6..f0ee8d1faac 100644 --- a/plugins/chat/spec/components/chat_seeder_spec.rb +++ b/plugins/chat/spec/components/chat/seeder_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe ChatSeeder do +describe Chat::Seeder do fab!(:staff_category) { Fabricate(:private_category, name: "Staff", group: Group[:staff]) } fab!(:general_category) { Fabricate(:category, name: "General") } @@ -27,16 +27,16 @@ describe ChatSeeder do expected_members_count = GroupUser.where(group: group).count memberships_count = - UserChatChannelMembership.automatic.where(chat_channel: channel, following: true).count + Chat::UserChatChannelMembership.automatic.where(chat_channel: channel, following: true).count expect(memberships_count).to eq(expected_members_count) end it "seeds default channels" do - ChatSeeder.new.execute + Chat::Seeder.new.execute - staff_channel = ChatChannel.find_by(chatable: staff_category) - general_channel = ChatChannel.find_by(chatable: general_category) + staff_channel = Chat::Channel.find_by(chatable_id: staff_category) + general_channel = Chat::Channel.find_by(chatable_id: general_category) assert_channel_was_correctly_seeded(staff_channel, Group[:staff]) assert_channel_was_correctly_seeded(general_channel, Group[:everyone]) @@ -49,24 +49,24 @@ describe ChatSeeder do it "applies a name to the general category channel" do expected_name = general_category.name - ChatSeeder.new.execute + Chat::Seeder.new.execute - general_channel = ChatChannel.find_by(chatable: general_category) + general_channel = Chat::Channel.find_by(chatable_id: general_category) expect(general_channel.name).to eq(expected_name) end it "applies a name to the staff category channel" do expected_name = staff_category.name - ChatSeeder.new.execute + Chat::Seeder.new.execute - staff_channel = ChatChannel.find_by(chatable: staff_category) + staff_channel = Chat::Channel.find_by(chatable_id: staff_category) expect(staff_channel.name).to eq(expected_name) end it "does nothing when 'SiteSetting.needs_chat_seeded' is false" do SiteSetting.needs_chat_seeded = false - expect { ChatSeeder.new.execute }.not_to change { ChatChannel.count } + expect { Chat::Seeder.new.execute }.not_to change { Chat::Channel.count } end end diff --git a/plugins/chat/spec/fabricators/chat_fabricator.rb b/plugins/chat/spec/fabricators/chat_fabricator.rb index c0b4480325a..d642326abfa 100644 --- a/plugins/chat/spec/fabricators/chat_fabricator.rb +++ b/plugins/chat/spec/fabricators/chat_fabricator.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -Fabricator(:chat_channel) do +Fabricator(:chat_channel, class_name: "Chat::Channel") do name do sequence(:name) do |n| random_name = [ @@ -25,14 +25,14 @@ Fabricator(:chat_channel) do status { :open } end -Fabricator(:category_channel, from: :chat_channel, class_name: :category_channel) {} +Fabricator(:category_channel, from: :chat_channel) {} -Fabricator(:private_category_channel, from: :category_channel, class_name: :category_channel) do +Fabricator(:private_category_channel, from: :category_channel) do transient :group chatable { |attrs| Fabricate(:private_category, group: attrs[:group] || Group[:staff]) } end -Fabricator(:direct_message_channel, from: :chat_channel, class_name: :direct_message_channel) do +Fabricator(:direct_message_channel, from: :chat_channel) do transient :users, following: true, with_membership: true chatable do |attrs| Fabricate(:direct_message, users: attrs[:users] || [Fabricate(:user), Fabricate(:user)]) @@ -49,16 +49,16 @@ Fabricator(:direct_message_channel, from: :chat_channel, class_name: :direct_mes end end -Fabricator(:chat_message) do +Fabricator(:chat_message, class_name: "Chat::Message") do chat_channel user message "Beep boop" - cooked { |attrs| ChatMessage.cook(attrs[:message]) } - cooked_version ChatMessage::BAKED_VERSION + cooked { |attrs| Chat::Message.cook(attrs[:message]) } + cooked_version Chat::Message::BAKED_VERSION in_reply_to nil end -Fabricator(:chat_mention) do +Fabricator(:chat_mention, class_name: "Chat::Mention") do transient read: false transient high_priority: true transient identifier: :direct_mentions @@ -67,7 +67,7 @@ Fabricator(:chat_mention) do chat_message { Fabricate(:chat_message) } end -Fabricator(:chat_message_reaction) do +Fabricator(:chat_message_reaction, class_name: "Chat::MessageReaction") do chat_message { Fabricate(:chat_message) } user { Fabricate(:user) } emoji { %w[+1 tada heart joffrey_facepalm].sample } @@ -76,7 +76,7 @@ Fabricator(:chat_message_reaction) do end end -Fabricator(:chat_upload) do +Fabricator(:chat_upload, class_name: "Chat::Upload") do transient :user user { Fabricate(:user) } @@ -85,38 +85,40 @@ Fabricator(:chat_upload) do upload { |attrs| Fabricate(:upload, user: attrs[:user]) } end -Fabricator(:chat_message_revision) do +Fabricator(:chat_message_revision, class_name: "Chat::MessageRevision") do chat_message { Fabricate(:chat_message) } old_message { "something old" } new_message { "something new" } user { |attrs| attrs[:chat_message].user } end -Fabricator(:reviewable_chat_message) do +Fabricator(:chat_reviewable_message, class_name: "Chat::ReviewableMessage") do reviewable_by_moderator true type "ReviewableChatMessage" created_by { Fabricate(:user) } - target_type "ChatMessage" + target_type Chat::Message.sti_name target { Fabricate(:chat_message) } reviewable_scores { |p| [Fabricate.build(:reviewable_score, reviewable_id: p[:id])] } end -Fabricator(:direct_message) { users { [Fabricate(:user), Fabricate(:user)] } } +Fabricator(:direct_message, class_name: "Chat::DirectMessage") do + users { [Fabricate(:user), Fabricate(:user)] } +end -Fabricator(:chat_webhook_event) do +Fabricator(:chat_webhook_event, class_name: "Chat::WebhookEvent") do chat_message { Fabricate(:chat_message) } incoming_chat_webhook do |attrs| Fabricate(:incoming_chat_webhook, chat_channel: attrs[:chat_message].chat_channel) end end -Fabricator(:incoming_chat_webhook) do +Fabricator(:incoming_chat_webhook, class_name: "Chat::IncomingWebhook") do name { sequence(:name) { |i| "#{i + 1}" } } key { sequence(:key) { |i| "#{i + 1}" } } chat_channel { Fabricate(:chat_channel, chatable: Fabricate(:category)) } end -Fabricator(:user_chat_channel_membership) do +Fabricator(:user_chat_channel_membership, class_name: "Chat::UserChatChannelMembership") do user chat_channel following true @@ -130,7 +132,7 @@ Fabricator(:user_chat_channel_membership_for_dm, from: :user_chat_channel_member mobile_notification_level 2 end -Fabricator(:chat_draft) do +Fabricator(:chat_draft, class_name: "Chat::Draft") do user chat_channel @@ -143,7 +145,7 @@ Fabricator(:chat_draft) do end end -Fabricator(:chat_thread) do +Fabricator(:chat_thread, class_name: "Chat::Thread") do before_create do |thread, transients| thread.original_message_user = original_message.user thread.channel = original_message.chat_channel diff --git a/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb b/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb index 6fa39be8848..b981161ebc2 100644 --- a/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb +++ b/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb @@ -44,7 +44,7 @@ describe "API keys scoped to chat#create_message" do end it "can create chat messages" do - UserChatChannelMembership.create(user: admin, chat_channel: chat_channel, following: true) + Chat::UserChatChannelMembership.create(user: admin, chat_channel: chat_channel, following: true) expect { post "/chat/#{chat_channel.id}.json", headers: { @@ -54,12 +54,12 @@ describe "API keys scoped to chat#create_message" do params: { message: "asdfasdf asdfasdf", } - }.to change { ChatMessage.where(chat_channel: chat_channel).count }.by(1) + }.to change { Chat::Message.where(chat_channel: chat_channel).count }.by(1) expect(response.status).to eq(200) end it "cannot post in a channel it is not scoped for" do - UserChatChannelMembership.create(user: admin, chat_channel: chat_channel, following: true) + Chat::UserChatChannelMembership.create(user: admin, chat_channel: chat_channel, following: true) expect { post "/chat/#{chat_channel.id}.json", headers: { @@ -69,12 +69,16 @@ describe "API keys scoped to chat#create_message" do params: { message: "asdfasdf asdfasdf", } - }.not_to change { ChatMessage.where(chat_channel: chat_channel).count } + }.not_to change { Chat::Message.where(chat_channel: chat_channel).count } expect(response.status).to eq(403) end it "can only post in scoped channels" do - UserChatChannelMembership.create(user: admin, chat_channel: chat_channel_2, following: true) + Chat::UserChatChannelMembership.create( + user: admin, + chat_channel: chat_channel_2, + following: true, + ) expect { post "/chat/#{chat_channel_2.id}.json", headers: { @@ -84,7 +88,7 @@ describe "API keys scoped to chat#create_message" do params: { message: "asdfasdf asdfasdf", } - }.to change { ChatMessage.where(chat_channel: chat_channel_2).count }.by(1) + }.to change { Chat::Message.where(chat_channel: chat_channel_2).count }.by(1) expect(response.status).to eq(200) end end diff --git a/plugins/chat/spec/integration/post_chat_quote_spec.rb b/plugins/chat/spec/integration/post_chat_quote_spec.rb index 71457ccde1b..2ffe97df0b5 100644 --- a/plugins/chat/spec/integration/post_chat_quote_spec.rb +++ b/plugins/chat/spec/integration/post_chat_quote_spec.rb @@ -220,14 +220,14 @@ martin message1 = Fabricate(:chat_message, chat_channel: channel, user: post.user) message2 = Fabricate(:chat_message, chat_channel: channel, user: post.user) md = - ChatTranscriptService.new( + Chat::TranscriptService.new( channel, message2.user, messages_or_ids: [message2.id], ).generate_markdown message1.update!(message: md) md_for_post = - ChatTranscriptService.new( + Chat::TranscriptService.new( channel, message1.user, messages_or_ids: [message1.id], diff --git a/plugins/chat/spec/jobs/regular/auto_join_channel_batch_spec.rb b/plugins/chat/spec/jobs/regular/chat/auto_join_channel_batch_spec.rb similarity index 92% rename from plugins/chat/spec/jobs/regular/auto_join_channel_batch_spec.rb rename to plugins/chat/spec/jobs/regular/chat/auto_join_channel_batch_spec.rb index e97776b10fe..08b0ba6ad48 100644 --- a/plugins/chat/spec/jobs/regular/auto_join_channel_batch_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/auto_join_channel_batch_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Jobs::AutoJoinChannelBatch do +describe Jobs::Chat::AutoJoinChannelBatch do describe "#execute" do fab!(:category) { Fabricate(:category) } let!(:user) { Fabricate(:user, last_seen_at: 15.minutes.ago) } @@ -64,7 +64,12 @@ describe Jobs::AutoJoinChannelBatch do it "enqueues the user count update job and marks the channel user count as stale" do subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) - expect_job_enqueued(job: :update_channel_user_count, args: { chat_channel_id: channel.id }) + expect_job_enqueued( + job: Jobs::Chat::UpdateChannelUserCount, + args: { + chat_channel_id: channel.id, + }, + ) expect(channel.reload.user_count_stale).to eq(true) end @@ -72,7 +77,7 @@ describe Jobs::AutoJoinChannelBatch do it "does not enqueue the user count update job or mark the channel user count as stale when there is more than use user" do user_2 = Fabricate(:user) expect_not_enqueued_with( - job: :update_channel_user_count, + job: Jobs::Chat::UpdateChannelUserCount, args: { chat_channel_id: channel.id, }, @@ -92,7 +97,7 @@ describe Jobs::AutoJoinChannelBatch do it "sets the join reason to automatic" do subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) - new_membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + new_membership = Chat::UserChatChannelMembership.find_by(user: user, chat_channel: channel) expect(new_membership.automatic?).to eq(true) end @@ -179,12 +184,12 @@ describe Jobs::AutoJoinChannelBatch do end def assert_users_follows_channel(channel, users) - new_memberships = UserChatChannelMembership.where(user: users, chat_channel: channel) + new_memberships = Chat::UserChatChannelMembership.where(user: users, chat_channel: channel) expect(new_memberships.all?(&:following)).to eq(true) end def assert_user_skipped(channel, user) - new_membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + new_membership = Chat::UserChatChannelMembership.find_by(user: user, chat_channel: channel) expect(new_membership).to be_nil end end diff --git a/plugins/chat/spec/jobs/regular/auto_manage_channel_memberships_spec.rb b/plugins/chat/spec/jobs/regular/chat/auto_manage_channel_memberships_spec.rb similarity index 87% rename from plugins/chat/spec/jobs/regular/auto_manage_channel_memberships_spec.rb rename to plugins/chat/spec/jobs/regular/chat/auto_manage_channel_memberships_spec.rb index 1ea5470c7f9..cedec7d7e80 100644 --- a/plugins/chat/spec/jobs/regular/auto_manage_channel_memberships_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/auto_manage_channel_memberships_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Jobs::AutoManageChannelMemberships do +describe Jobs::Chat::AutoManageChannelMemberships do let(:user) { Fabricate(:user, last_seen_at: 15.minutes.ago) } let(:category) { Fabricate(:category, user: user) } let(:channel) { Fabricate(:category_channel, auto_join_users: true, chatable: category) } @@ -13,7 +13,7 @@ describe Jobs::AutoManageChannelMemberships do end it "does nothing when the channel doesn't exist" do - assert_batches_enqueued(ChatChannel.new(id: -1), 0) + assert_batches_enqueued(Chat::Channel.new(id: -1), 0) end it "does nothing when the chatable is not a category" do @@ -44,24 +44,24 @@ describe Jobs::AutoManageChannelMemberships do it "does nothing when we already reached the max_chat_auto_joined_users limit" do SiteSetting.max_chat_auto_joined_users = 1 user_2 = Fabricate(:user, last_seen_at: 2.minutes.ago) - UserChatChannelMembership.create!( + Chat::UserChatChannelMembership.create!( user: user_2, chat_channel: channel, following: true, - join_mode: UserChatChannelMembership.join_modes[:automatic], + join_mode: Chat::UserChatChannelMembership.join_modes[:automatic], ) assert_batches_enqueued(channel, 0) end it "ignores users that are already channel members" do - UserChatChannelMembership.create!(user: user, chat_channel: channel, following: true) + Chat::UserChatChannelMembership.create!(user: user, chat_channel: channel, following: true) assert_batches_enqueued(channel, 0) end it "doesn't queue a batch when the user doesn't follow the channel" do - UserChatChannelMembership.create!(user: user, chat_channel: channel, following: false) + Chat::UserChatChannelMembership.create!(user: user, chat_channel: channel, following: false) assert_batches_enqueued(channel, 0) end @@ -120,7 +120,7 @@ describe Jobs::AutoManageChannelMemberships do def assert_batches_enqueued(channel, expected) expect { subject.execute(chat_channel_id: channel.id) }.to change( - Jobs::AutoJoinChannelBatch.jobs, + Jobs::Chat::AutoJoinChannelBatch.jobs, :size, ).by(expected) end diff --git a/plugins/chat/spec/jobs/chat_channel_archive_spec.rb b/plugins/chat/spec/jobs/regular/chat/channel_archive_spec.rb similarity index 88% rename from plugins/chat/spec/jobs/chat_channel_archive_spec.rb rename to plugins/chat/spec/jobs/regular/chat/channel_archive_spec.rb index a60c8de55d2..02b43d1749a 100644 --- a/plugins/chat/spec/jobs/chat_channel_archive_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/channel_archive_spec.rb @@ -2,12 +2,12 @@ require "rails_helper" -describe Jobs::ChatChannelArchive do +describe Jobs::Chat::ChannelArchive do fab!(:chat_channel) { Fabricate(:category_channel) } fab!(:user) { Fabricate(:user, admin: true) } fab!(:category) { Fabricate(:category) } fab!(:chat_archive) do - ChatChannelArchive.create!( + Chat::ChannelArchive.create!( chat_channel: chat_channel, archived_by: user, destination_topic_title: "This will be the archive topic", @@ -34,7 +34,7 @@ describe Jobs::ChatChannelArchive do end it "processes the archive" do - Chat::ChatChannelArchiveService.any_instance.expects(:execute) + Chat::ChannelArchiveService.any_instance.expects(:execute) run_job end end diff --git a/plugins/chat/spec/jobs/chat_channel_delete_spec.rb b/plugins/chat/spec/jobs/regular/chat/channel_delete_spec.rb similarity index 76% rename from plugins/chat/spec/jobs/chat_channel_delete_spec.rb rename to plugins/chat/spec/jobs/regular/chat/channel_delete_spec.rb index 3ab19ae6f3b..4d9f5128faf 100644 --- a/plugins/chat/spec/jobs/chat_channel_delete_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/channel_delete_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe Jobs::ChatChannelDelete do +describe Jobs::Chat::ChannelDelete do fab!(:chat_channel) { Fabricate(:chat_channel) } fab!(:user1) { Fabricate(:user) } fab!(:user2) { Fabricate(:user) } @@ -14,7 +14,7 @@ describe Jobs::ChatChannelDelete do end @message_ids = messages.map(&:id) - 10.times { ChatMessageReaction.create(chat_message: messages.sample, user: users.sample) } + 10.times { Chat::MessageReaction.create(chat_message: messages.sample, user: users.sample) } 10.times do upload = Fabricate(:upload, user: users.sample) @@ -28,14 +28,14 @@ describe Jobs::ChatChannelDelete do UploadReference.create(target: message, upload: upload) end - ChatMention.create( + Chat::Mention.create( user: user2, chat_message: messages.sample, notification: Fabricate(:notification), ) @incoming_chat_webhook_id = Fabricate(:incoming_chat_webhook, chat_channel: chat_channel) - ChatWebhookEvent.create( + Chat::WebhookEvent.create( incoming_chat_webhook: @incoming_chat_webhook_id, chat_message: messages.sample, ) @@ -48,7 +48,7 @@ describe Jobs::ChatChannelDelete do new_message: revision_message.message, ) - ChatDraft.create(chat_channel: chat_channel, user: users.sample, data: "wow some draft") + Chat::Draft.create(chat_channel: chat_channel, user: users.sample, data: "wow some draft") Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user1) Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user2) @@ -59,21 +59,21 @@ describe Jobs::ChatChannelDelete do def counts { - incoming_webhooks: IncomingChatWebhook.where(chat_channel_id: chat_channel.id).count, + incoming_webhooks: Chat::IncomingWebhook.where(chat_channel_id: chat_channel.id).count, webhook_events: - ChatWebhookEvent.where(incoming_chat_webhook_id: @incoming_chat_webhook_id).count, - drafts: ChatDraft.where(chat_channel: chat_channel).count, - channel_memberships: UserChatChannelMembership.where(chat_channel: chat_channel).count, - revisions: ChatMessageRevision.where(chat_message_id: @message_ids).count, - mentions: ChatMention.where(chat_message_id: @message_ids).count, + Chat::WebhookEvent.where(incoming_chat_webhook_id: @incoming_chat_webhook_id).count, + drafts: Chat::Draft.where(chat_channel: chat_channel).count, + channel_memberships: Chat::UserChatChannelMembership.where(chat_channel: chat_channel).count, + revisions: Chat::MessageRevision.where(chat_message_id: @message_ids).count, + mentions: Chat::Mention.where(chat_message_id: @message_ids).count, chat_uploads: DB.query_single( "SELECT COUNT(*) FROM chat_uploads WHERE chat_message_id IN (#{@message_ids.join(",")})", ).first, upload_references: - UploadReference.where(target_id: @message_ids, target_type: "ChatMessage").count, - messages: ChatMessage.where(id: @message_ids).count, - reactions: ChatMessageReaction.where(chat_message_id: @message_ids).count, + UploadReference.where(target_id: @message_ids, target_type: Chat::Message.sti_name).count, + messages: Chat::Message.where(id: @message_ids).count, + reactions: Chat::MessageReaction.where(chat_message_id: @message_ids).count, } end diff --git a/plugins/chat/spec/jobs/regular/delete_user_messages_spec.rb b/plugins/chat/spec/jobs/regular/chat/delete_user_messages_spec.rb similarity index 86% rename from plugins/chat/spec/jobs/regular/delete_user_messages_spec.rb rename to plugins/chat/spec/jobs/regular/chat/delete_user_messages_spec.rb index 26242bae9c7..224d382a3c5 100644 --- a/plugins/chat/spec/jobs/regular/delete_user_messages_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/delete_user_messages_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Jobs::DeleteUserMessages do +RSpec.describe Jobs::Chat::DeleteUserMessages do describe "#execute" do fab!(:user_1) { Fabricate(:user) } fab!(:channel) { Fabricate(:chat_channel) } @@ -26,7 +26,7 @@ RSpec.describe Jobs::DeleteUserMessages do subject.execute(user_id: user_1) - expect(ChatMessage.with_deleted.where(id: chat_message.id)).to be_empty + expect(Chat::Message.with_deleted.where(id: chat_message.id)).to be_empty end end end diff --git a/plugins/chat/spec/jobs/regular/chat_notify_mentioned_spec.rb b/plugins/chat/spec/jobs/regular/chat/notify_mentioned_spec.rb similarity index 92% rename from plugins/chat/spec/jobs/regular/chat_notify_mentioned_spec.rb rename to plugins/chat/spec/jobs/regular/chat/notify_mentioned_spec.rb index e411d5b052a..a72aa02b4bd 100644 --- a/plugins/chat/spec/jobs/regular/chat_notify_mentioned_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/notify_mentioned_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Jobs::ChatNotifyMentioned do +describe Jobs::Chat::NotifyMentioned do fab!(:user_1) { Fabricate(:user) } fab!(:user_2) { Fabricate(:user) } fab!(:public_channel) { Fabricate(:category_channel) } @@ -77,7 +77,7 @@ describe Jobs::ChatNotifyMentioned do it "does nothing when user is not following the channel" do message = create_chat_message - UserChatChannelMembership.where(chat_channel: public_channel, user: user_2).update!( + Chat::UserChatChannelMembership.where(chat_channel: public_channel, user: user_2).update!( following: false, ) @@ -95,7 +95,7 @@ describe Jobs::ChatNotifyMentioned do it "does nothing when user doesn't have a membership record" do message = create_chat_message - UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).destroy! + Chat::UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).destroy! PostAlerter.expects(:push_notification).never @@ -146,8 +146,8 @@ describe Jobs::ChatNotifyMentioned do it "skips desktop notifications based on user preferences" do message = create_chat_message - UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + Chat::UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], ) desktop_notification = @@ -158,8 +158,8 @@ describe Jobs::ChatNotifyMentioned do it "skips push notifications based on user preferences" do message = create_chat_message - UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + Chat::UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], ) PostAlerter.expects(:push_notification).never @@ -173,8 +173,8 @@ describe Jobs::ChatNotifyMentioned do it "skips desktop notifications based on user muting preferences" do message = create_chat_message - UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + Chat::UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], muted: true, ) @@ -186,8 +186,8 @@ describe Jobs::ChatNotifyMentioned do it "skips push notifications based on user muting preferences" do message = create_chat_message - UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + Chat::UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], muted: true, ) @@ -214,7 +214,7 @@ describe Jobs::ChatNotifyMentioned do expect(desktop_notification.data[:notification_type]).to eq(Notification.types[:chat_mention]) expect(desktop_notification.data[:username]).to eq(user_1.username) expect(desktop_notification.data[:tag]).to eq( - Chat::ChatNotifier.push_notification_tag(:mention, public_channel.id), + Chat::Notifier.push_notification_tag(:mention, public_channel.id), ) expect(desktop_notification.data[:excerpt]).to eq(message.push_notification_excerpt) expect(desktop_notification.data[:post_url]).to eq( @@ -230,7 +230,7 @@ describe Jobs::ChatNotifyMentioned do { notification_type: Notification.types[:chat_mention], username: user_1.username, - tag: Chat::ChatNotifier.push_notification_tag(:mention, public_channel.id), + tag: Chat::Notifier.push_notification_tag(:mention, public_channel.id), excerpt: message.push_notification_excerpt, post_url: "/chat/c/#{public_channel.slug}/#{public_channel.id}/#{message.id}", translated_title: payload_translated_title, @@ -264,7 +264,7 @@ describe Jobs::ChatNotifyMentioned do expect(data_hash[:chat_channel_slug]).to eq(public_channel.slug) chat_mention = - ChatMention.where(notification: created_notification, user: user_2, chat_message: message) + Chat::Mention.where(notification: created_notification, user: user_2, chat_message: message) expect(chat_mention).to be_present end end diff --git a/plugins/chat/spec/jobs/regular/chat_notify_watching_spec.rb b/plugins/chat/spec/jobs/regular/chat/notify_watching_spec.rb similarity index 91% rename from plugins/chat/spec/jobs/regular/chat_notify_watching_spec.rb rename to plugins/chat/spec/jobs/regular/chat/notify_watching_spec.rb index 72a09d58bf8..0d6f6e1d63e 100644 --- a/plugins/chat/spec/jobs/regular/chat_notify_watching_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/notify_watching_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Jobs::ChatNotifyWatching do +RSpec.describe Jobs::Chat::NotifyWatching do fab!(:user1) { Fabricate(:user) } fab!(:user2) { Fabricate(:user) } fab!(:user3) { Fabricate(:user) } @@ -39,7 +39,7 @@ RSpec.describe Jobs::ChatNotifyWatching do before do membership2.update!( - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) end @@ -56,7 +56,7 @@ RSpec.describe Jobs::ChatNotifyWatching do "discourse_push_notifications.popup.new_chat_message", { username: user1.username, channel: channel.title(user2) }, ), - tag: Chat::ChatNotifier.push_notification_tag(:message, channel.id), + tag: Chat::Notifier.push_notification_tag(:message, channel.id), excerpt: message.message, }, ) @@ -75,8 +75,8 @@ RSpec.describe Jobs::ChatNotifyWatching do context "when mobile_notification_level is always and desktop_notification_level is none" do before do membership2.update!( - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) end @@ -93,7 +93,7 @@ RSpec.describe Jobs::ChatNotifyWatching do "discourse_push_notifications.popup.new_chat_message", { username: user1.username, channel: channel.title(user2) }, ), - tag: Chat::ChatNotifier.push_notification_tag(:message, channel.id), + tag: Chat::Notifier.push_notification_tag(:message, channel.id), excerpt: message.message, }, ), @@ -179,7 +179,7 @@ RSpec.describe Jobs::ChatNotifyWatching do before do membership2.update!( - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) end @@ -196,7 +196,7 @@ RSpec.describe Jobs::ChatNotifyWatching do "discourse_push_notifications.popup.new_direct_chat_message", { username: user1.username, channel: channel.title(user2) }, ), - tag: Chat::ChatNotifier.push_notification_tag(:message, channel.id), + tag: Chat::Notifier.push_notification_tag(:message, channel.id), excerpt: message.message, }, ) @@ -215,8 +215,8 @@ RSpec.describe Jobs::ChatNotifyWatching do context "when mobile_notification_level is always and desktop_notification_level is none" do before do membership2.update!( - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) end @@ -233,7 +233,7 @@ RSpec.describe Jobs::ChatNotifyWatching do "discourse_push_notifications.popup.new_direct_chat_message", { username: user1.username, channel: channel.title(user2) }, ), - tag: Chat::ChatNotifier.push_notification_tag(:message, channel.id), + tag: Chat::Notifier.push_notification_tag(:message, channel.id), excerpt: message.message, }, ), diff --git a/plugins/chat/spec/jobs/process_chat_message_spec.rb b/plugins/chat/spec/jobs/regular/chat/process_message_spec.rb similarity index 88% rename from plugins/chat/spec/jobs/process_chat_message_spec.rb rename to plugins/chat/spec/jobs/regular/chat/process_message_spec.rb index cb98c286afc..658bb2e3d1c 100644 --- a/plugins/chat/spec/jobs/process_chat_message_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/process_message_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Jobs::ProcessChatMessage do +describe Jobs::Chat::ProcessMessage do fab!(:chat_message) { Fabricate(:chat_message, message: "https://discourse.org/team") } it "updates cooked with oneboxes" do @@ -23,7 +23,7 @@ describe Jobs::ProcessChatMessage do fab!(:chat_message) { Fabricate(:chat_message, message: "a very lovely cat") } it "publishes the update" do - ChatPublisher.expects(:publish_processed!).once + Chat::Publisher.expects(:publish_processed!).once described_class.new.execute(chat_message_id: chat_message.id, is_dirty: true) end end @@ -32,14 +32,14 @@ describe Jobs::ProcessChatMessage do fab!(:chat_message) { Fabricate(:chat_message, message: "a very lovely cat") } it "doesn’t publish the update" do - ChatPublisher.expects(:publish_processed!).never + Chat::Publisher.expects(:publish_processed!).never described_class.new.execute(chat_message_id: chat_message.id) end context "when the cooked message changed" do it "publishes the update" do chat_message.update!(cooked: "another lovely cat") - ChatPublisher.expects(:publish_processed!).once + Chat::Publisher.expects(:publish_processed!).once described_class.new.execute(chat_message_id: chat_message.id) end end diff --git a/plugins/chat/spec/jobs/regular/send_message_notifications_spec.rb b/plugins/chat/spec/jobs/regular/chat/send_message_notifications_spec.rb similarity index 59% rename from plugins/chat/spec/jobs/regular/send_message_notifications_spec.rb rename to plugins/chat/spec/jobs/regular/chat/send_message_notifications_spec.rb index e00bad83f5c..2739339fc35 100644 --- a/plugins/chat/spec/jobs/regular/send_message_notifications_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/send_message_notifications_spec.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -RSpec.describe Jobs::SendMessageNotifications do +RSpec.describe Jobs::Chat::SendMessageNotifications do describe "#execute" do context "when the message doesn't exist" do it "does nothing" do - Chat::ChatNotifier.any_instance.expects(:notify_new).never - Chat::ChatNotifier.any_instance.expects(:notify_edit).never + Chat::Notifier.any_instance.expects(:notify_new).never + Chat::Notifier.any_instance.expects(:notify_edit).never subject.execute(eason: "new", timestamp: 1.minute.ago) end @@ -15,8 +15,8 @@ RSpec.describe Jobs::SendMessageNotifications do fab!(:chat_message) { Fabricate(:chat_message) } it "does nothing when the reason is invalid" do - Chat::ChatNotifier.expects(:notify_new).never - Chat::ChatNotifier.expects(:notify_edit).never + Chat::Notifier.expects(:notify_new).never + Chat::Notifier.expects(:notify_edit).never subject.execute( chat_message_id: chat_message.id, @@ -26,22 +26,22 @@ RSpec.describe Jobs::SendMessageNotifications do end it "does nothing if there is no timestamp" do - Chat::ChatNotifier.any_instance.expects(:notify_new).never - Chat::ChatNotifier.any_instance.expects(:notify_edit).never + Chat::Notifier.any_instance.expects(:notify_new).never + Chat::Notifier.any_instance.expects(:notify_edit).never subject.execute(chat_message_id: chat_message.id, reason: "new") end it "calls notify_new when the reason is 'new'" do - Chat::ChatNotifier.any_instance.expects(:notify_new).once - Chat::ChatNotifier.any_instance.expects(:notify_edit).never + Chat::Notifier.any_instance.expects(:notify_new).once + Chat::Notifier.any_instance.expects(:notify_edit).never subject.execute(chat_message_id: chat_message.id, reason: "new", timestamp: 1.minute.ago) end it "calls notify_edit when the reason is 'edit'" do - Chat::ChatNotifier.any_instance.expects(:notify_new).never - Chat::ChatNotifier.any_instance.expects(:notify_edit).once + Chat::Notifier.any_instance.expects(:notify_new).never + Chat::Notifier.any_instance.expects(:notify_edit).once subject.execute(chat_message_id: chat_message.id, reason: "edit", timestamp: 1.minute.ago) end diff --git a/plugins/chat/spec/jobs/regular/update_channel_user_count_spec.rb b/plugins/chat/spec/jobs/regular/chat/update_channel_user_count_spec.rb similarity index 82% rename from plugins/chat/spec/jobs/regular/update_channel_user_count_spec.rb rename to plugins/chat/spec/jobs/regular/chat/update_channel_user_count_spec.rb index 6674e53b9e7..a6d2bffbe27 100644 --- a/plugins/chat/spec/jobs/regular/update_channel_user_count_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/update_channel_user_count_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Jobs::UpdateChannelUserCount do +RSpec.describe Jobs::Chat::UpdateChannelUserCount do fab!(:channel) { Fabricate(:category_channel, user_count: 0, user_count_stale: true) } fab!(:user1) { Fabricate(:user) } fab!(:user2) { Fabricate(:user) } @@ -18,18 +18,18 @@ RSpec.describe Jobs::UpdateChannelUserCount do it "does nothing if the channel does not exist" do channel.destroy - ChatPublisher.expects(:publish_chat_channel_metadata).never + Chat::Publisher.expects(:publish_chat_channel_metadata).never expect(described_class.new.execute(chat_channel_id: channel.id)).to eq(nil) end it "does nothing if the user count has not been marked stale" do channel.update!(user_count_stale: false) - ChatPublisher.expects(:publish_chat_channel_metadata).never + Chat::Publisher.expects(:publish_chat_channel_metadata).never expect(described_class.new.execute(chat_channel_id: channel.id)).to eq(nil) end it "updates the channel user_count and sets user_count_stale back to false" do - ChatPublisher.expects(:publish_chat_channel_metadata).with(channel) + Chat::Publisher.expects(:publish_chat_channel_metadata).with(channel) described_class.new.execute(chat_channel_id: channel.id) channel.reload expect(channel.user_count).to eq(3) diff --git a/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb b/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb index e420b0a8a58..1807f0e74d5 100644 --- a/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb +++ b/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb @@ -2,18 +2,18 @@ require "rails_helper" -describe Jobs::AutoJoinUsers do +describe Jobs::Chat::AutoJoinUsers do it "works" do Jobs.run_immediately! channel = Fabricate(:category_channel, auto_join_users: true) user = Fabricate(:user, last_seen_at: 1.minute.ago, active: true) - membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + membership = Chat::UserChatChannelMembership.find_by(user: user, chat_channel: channel) expect(membership).to be_nil subject.execute({}) - membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + membership = Chat::UserChatChannelMembership.find_by(user: user, chat_channel: channel) expect(membership.following).to eq(true) end end diff --git a/plugins/chat/spec/jobs/delete_old_chat_messages_spec.rb b/plugins/chat/spec/jobs/scheduled/delete_old_chat_messages_spec.rb similarity index 95% rename from plugins/chat/spec/jobs/delete_old_chat_messages_spec.rb rename to plugins/chat/spec/jobs/scheduled/delete_old_chat_messages_spec.rb index 79b0ea94c21..2fa3edb18cd 100644 --- a/plugins/chat/spec/jobs/delete_old_chat_messages_spec.rb +++ b/plugins/chat/spec/jobs/scheduled/delete_old_chat_messages_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Jobs::DeleteOldChatMessages do +describe Jobs::Chat::DeleteOldMessages do base_date = DateTime.parse("2020-12-01 00:00 UTC") fab!(:public_channel) { Fabricate(:category_channel) } @@ -85,7 +85,7 @@ describe Jobs::DeleteOldChatMessages do SiteSetting.chat_channel_retention_days = 0 SiteSetting.chat_dm_retention_days = 0 - expect { described_class.new.execute }.not_to change { ChatMessage.count } + expect { described_class.new.execute }.not_to change { Chat::Message.count } end describe "public channels" do @@ -107,7 +107,7 @@ describe Jobs::DeleteOldChatMessages do it "does nothing when no messages fall in the time range" do SiteSetting.chat_channel_retention_days = 800 - expect { described_class.new.execute }.not_to change { ChatMessage.in_public_channel.count } + expect { described_class.new.execute }.not_to change { Chat::Message.in_public_channel.count } end end @@ -130,7 +130,7 @@ describe Jobs::DeleteOldChatMessages do it "does nothing when no messages fall in the time range" do SiteSetting.chat_dm_retention_days = 800 - expect { described_class.new.execute }.not_to change { ChatMessage.in_dm_channel.count } + expect { described_class.new.execute }.not_to change { Chat::Message.in_dm_channel.count } end end end diff --git a/plugins/chat/spec/jobs/scheduled/email_chat_notifications_spec.rb b/plugins/chat/spec/jobs/scheduled/email_notifications_spec.rb similarity index 55% rename from plugins/chat/spec/jobs/scheduled/email_chat_notifications_spec.rb rename to plugins/chat/spec/jobs/scheduled/email_notifications_spec.rb index c061288aabd..a0c2975e68e 100644 --- a/plugins/chat/spec/jobs/scheduled/email_chat_notifications_spec.rb +++ b/plugins/chat/spec/jobs/scheduled/email_notifications_spec.rb @@ -1,15 +1,15 @@ # frozen_string_literal: true -describe Jobs::EmailChatNotifications do +describe Jobs::Chat::EmailNotifications do before { Jobs.run_immediately! } context "when chat is enabled" do before { SiteSetting.chat_enabled = true } it "starts the mailer" do - Chat::ChatMailer.expects(:send_unread_mentions_summary) + Chat::Mailer.expects(:send_unread_mentions_summary) - Jobs.enqueue(:email_chat_notifications) + Jobs.enqueue(Jobs::Chat::EmailNotifications) end end @@ -17,9 +17,9 @@ describe Jobs::EmailChatNotifications do before { SiteSetting.chat_enabled = false } it "does nothing" do - Chat::ChatMailer.expects(:send_unread_mentions_summary).never + Chat::Mailer.expects(:send_unread_mentions_summary).never - Jobs.enqueue(:email_chat_notifications) + Jobs.enqueue(Jobs::Chat::EmailNotifications) end end end diff --git a/plugins/chat/spec/jobs/chat_periodical_updates_spec.rb b/plugins/chat/spec/jobs/scheduled/periodical_updates_spec.rb similarity index 53% rename from plugins/chat/spec/jobs/chat_periodical_updates_spec.rb rename to plugins/chat/spec/jobs/scheduled/periodical_updates_spec.rb index 113a58229ce..5fa751779a3 100644 --- a/plugins/chat/spec/jobs/chat_periodical_updates_spec.rb +++ b/plugins/chat/spec/jobs/scheduled/periodical_updates_spec.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -RSpec.describe Jobs::ChatPeriodicalUpdates do +RSpec.describe Jobs::Chat::PeriodicalUpdates do it "works" do # does not blow up, no mocks, everything is called - Jobs::ChatPeriodicalUpdates.new.execute(nil) + Jobs::Chat::PeriodicalUpdates.new.execute(nil) end end diff --git a/plugins/chat/spec/jobs/update_user_counts_for_chat_channels_spec.rb b/plugins/chat/spec/jobs/scheduled/update_user_counts_for_channels_spec.rb similarity index 88% rename from plugins/chat/spec/jobs/update_user_counts_for_chat_channels_spec.rb rename to plugins/chat/spec/jobs/scheduled/update_user_counts_for_channels_spec.rb index 753b7b7bdfa..67cdbe6228e 100644 --- a/plugins/chat/spec/jobs/update_user_counts_for_chat_channels_spec.rb +++ b/plugins/chat/spec/jobs/scheduled/update_user_counts_for_channels_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Jobs::UpdateUserCountsForChatChannels do +describe Jobs::Chat::UpdateUserCountsForChannels do fab!(:chat_channel_1) { Fabricate(:category_channel, user_count: 0) } fab!(:chat_channel_2) { Fabricate(:category_channel, user_count: 0) } fab!(:user_1) { Fabricate(:user) } @@ -24,7 +24,7 @@ describe Jobs::UpdateUserCountsForChatChannels do it "sets the user_count correctly for each chat channel" do create_memberships - Jobs::UpdateUserCountsForChatChannels.new.execute + Jobs::Chat::UpdateUserCountsForChannels.new.execute expect(chat_channel_1.reload.user_count).to eq(2) expect(chat_channel_2.reload.user_count).to eq(3) @@ -39,7 +39,7 @@ describe Jobs::UpdateUserCountsForChatChannels do user_3.update(staged: true) user_4.update(active: false) - Jobs::UpdateUserCountsForChatChannels.new.execute + Jobs::Chat::UpdateUserCountsForChannels.new.execute expect(chat_channel_1.reload.user_count).to eq(1) expect(chat_channel_2.reload.user_count).to eq(0) @@ -49,11 +49,11 @@ describe Jobs::UpdateUserCountsForChatChannels do create_memberships chat_channel_1.update!(status: :archived) - Jobs::UpdateUserCountsForChatChannels.new.execute + Jobs::Chat::UpdateUserCountsForChannels.new.execute expect(chat_channel_1.reload.user_count).to eq(0) chat_channel_1.update!(status: :read_only) - Jobs::UpdateUserCountsForChatChannels.new.execute + Jobs::Chat::UpdateUserCountsForChannels.new.execute expect(chat_channel_1.reload.user_count).to eq(0) end end diff --git a/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb b/plugins/chat/spec/lib/chat/channel_archive_service_spec.rb similarity index 89% rename from plugins/chat/spec/lib/chat_channel_archive_service_spec.rb rename to plugins/chat/spec/lib/chat/channel_archive_service_spec.rb index 144b1032257..44a6d4c4db4 100644 --- a/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb +++ b/plugins/chat/spec/lib/chat/channel_archive_service_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatChannelArchiveService do +describe Chat::ChannelArchiveService do class FakeArchiveError < StandardError end @@ -10,7 +10,7 @@ describe Chat::ChatChannelArchiveService do fab!(:user) { Fabricate(:user, admin: true) } fab!(:category) { Fabricate(:category) } let(:topic_params) { { topic_title: "This will be a new topic", category_id: category.id } } - subject { Chat::ChatChannelArchiveService } + subject { Chat::ChannelArchiveService } before { SiteSetting.chat_enabled = true } @@ -32,7 +32,7 @@ describe Chat::ChatChannelArchiveService do acting_user: user, topic_params: topic_params, ) - channel_archive = ChatChannelArchive.find_by(chat_channel: channel) + channel_archive = Chat::ChannelArchive.find_by(chat_channel: channel) expect(channel_archive.archived_by).to eq(user) expect(channel_archive.destination_topic_title).to eq("This will be a new topic") expect(channel_archive.destination_category_id).to eq(category.id) @@ -49,7 +49,7 @@ describe Chat::ChatChannelArchiveService do ) expect( job_enqueued?( - job: :chat_channel_archive, + job: Jobs::Chat::ChannelArchive, args: { chat_channel_archive_id: channel_archive.id, }, @@ -69,7 +69,7 @@ describe Chat::ChatChannelArchiveService do acting_user: user, topic_params: topic_params, ) - }.not_to change { ChatChannelArchive.count } + }.not_to change { Chat::ChannelArchive.count } end it "does not count already deleted messages toward the archive total" do @@ -106,13 +106,13 @@ describe Chat::ChatChannelArchiveService do it "makes a topic, deletes all the messages, creates posts for batches of messages, and changes the channel to archived" do create_messages(50) && start_archive - reaction_message = ChatMessage.last - ChatMessageReaction.create!( + reaction_message = Chat::Message.last + Chat::MessageReaction.create!( chat_message: reaction_message, user: Fabricate(:user), emoji: "+1", ) - stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + stub_const(Chat::ChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do subject.new(@channel_archive).execute end @@ -152,7 +152,7 @@ describe Chat::ChatChannelArchiveService do it "successfully links uploads from messages to the post" do create_messages(3) && start_archive - UploadReference.create(target: ChatMessage.last, upload: Fabricate(:upload)) + UploadReference.create!(target: Chat::Message.last, upload: Fabricate(:upload)) subject.new(@channel_archive).execute expect(@channel_archive.reload.complete?).to eq(true) expect(@channel_archive.destination_topic.posts.last.upload_references.count).to eq(1) @@ -207,30 +207,34 @@ describe Chat::ChatChannelArchiveService do .chat_messages .map(&:user) .each do |user| - UserChatChannelMembership.create!(chat_channel: channel, user: user, following: true) + Chat::UserChatChannelMembership.create!( + chat_channel: channel, + user: user, + following: true, + ) end end it "unfollows (leaves) the channel for all users" do expect( - UserChatChannelMembership.where(chat_channel: channel, following: true).count, + Chat::UserChatChannelMembership.where(chat_channel: channel, following: true).count, ).to eq(3) start_archive subject.new(@channel_archive).execute expect(@channel_archive.reload.complete?).to eq(true) expect( - UserChatChannelMembership.where(chat_channel: channel, following: true).count, + Chat::UserChatChannelMembership.where(chat_channel: channel, following: true).count, ).to eq(0) end it "resets unread state for all users" do - UserChatChannelMembership.last.update!( + Chat::UserChatChannelMembership.last.update!( last_read_message_id: channel.chat_messages.first.id, ) start_archive subject.new(@channel_archive).execute expect(@channel_archive.reload.complete?).to eq(true) - expect(UserChatChannelMembership.last.last_read_message_id).to eq( + expect(Chat::UserChatChannelMembership.last.last_read_message_id).to eq( channel.chat_messages.last.id, ) end @@ -300,13 +304,13 @@ describe Chat::ChatChannelArchiveService do it "deletes all the messages, creates posts for batches of messages, and changes the channel to archived" do create_messages(50) && start_archive - reaction_message = ChatMessage.last - ChatMessageReaction.create!( + reaction_message = Chat::Message.last + Chat::MessageReaction.create!( chat_message: reaction_message, user: Fabricate(:user), emoji: "+1", ) - stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + stub_const(Chat::ChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do subject.new(@channel_archive).execute end @@ -342,12 +346,12 @@ describe Chat::ChatChannelArchiveService do Rails.logger = @fake_logger = FakeLogger.new create_messages(35) && start_archive - Chat::ChatChannelArchiveService + Chat::ChannelArchiveService .any_instance .stubs(:create_post) .raises(FakeArchiveError.new("this is a test error")) - stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + stub_const(Chat::ChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do expect { subject.new(@channel_archive).execute }.to raise_error(FakeArchiveError) end @@ -359,8 +363,8 @@ describe Chat::ChatChannelArchiveService do I18n.t("system_messages.chat_channel_archive_failed.subject_template"), ) - Chat::ChatChannelArchiveService.any_instance.unstub(:create_post) - stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + Chat::ChannelArchiveService.any_instance.unstub(:create_post) + stub_const(Chat::ChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do subject.new(@channel_archive).execute end diff --git a/plugins/chat/spec/lib/chat_channel_fetcher_spec.rb b/plugins/chat/spec/lib/chat/channel_fetcher_spec.rb similarity index 66% rename from plugins/chat/spec/lib/chat_channel_fetcher_spec.rb rename to plugins/chat/spec/lib/chat/channel_fetcher_spec.rb index 46a1f394197..d556aa43e84 100644 --- a/plugins/chat/spec/lib/chat_channel_fetcher_spec.rb +++ b/plugins/chat/spec/lib/chat/channel_fetcher_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe Chat::ChatChannelFetcher do +describe Chat::ChannelFetcher do fab!(:category) { Fabricate(:category, name: "support") } fab!(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) } fab!(:category_channel) { Fabricate(:category_channel, chatable: category, slug: "support") } @@ -16,30 +16,30 @@ describe Chat::ChatChannelFetcher do end def memberships - UserChatChannelMembership.where(user: user1) + Chat::UserChatChannelMembership.where(user: user1) end describe ".structured" do it "returns open channel only" do category_channel.user_chat_channel_memberships.create!(user: user1, following: true) - channels = subject.structured(guardian)[:public_channels] + channels = described_class.structured(guardian)[:public_channels] expect(channels).to contain_exactly(category_channel) category_channel.closed!(Discourse.system_user) - channels = subject.structured(guardian)[:public_channels] + channels = described_class.structured(guardian)[:public_channels] expect(channels).to be_blank end it "returns followed channel only" do - channels = subject.structured(guardian)[:public_channels] + channels = described_class.structured(guardian)[:public_channels] expect(channels).to be_blank category_channel.user_chat_channel_memberships.create!(user: user1, following: true) - channels = subject.structured(guardian)[:public_channels] + channels = described_class.structured(guardian)[:public_channels] expect(channels).to contain_exactly(category_channel) end @@ -58,14 +58,14 @@ describe Chat::ChatChannelFetcher do end it "returns the correct count" do - unread_counts = subject.unread_counts([category_channel], user1) + unread_counts = described_class.unread_counts([category_channel], user1) expect(unread_counts[category_channel.id]).to eq(2) end end context "with no unread messages" do it "returns the correct count" do - unread_counts = subject.unread_counts([category_channel], user1) + unread_counts = described_class.unread_counts([category_channel], user1) expect(unread_counts[category_channel.id]).to eq(0) end end @@ -78,7 +78,7 @@ describe Chat::ChatChannelFetcher do before { last_unread.update!(deleted_at: Time.zone.now) } it "returns the correct count" do - unread_counts = subject.unread_counts([category_channel], user1) + unread_counts = described_class.unread_counts([category_channel], user1) expect(unread_counts[category_channel.id]).to eq(0) end end @@ -91,7 +91,7 @@ describe Chat::ChatChannelFetcher do end it "returns the correct count" do - unread_counts = subject.unread_counts([category_channel], user1) + unread_counts = described_class.unread_counts([category_channel], user1) expect(unread_counts[category_channel.id]).to eq(0) end end @@ -100,41 +100,45 @@ describe Chat::ChatChannelFetcher do describe ".all_secured_channel_ids" do it "returns nothing by default if the user has no memberships" do - expect(subject.all_secured_channel_ids(guardian)).to eq([]) + expect(described_class.all_secured_channel_ids(guardian)).to eq([]) end context "when the user has memberships to all the channels" do before do - UserChatChannelMembership.create!( + Chat::UserChatChannelMembership.create!( user: user1, chat_channel: category_channel, following: true, ) - UserChatChannelMembership.create!( + Chat::UserChatChannelMembership.create!( user: user1, chat_channel: direct_message_channel1, following: true, - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) end it "returns category channel because they are public by default" do - expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id]) + expect(described_class.all_secured_channel_ids(guardian)).to match_array( + [category_channel.id], + ) end it "returns all the channels if the user is a member of the DM channel also" do - DirectMessageUser.create!(user: user1, direct_message: dm_channel1) - expect(subject.all_secured_channel_ids(guardian)).to match_array( + Chat::DirectMessageUser.create!(user: user1, direct_message: dm_channel1) + expect(described_class.all_secured_channel_ids(guardian)).to match_array( [category_channel.id, direct_message_channel1.id], ) end it "does not include the category channel if the category is a private category the user cannot see" do category_channel.update!(chatable: private_category) - expect(subject.all_secured_channel_ids(guardian)).to be_empty + expect(described_class.all_secured_channel_ids(guardian)).to be_empty GroupUser.create!(group: private_category.groups.last, user: user1) - expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id]) + expect(described_class.all_secured_channel_ids(guardian)).to match_array( + [category_channel.id], + ) end context "when restricted category" do @@ -150,7 +154,7 @@ describe Chat::ChatChannelFetcher do permission_type: CategoryGroup.permission_types[:readonly], ), ) - expect(subject.all_secured_channel_ids(guardian)).to be_empty + expect(described_class.all_secured_channel_ids(guardian)).to be_empty end it "includes the category channel for member of group with create_post access" do @@ -162,7 +166,9 @@ describe Chat::ChatChannelFetcher do permission_type: CategoryGroup.permission_types[:create_post], ), ) - expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id]) + expect(described_class.all_secured_channel_ids(guardian)).to match_array( + [category_channel.id], + ) end it "includes the category channel for member of group with full access" do @@ -174,7 +180,9 @@ describe Chat::ChatChannelFetcher do permission_type: CategoryGroup.permission_types[:full], ), ) - expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id]) + expect(described_class.all_secured_channel_ids(guardian)).to match_array( + [category_channel.id], + ) end end end @@ -185,13 +193,15 @@ describe Chat::ChatChannelFetcher do it "does not include DM channels" do expect( - subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + described_class.secured_public_channels(guardian, memberships, following: following).map( + &:id + ), ).to match_array([category_channel.id]) end it "can filter by channel name, or category name" do expect( - subject.secured_public_channels( + described_class.secured_public_channels( guardian, memberships, following: following, @@ -202,7 +212,7 @@ describe Chat::ChatChannelFetcher do category_channel.update!(name: "cool stuff") expect( - subject.secured_public_channels( + described_class.secured_public_channels( guardian, memberships, following: following, @@ -213,29 +223,33 @@ describe Chat::ChatChannelFetcher do it "can filter by an array of slugs" do expect( - subject.secured_public_channels(guardian, memberships, slugs: ["support"]).map(&:id), + described_class.secured_public_channels(guardian, memberships, slugs: ["support"]).map( + &:id + ), ).to match_array([category_channel.id]) end it "returns nothing if the array of slugs is empty" do - expect(subject.secured_public_channels(guardian, memberships, slugs: []).map(&:id)).to eq([]) + expect( + described_class.secured_public_channels(guardian, memberships, slugs: []).map(&:id), + ).to eq([]) end it "can filter by status" do expect( - subject.secured_public_channels(guardian, memberships, status: "closed").map(&:id), + described_class.secured_public_channels(guardian, memberships, status: "closed").map(&:id), ).to match_array([]) category_channel.closed!(Discourse.system_user) expect( - subject.secured_public_channels(guardian, memberships, status: "closed").map(&:id), + described_class.secured_public_channels(guardian, memberships, status: "closed").map(&:id), ).to match_array([category_channel.id]) end it "can filter by following" do expect( - subject.secured_public_channels(guardian, memberships, following: true).map(&:id), + described_class.secured_public_channels(guardian, memberships, following: true).map(&:id), ).to be_blank end @@ -244,35 +258,39 @@ describe Chat::ChatChannelFetcher do another_channel = Fabricate(:category_channel) expect( - subject.secured_public_channels(guardian, memberships, following: false).map(&:id), + described_class.secured_public_channels(guardian, memberships, following: false).map(&:id), ).to match_array([category_channel.id, another_channel.id]) end it "ensures offset is >= 0" do expect( - subject.secured_public_channels(guardian, memberships, offset: -235).map(&:id), + described_class.secured_public_channels(guardian, memberships, offset: -235).map(&:id), ).to match_array([category_channel.id]) end it "ensures limit is > 0" do expect( - subject.secured_public_channels(guardian, memberships, limit: -1, offset: 0).map(&:id), + described_class.secured_public_channels(guardian, memberships, limit: -1, offset: 0).map( + &:id + ), ).to match_array([category_channel.id]) end it "ensures limit has a max value" do - over_limit = Chat::ChatChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS + 1 + over_limit = Chat::ChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS + 1 over_limit.times { Fabricate(:category_channel) } expect( - subject.secured_public_channels(guardian, memberships, limit: over_limit).length, - ).to eq(Chat::ChatChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS) + described_class.secured_public_channels(guardian, memberships, limit: over_limit).length, + ).to eq(Chat::ChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS) end it "does not show the user category channels they cannot access" do category_channel.update!(chatable: private_category) expect( - subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + described_class.secured_public_channels(guardian, memberships, following: following).map( + &:id + ), ).to be_empty end @@ -281,22 +299,26 @@ describe Chat::ChatChannelFetcher do it "only returns channels where the user is a member and is following the channel" do expect( - subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + described_class.secured_public_channels(guardian, memberships, following: following).map( + &:id + ), ).to be_empty - UserChatChannelMembership.create!( + Chat::UserChatChannelMembership.create!( user: user1, chat_channel: category_channel, following: true, ) expect( - subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + described_class.secured_public_channels(guardian, memberships, following: following).map( + &:id + ), ).to match_array([category_channel.id]) end it "includes the unread count based on mute settings" do - UserChatChannelMembership.create!( + Chat::UserChatChannelMembership.create!( user: user1, chat_channel: category_channel, following: true, @@ -306,7 +328,11 @@ describe Chat::ChatChannelFetcher do Fabricate(:chat_message, user: user2, chat_channel: category_channel) resolved_memberships = memberships - subject.secured_public_channels(guardian, resolved_memberships, following: following) + described_class.secured_public_channels( + guardian, + resolved_memberships, + following: following, + ) expect( resolved_memberships @@ -317,7 +343,11 @@ describe Chat::ChatChannelFetcher do resolved_memberships.last.update!(muted: true) resolved_memberships = memberships - subject.secured_public_channels(guardian, resolved_memberships, following: following) + described_class.secured_public_channels( + guardian, + resolved_memberships, + following: following, + ) expect( resolved_memberships @@ -328,7 +358,7 @@ describe Chat::ChatChannelFetcher do end end - describe "#secured_direct_message_channels" do + describe ".secured_direct_message_channels" do it "includes direct message channels the user is a member of ordered by last_message_sent_at" do Fabricate( :user_chat_channel_membership_for_dm, @@ -336,22 +366,22 @@ describe Chat::ChatChannelFetcher do user: user1, following: true, ) - DirectMessageUser.create!(direct_message: dm_channel1, user: user1) - DirectMessageUser.create!(direct_message: dm_channel1, user: user2) + Chat::DirectMessageUser.create!(direct_message: dm_channel1, user: user1) + Chat::DirectMessageUser.create!(direct_message: dm_channel1, user: user2) Fabricate( :user_chat_channel_membership_for_dm, chat_channel: direct_message_channel2, user: user1, following: true, ) - DirectMessageUser.create!(direct_message: dm_channel2, user: user1) - DirectMessageUser.create!(direct_message: dm_channel2, user: user2) + Chat::DirectMessageUser.create!(direct_message: dm_channel2, user: user1) + Chat::DirectMessageUser.create!(direct_message: dm_channel2, user: user2) direct_message_channel1.update!(last_message_sent_at: 1.day.ago) direct_message_channel2.update!(last_message_sent_at: 1.hour.ago) expect( - subject.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), + described_class.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), ).to eq([direct_message_channel2.id, direct_message_channel1.id]) end @@ -362,10 +392,10 @@ describe Chat::ChatChannelFetcher do user: user1, following: true, ) - DirectMessageUser.create!(direct_message: dm_channel1, user: user2) + Chat::DirectMessageUser.create!(direct_message: dm_channel1, user: user2) expect( - subject.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), + described_class.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), ).not_to include(direct_message_channel1.id) end @@ -377,14 +407,14 @@ describe Chat::ChatChannelFetcher do user: user1, following: true, ) - DirectMessageUser.create!(direct_message: dm_channel1, user: user1) - DirectMessageUser.create!(direct_message: dm_channel1, user: user2) + Chat::DirectMessageUser.create!(direct_message: dm_channel1, user: user1) + Chat::DirectMessageUser.create!(direct_message: dm_channel1, user: user2) Fabricate(:chat_message, user: user2, chat_channel: direct_message_channel1) Fabricate(:chat_message, user: user2, chat_channel: direct_message_channel1) resolved_memberships = memberships - subject.secured_direct_message_channels(user1.id, resolved_memberships, guardian) + described_class.secured_direct_message_channels(user1.id, resolved_memberships, guardian) target_membership = resolved_memberships.find { |mem| mem.chat_channel_id == direct_message_channel1.id } expect(target_membership.unread_count).to eq(2) @@ -393,7 +423,7 @@ describe Chat::ChatChannelFetcher do target_membership = resolved_memberships.find { |mem| mem.chat_channel_id == direct_message_channel1.id } target_membership.update!(muted: true) - subject.secured_direct_message_channels(user1.id, resolved_memberships, guardian) + described_class.secured_direct_message_channels(user1.id, resolved_memberships, guardian) expect(target_membership.unread_count).to eq(0) end end @@ -401,20 +431,22 @@ describe Chat::ChatChannelFetcher do describe ".find_with_access_check" do it "raises NotFound if the channel does not exist" do category_channel.destroy! - expect { subject.find_with_access_check(category_channel.id, guardian) }.to raise_error( - Discourse::NotFound, - ) + expect { + described_class.find_with_access_check(category_channel.id, guardian) + }.to raise_error(Discourse::NotFound) end it "raises InvalidAccess if the user cannot see the channel" do category_channel.update!(chatable: private_category) - expect { subject.find_with_access_check(category_channel.id, guardian) }.to raise_error( - Discourse::InvalidAccess, - ) + expect { + described_class.find_with_access_check(category_channel.id, guardian) + }.to raise_error(Discourse::InvalidAccess) end it "returns the chat channel if it is found and accessible" do - expect(subject.find_with_access_check(category_channel.id, guardian)).to eq(category_channel) + expect(described_class.find_with_access_check(category_channel.id, guardian)).to eq( + category_channel, + ) end end end diff --git a/plugins/chat/spec/lib/chat_channel_hashtag_data_source_spec.rb b/plugins/chat/spec/lib/chat/channel_hashtag_data_source_spec.rb similarity index 99% rename from plugins/chat/spec/lib/chat_channel_hashtag_data_source_spec.rb rename to plugins/chat/spec/lib/chat/channel_hashtag_data_source_spec.rb index cf7de252b76..0a93c2b5e0a 100644 --- a/plugins/chat/spec/lib/chat_channel_hashtag_data_source_spec.rb +++ b/plugins/chat/spec/lib/chat/channel_hashtag_data_source_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Chat::ChatChannelHashtagDataSource do +RSpec.describe Chat::ChannelHashtagDataSource do fab!(:user) { Fabricate(:user) } fab!(:category) { Fabricate(:category) } fab!(:group) { Fabricate(:group) } diff --git a/plugins/chat/spec/lib/chat_channel_membership_manager_spec.rb b/plugins/chat/spec/lib/chat/channel_membership_manager_spec.rb similarity index 86% rename from plugins/chat/spec/lib/chat_channel_membership_manager_spec.rb rename to plugins/chat/spec/lib/chat/channel_membership_manager_spec.rb index f5c9c694792..ac96a8fb979 100644 --- a/plugins/chat/spec/lib/chat_channel_membership_manager_spec.rb +++ b/plugins/chat/spec/lib/chat/channel_membership_manager_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Chat::ChatChannelMembershipManager do +RSpec.describe Chat::ChannelMembershipManager do fab!(:user) { Fabricate(:user) } fab!(:channel1) { Fabricate(:category_channel) } fab!(:channel2) { Fabricate(:category_channel) } @@ -31,7 +31,7 @@ RSpec.describe Chat::ChatChannelMembershipManager do it "creates a membership if one does not exist for the user and channel already" do membership = nil expect { membership = described_class.new(channel1).follow(user) }.to change { - UserChatChannelMembership.count + Chat::UserChatChannelMembership.count }.by(1) expect(membership.following).to eq(true) expect(membership.chat_channel).to eq(channel1) @@ -41,7 +41,12 @@ RSpec.describe Chat::ChatChannelMembershipManager do it "enqueues user_count recalculation and marks user_count_stale as true" do described_class.new(channel1).follow(user) expect(channel1.reload.user_count_stale).to eq(true) - expect_job_enqueued(job: :update_channel_user_count, args: { chat_channel_id: channel1.id }) + expect_job_enqueued( + job: Jobs::Chat::UpdateChannelUserCount, + args: { + chat_channel_id: channel1.id, + }, + ) end it "updates the membership to following if it already existed" do @@ -53,7 +58,7 @@ RSpec.describe Chat::ChatChannelMembershipManager do following: false, ) expect { membership = described_class.new(channel1).follow(user) }.not_to change { - UserChatChannelMembership.count + Chat::UserChatChannelMembership.count } expect(membership.reload.following).to eq(true) end @@ -76,7 +81,12 @@ RSpec.describe Chat::ChatChannelMembershipManager do membership.reload expect(membership.following).to eq(false) expect(channel1.reload.user_count_stale).to eq(true) - expect_job_enqueued(job: :update_channel_user_count, args: { chat_channel_id: channel1.id }) + expect_job_enqueued( + job: Jobs::Chat::UpdateChannelUserCount, + args: { + chat_channel_id: channel1.id, + }, + ) end it "does not recalculate user count if the user was already not following the channel" do @@ -88,7 +98,7 @@ RSpec.describe Chat::ChatChannelMembershipManager do following: false, ) expect_not_enqueued_with( - job: :update_channel_user_count, + job: Jobs::Chat::UpdateChannelUserCount, args: { chat_channel_id: channel1.id, }, diff --git a/plugins/chat/spec/lib/direct_message_channel_creator_spec.rb b/plugins/chat/spec/lib/chat/direct_message_channel_creator_spec.rb similarity index 64% rename from plugins/chat/spec/lib/direct_message_channel_creator_spec.rb rename to plugins/chat/spec/lib/chat/direct_message_channel_creator_spec.rb index 0ae71e0c462..dae249a2d52 100644 --- a/plugins/chat/spec/lib/direct_message_channel_creator_spec.rb +++ b/plugins/chat/spec/lib/chat/direct_message_channel_creator_spec.rb @@ -21,20 +21,20 @@ describe Chat::DirectMessageChannelCreator do existing_channel = nil expect { existing_channel = - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) - }.not_to change { ChatChannel.count } + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.not_to change { Chat::Channel.count } expect(existing_channel).to eq(dm_chat_channel) end - it "creates UserChatChannelMembership records and sets their notification levels, and only updates creator membership to following" do + it "creates Chat::UserChatChannelMembership records and sets their notification levels, and only updates creator membership to following" do Fabricate( :user_chat_channel_membership, user: user_2, chat_channel: dm_chat_channel, following: false, muted: true, - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], ) Fabricate( :user_chat_channel_membership, @@ -42,16 +42,19 @@ describe Chat::DirectMessageChannelCreator do chat_channel: dm_chat_channel, following: false, muted: true, - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], ) expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) - }.to change { UserChatChannelMembership.count }.by(1) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to change { Chat::UserChatChannelMembership.count }.by(1) user_1_membership = - UserChatChannelMembership.find_by(user_id: user_1.id, chat_channel_id: dm_chat_channel) + Chat::UserChatChannelMembership.find_by( + user_id: user_1.id, + chat_channel_id: dm_chat_channel, + ) expect(user_1_membership.last_read_message_id).to eq(nil) expect(user_1_membership.desktop_notification_level).to eq("always") expect(user_1_membership.mobile_notification_level).to eq("always") @@ -59,7 +62,10 @@ describe Chat::DirectMessageChannelCreator do expect(user_1_membership.following).to eq(true) user_2_membership = - UserChatChannelMembership.find_by(user_id: user_2.id, chat_channel_id: dm_chat_channel) + Chat::UserChatChannelMembership.find_by( + user_id: user_2.id, + chat_channel_id: dm_chat_channel, + ) expect(user_2_membership.last_read_message_id).to eq(nil) expect(user_2_membership.desktop_notification_level).to eq("never") expect(user_2_membership.mobile_notification_level).to eq("never") @@ -67,7 +73,10 @@ describe Chat::DirectMessageChannelCreator do expect(user_2_membership.following).to eq(false) user_3_membership = - UserChatChannelMembership.find_by(user_id: user_3.id, chat_channel_id: dm_chat_channel) + Chat::UserChatChannelMembership.find_by( + user_id: user_3.id, + chat_channel_id: dm_chat_channel, + ) expect(user_3_membership.last_read_message_id).to eq(nil) expect(user_3_membership.desktop_notification_level).to eq("never") expect(user_3_membership.mobile_notification_level).to eq("never") @@ -79,7 +88,7 @@ describe Chat::DirectMessageChannelCreator do messages = MessageBus .track_publish do - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) end .filter { |m| m.channel == "/chat/new-channel" } @@ -93,16 +102,21 @@ describe Chat::DirectMessageChannelCreator do it "allows a user to create a direct message to themselves, without creating a new channel" do existing_channel = nil expect { - existing_channel = subject.create!(acting_user: user_1, target_users: [user_1]) - }.to not_change { ChatChannel.count }.and change { UserChatChannelMembership.count }.by(1) + existing_channel = described_class.create!(acting_user: user_1, target_users: [user_1]) + }.to not_change { Chat::Channel.count }.and change { + Chat::UserChatChannelMembership.count + }.by(1) expect(existing_channel).to eq(own_chat_channel) end it "deduplicates target_users" do existing_channel = nil expect { - existing_channel = subject.create!(acting_user: user_1, target_users: [user_1, user_1]) - }.to not_change { ChatChannel.count }.and change { UserChatChannelMembership.count }.by(1) + existing_channel = + described_class.create!(acting_user: user_1, target_users: [user_1, user_1]) + }.to not_change { Chat::Channel.count }.and change { + Chat::UserChatChannelMembership.count + }.by(1) expect(existing_channel).to eq(own_chat_channel) end @@ -110,13 +124,14 @@ describe Chat::DirectMessageChannelCreator do before { SiteSetting.direct_message_enabled_groups = Group::AUTO_GROUPS[:trust_level_4] } it "raises an error and does not change membership or channel counts" do - channel_count = ChatChannel.count - membership_count = UserChatChannelMembership.count + channel_count = Chat::Channel.count + membership_count = Chat::UserChatChannelMembership.count expect { - existing_channel = subject.create!(acting_user: user_1, target_users: [user_1, user_1]) + existing_channel = + described_class.create!(acting_user: user_1, target_users: [user_1, user_1]) }.to raise_error(Discourse::InvalidAccess) - expect(ChatChannel.count).to eq(channel_count) - expect(UserChatChannelMembership.count).to eq(membership_count) + expect(Chat::Channel.count).to eq(channel_count) + expect(Chat::UserChatChannelMembership.count).to eq(membership_count) end context "when user is staff" do @@ -126,8 +141,8 @@ describe Chat::DirectMessageChannelCreator do existing_channel = nil expect { existing_channel = - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) - }.not_to change { ChatChannel.count } + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.not_to change { Chat::Channel.count } expect(existing_channel).to eq(dm_chat_channel) end end @@ -136,19 +151,19 @@ describe Chat::DirectMessageChannelCreator do context "with non existing direct message channel" do it "creates a new chat channel" do - expect { subject.create!(acting_user: user_1, target_users: [user_1, user_2]) }.to change { - ChatChannel.count - }.by(1) + expect { + described_class.create!(acting_user: user_1, target_users: [user_1, user_2]) + }.to change { Chat::Channel.count }.by(1) end - it "creates UserChatChannelMembership records and sets their notification levels" do - expect { subject.create!(acting_user: user_1, target_users: [user_1, user_2]) }.to change { - UserChatChannelMembership.count - }.by(2) + it "creates Chat::UserChatChannelMembership records and sets their notification levels" do + expect { + described_class.create!(acting_user: user_1, target_users: [user_1, user_2]) + }.to change { Chat::UserChatChannelMembership.count }.by(2) - chat_channel = ChatChannel.last + chat_channel = Chat::Channel.last user_1_membership = - UserChatChannelMembership.find_by(user_id: user_1.id, chat_channel_id: chat_channel) + Chat::UserChatChannelMembership.find_by(user_id: user_1.id, chat_channel_id: chat_channel) expect(user_1_membership.last_read_message_id).to eq(nil) expect(user_1_membership.desktop_notification_level).to eq("always") expect(user_1_membership.mobile_notification_level).to eq("always") @@ -159,10 +174,12 @@ describe Chat::DirectMessageChannelCreator do it "publishes the new DM channel message bus message for each user" do messages = MessageBus - .track_publish { subject.create!(acting_user: user_1, target_users: [user_1, user_2]) } + .track_publish do + described_class.create!(acting_user: user_1, target_users: [user_1, user_2]) + end .filter { |m| m.channel == "/chat/new-channel" } - chat_channel = ChatChannel.last + chat_channel = Chat::Channel.last expect(messages.count).to eq(2) expect(messages.first[:data]).to be_kind_of(Hash) expect(messages.map { |m| m.dig(:data, :channel, :id) }).to eq( @@ -171,15 +188,17 @@ describe Chat::DirectMessageChannelCreator do end it "allows a user to create a direct message to themselves" do - expect { subject.create!(acting_user: user_1, target_users: [user_1]) }.to change { - ChatChannel.count - }.by(1).and change { UserChatChannelMembership.count }.by(1) + expect { described_class.create!(acting_user: user_1, target_users: [user_1]) }.to change { + Chat::Channel.count + }.by(1).and change { Chat::UserChatChannelMembership.count }.by(1) end it "deduplicates target_users" do - expect { subject.create!(acting_user: user_1, target_users: [user_1, user_1]) }.to change { - ChatChannel.count - }.by(1).and change { UserChatChannelMembership.count }.by(1) + expect { + described_class.create!(acting_user: user_1, target_users: [user_1, user_1]) + }.to change { Chat::Channel.count }.by(1).and change { + Chat::UserChatChannelMembership.count + }.by(1) end context "when number of users is over the limit" do @@ -187,7 +206,7 @@ describe Chat::DirectMessageChannelCreator do it "raises an error" do expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.over_chat_max_direct_message_users", count: 2), @@ -199,8 +218,8 @@ describe Chat::DirectMessageChannelCreator do it "creates a new chat channel" do expect { - subject.create!(acting_user: admin, target_users: [admin, user_1, user_2]) - }.to change { ChatChannel.count }.by(1) + described_class.create!(acting_user: admin, target_users: [admin, user_1, user_2]) + }.to change { Chat::Channel.count }.by(1) end end @@ -209,7 +228,7 @@ describe Chat::DirectMessageChannelCreator do it "raises an error" do expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.over_chat_max_direct_message_users_allow_self"), @@ -222,8 +241,8 @@ describe Chat::DirectMessageChannelCreator do before { SiteSetting.chat_max_direct_message_users = 0 } it "creates a new chat channel" do - expect { subject.create!(acting_user: user_1, target_users: [user_1]) }.to change { - ChatChannel.count + expect { described_class.create!(acting_user: user_1, target_users: [user_1]) }.to change { + Chat::Channel.count }.by(1) end end @@ -232,8 +251,8 @@ describe Chat::DirectMessageChannelCreator do before { SiteSetting.chat_max_direct_message_users = 1 } it "creates a new chat channel" do - expect { subject.create!(acting_user: user_1, target_users: [user_1]) }.to change { - ChatChannel.count + expect { described_class.create!(acting_user: user_1, target_users: [user_1]) }.to change { + Chat::Channel.count }.by(1) end end @@ -242,13 +261,13 @@ describe Chat::DirectMessageChannelCreator do before { SiteSetting.direct_message_enabled_groups = Group::AUTO_GROUPS[:trust_level_4] } it "raises an error and does not change membership or channel counts" do - channel_count = ChatChannel.count - membership_count = UserChatChannelMembership.count + channel_count = Chat::Channel.count + membership_count = Chat::UserChatChannelMembership.count expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2]) }.to raise_error(Discourse::InvalidAccess) - expect(ChatChannel.count).to eq(channel_count) - expect(UserChatChannelMembership.count).to eq(membership_count) + expect(Chat::Channel.count).to eq(channel_count) + expect(Chat::UserChatChannelMembership.count).to eq(membership_count) end context "when user is staff" do @@ -256,8 +275,8 @@ describe Chat::DirectMessageChannelCreator do it "creates a new chat channel" do expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2]) - }.to change { ChatChannel.count }.by(1) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2]) + }.to change { Chat::Channel.count }.by(1) end end end @@ -271,7 +290,7 @@ describe Chat::DirectMessageChannelCreator do it "raises an error with a helpful message" do expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.not_accepting_dms", username: user_2.username), @@ -280,7 +299,7 @@ describe Chat::DirectMessageChannelCreator do it "does not let the ignoring user create a DM either and raises an error with a helpful message" do expect { - subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + described_class.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.actor_ignoring_target_user", username: user_1.username), @@ -293,7 +312,7 @@ describe Chat::DirectMessageChannelCreator do it "raises an error with a helpful message" do expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.not_accepting_dms", username: user_2.username), @@ -302,7 +321,7 @@ describe Chat::DirectMessageChannelCreator do it "does not let the muting user create a DM either and raises an error with a helpful message" do expect { - subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + described_class.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.actor_muting_target_user", username: user_1.username), @@ -315,7 +334,7 @@ describe Chat::DirectMessageChannelCreator do it "raises an error with a helpful message" do expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.not_accepting_dms", username: user_2.username), @@ -324,7 +343,7 @@ describe Chat::DirectMessageChannelCreator do it "does not let the user who is preventing PM/DM create a DM either and raises an error with a helpful message" do expect { - subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + described_class.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.actor_disallowed_dms"), @@ -337,20 +356,20 @@ describe Chat::DirectMessageChannelCreator do it "raises an error with a helpful message" do expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) }.to raise_error(Chat::DirectMessageChannelCreator::NotAllowed) end it "does not raise an error if the acting user is allowed to send the PM" do AllowedPmUser.create!(user: user_2, allowed_pm_user: user_1) expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) - }.to change { ChatChannel.count }.by(1) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to change { Chat::Channel.count }.by(1) end it "does not let the user who is preventing PM/DM create a DM either and raises an error with a helpful message" do expect { - subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + described_class.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.actor_preventing_target_user_from_dm", username: user_1.username), diff --git a/plugins/chat/spec/lib/duplicate_message_validator_spec.rb b/plugins/chat/spec/lib/chat/duplicate_message_validator_spec.rb similarity index 100% rename from plugins/chat/spec/lib/duplicate_message_validator_spec.rb rename to plugins/chat/spec/lib/chat/duplicate_message_validator_spec.rb diff --git a/plugins/chat/spec/lib/guardian_extensions_spec.rb b/plugins/chat/spec/lib/chat/guardian_extensions_spec.rb similarity index 98% rename from plugins/chat/spec/lib/guardian_extensions_spec.rb rename to plugins/chat/spec/lib/chat/guardian_extensions_spec.rb index e2e4c4cbc70..4366cf5df46 100644 --- a/plugins/chat/spec/lib/guardian_extensions_spec.rb +++ b/plugins/chat/spec/lib/chat/guardian_extensions_spec.rb @@ -82,7 +82,7 @@ RSpec.describe Chat::GuardianExtensions do end it "returns true if the user is part of the direct message" do - DirectMessageUser.create!(user: user, direct_message: chatable) + Chat::DirectMessageUser.create!(user: user, direct_message: chatable) expect(guardian.can_join_chat_channel?(channel)).to eq(true) end end @@ -206,7 +206,7 @@ RSpec.describe Chat::GuardianExtensions do end context "for DM channel" do - fab!(:dm_channel) { DirectMessage.create! } + fab!(:dm_channel) { Chat::DirectMessage.create! } before { channel.update(chatable_type: "DirectMessageType", chatable: dm_channel) } @@ -234,7 +234,7 @@ RSpec.describe Chat::GuardianExtensions do end context "when chatable is a direct message" do - fab!(:chatable) { DirectMessage.create! } + fab!(:chatable) { Chat::DirectMessage.create! } it "allows owner to restore" do expect(guardian.can_restore_chat?(message, chatable)).to eq(true) @@ -284,7 +284,7 @@ RSpec.describe Chat::GuardianExtensions do end context "when chatable is a direct message" do - fab!(:chatable) { DirectMessage.create! } + fab!(:chatable) { Chat::DirectMessage.create! } it "allows staff to restore" do expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) @@ -317,7 +317,7 @@ RSpec.describe Chat::GuardianExtensions do end context "when chatable is a direct message" do - fab!(:chatable) { DirectMessage.create! } + fab!(:chatable) { Chat::DirectMessage.create! } it "allows staff to restore" do expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) diff --git a/plugins/chat/spec/lib/chat_message_bookmarkable_spec.rb b/plugins/chat/spec/lib/chat/message_bookmarkable_spec.rb similarity index 94% rename from plugins/chat/spec/lib/chat_message_bookmarkable_spec.rb rename to plugins/chat/spec/lib/chat/message_bookmarkable_spec.rb index af7210611a5..d6c97f1eb59 100644 --- a/plugins/chat/spec/lib/chat_message_bookmarkable_spec.rb +++ b/plugins/chat/spec/lib/chat/message_bookmarkable_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe ChatMessageBookmarkable do +describe Chat::MessageBookmarkable do fab!(:user) { Fabricate(:user) } fab!(:guardian) { Guardian.new(user) } fab!(:other_category) { Fabricate(:private_category, group: Fabricate(:group)) } @@ -11,8 +11,8 @@ describe ChatMessageBookmarkable do fab!(:channel) { Fabricate(:category_channel) } before do - register_test_bookmarkable(ChatMessageBookmarkable) - UserChatChannelMembership.create(chat_channel: channel, user: user, following: true) + register_test_bookmarkable(described_class) + Chat::UserChatChannelMembership.create(chat_channel: channel, user: user, following: true) end after { DiscoursePluginRegistry.reset_register!(:bookmarkables) } @@ -25,7 +25,7 @@ describe ChatMessageBookmarkable do let!(:bookmark2) { Fabricate(:bookmark, user: user, bookmarkable: message2) } let!(:bookmark3) { Fabricate(:bookmark) } - subject { RegisteredBookmarkable.new(ChatMessageBookmarkable) } + subject { RegisteredBookmarkable.new(described_class) } describe "#perform_list_query" do it "returns all the user's bookmarks" do @@ -50,7 +50,7 @@ describe ChatMessageBookmarkable do direct_message = Fabricate(:direct_message) channel.update(chatable: direct_message) expect(subject.perform_list_query(user, guardian)).to eq(nil) - DirectMessageUser.create(user: user, direct_message: direct_message) + Chat::DirectMessageUser.create(user: user, direct_message: direct_message) bookmark1.reload user.reload guardian = Guardian.new(user) @@ -109,7 +109,7 @@ describe ChatMessageBookmarkable do bookmark1.bookmarkable.trash! bookmark1.reload expect(subject.can_send_reminder?(bookmark1)).to eq(false) - ChatMessage.with_deleted.find_by(id: bookmark1.bookmarkable_id).recover! + Chat::Message.with_deleted.find_by(id: bookmark1.bookmarkable_id).recover! bookmark1.reload bookmark1.bookmarkable.chat_channel.trash! bookmark1.reload diff --git a/plugins/chat/spec/lib/chat_message_mentions_spec.rb b/plugins/chat/spec/lib/chat/message_mentions_spec.rb similarity index 86% rename from plugins/chat/spec/lib/chat_message_mentions_spec.rb rename to plugins/chat/spec/lib/chat/message_mentions_spec.rb index dd2480d3e0a..4254c9c19ca 100644 --- a/plugins/chat/spec/lib/chat_message_mentions_spec.rb +++ b/plugins/chat/spec/lib/chat/message_mentions_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -RSpec.describe Chat::ChatMessageMentions do +RSpec.describe Chat::MessageMentions do fab!(:channel_member_1) { Fabricate(:user) } fab!(:channel_member_2) { Fabricate(:user) } fab!(:channel_member_3) { Fabricate(:user) } @@ -19,7 +19,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns all members of the channel" do message = create_message("mentioning @all") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.global_mentions.pluck(:username) expect(result).to contain_exactly( @@ -32,7 +32,7 @@ RSpec.describe Chat::ChatMessageMentions do it "doesn't include users that were also mentioned directly" do message = create_message("mentioning @all and @#{channel_member_1.username}") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.global_mentions.pluck(:username) expect(result).to contain_exactly(channel_member_2.username, channel_member_3.username) @@ -41,7 +41,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns an empty list if there are no global mentions" do message = create_message("not mentioning anybody") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.global_mentions.pluck(:username) expect(result).to be_empty @@ -59,7 +59,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns all members of the channel who were online in the last 5 minutes" do message = create_message("mentioning @here") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.here_mentions.pluck(:username) expect(result).to contain_exactly(channel_member_1.username, channel_member_2.username) @@ -68,7 +68,7 @@ RSpec.describe Chat::ChatMessageMentions do it "doesn't include users that were also mentioned directly" do message = create_message("mentioning @here and @#{channel_member_1.username}") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.here_mentions.pluck(:username) expect(result).to contain_exactly(channel_member_2.username) @@ -77,7 +77,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns an empty list if there are no here mentions" do message = create_message("not mentioning anybody") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.here_mentions.pluck(:username) expect(result).to be_empty @@ -89,7 +89,7 @@ RSpec.describe Chat::ChatMessageMentions do message = create_message("mentioning @#{channel_member_1.username} and @#{channel_member_2.username}") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.direct_mentions.pluck(:username) expect(result).to contain_exactly(channel_member_1.username, channel_member_2.username) @@ -98,7 +98,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns a mentioned user even if he's not a member of the channel" do message = create_message("mentioning @#{not_a_channel_member.username}") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.direct_mentions.pluck(:username) expect(result).to contain_exactly(not_a_channel_member.username) @@ -107,7 +107,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns an empty list if no one was mentioned directly" do message = create_message("not mentioning anybody") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.direct_mentions.pluck(:username) expect(result).to be_empty @@ -128,7 +128,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns members of a mentioned group even if some of them is not members of the channel" do message = create_message("mentioning @#{group1.name}") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.group_mentions.pluck(:username) expect(result).to contain_exactly( @@ -141,7 +141,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns an empty list if no group was mentioned" do message = create_message("not mentioning anyone") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.group_mentions.pluck(:username) expect(result).to be_empty @@ -152,7 +152,7 @@ RSpec.describe Chat::ChatMessageMentions do group1.save! message = create_message("mentioning @#{group1.name}") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.group_mentions.pluck(:username) expect(result).to be_empty diff --git a/plugins/chat/spec/lib/message_mover_spec.rb b/plugins/chat/spec/lib/chat/message_mover_spec.rb similarity index 88% rename from plugins/chat/spec/lib/message_mover_spec.rb rename to plugins/chat/spec/lib/chat/message_mover_spec.rb index f78d3f6b0f3..3c3781be65e 100644 --- a/plugins/chat/spec/lib/message_mover_spec.rb +++ b/plugins/chat/spec/lib/chat/message_mover_spec.rb @@ -72,8 +72,8 @@ describe Chat::MessageMover do it "deletes the messages from the source channel and sends messagebus delete messages" do messages = MessageBus.track_publish { move! } - expect(ChatMessage.where(id: move_message_ids)).to eq([]) - deleted_messages = ChatMessage.with_deleted.where(id: move_message_ids).order(:id) + expect(Chat::Message.where(id: move_message_ids)).to eq([]) + deleted_messages = Chat::Message.with_deleted.where(id: move_message_ids).order(:id) expect(deleted_messages.count).to eq(3) expect(messages.first.channel).to eq("/chat/#{source_channel.id}") expect(messages.first.data[:typ]).to eq("bulk_delete") @@ -83,9 +83,10 @@ describe Chat::MessageMover do it "creates a message in the source channel to indicate that the messages have been moved" do move! - placeholder_message = ChatMessage.where(chat_channel: source_channel).order(:created_at).last + placeholder_message = + Chat::Message.where(chat_channel: source_channel).order(:created_at).last destination_first_moved_message = - ChatMessage.find_by(chat_channel: destination_channel, message: "the first to be moved") + Chat::Message.find_by(chat_channel: destination_channel, message: "the first to be moved") expect(placeholder_message.message).to eq( I18n.t( "chat.channel.messages_moved", @@ -100,7 +101,10 @@ describe Chat::MessageMover do it "preserves the order of the messages in the destination channel" do move! moved_messages = - ChatMessage.where(chat_channel: destination_channel).order("created_at ASC, id ASC").last(3) + Chat::Message + .where(chat_channel: destination_channel) + .order("created_at ASC, id ASC") + .last(3) expect(moved_messages.map(&:message)).to eq( ["the first to be moved", "message deux @testmovechat", "the third message"], ) @@ -115,7 +119,10 @@ describe Chat::MessageMover do move! moved_messages = - ChatMessage.where(chat_channel: destination_channel).order("created_at ASC, id ASC").last(3) + Chat::Message + .where(chat_channel: destination_channel) + .order("created_at ASC, id ASC") + .last(3) expect(reaction.reload.chat_message_id).to eq(moved_messages.first.id) expect(upload.reload.target_id).to eq(moved_messages.first.id) expect(mention.reload.chat_message_id).to eq(moved_messages.second.id) @@ -128,7 +135,10 @@ describe Chat::MessageMover do message2.update!(in_reply_to: message1) move! moved_messages = - ChatMessage.where(chat_channel: destination_channel).order("created_at ASC, id ASC").last(3) + Chat::Message + .where(chat_channel: destination_channel) + .order("created_at ASC, id ASC") + .last(3) expect(moved_messages.pluck(:in_reply_to_id).uniq).to eq([nil]) end @@ -152,7 +162,7 @@ describe Chat::MessageMover do it "does not preserve thread_ids" do move! moved_messages = - ChatMessage + Chat::Message .where(chat_channel: destination_channel) .order("created_at ASC, id ASC") .last(3) @@ -162,7 +172,7 @@ describe Chat::MessageMover do it "deletes the empty thread" do move! - expect(ChatThread.exists?(id: thread.id)).to eq(false) + expect(Chat::Thread.exists?(id: thread.id)).to eq(false) end it "clears in_reply_to_id for remaining messages when the messages they were replying to are moved but leaves the thread_id" do @@ -190,8 +200,8 @@ describe Chat::MessageMover do message: "the fifth message", thread: thread, ) - expect { move! }.to change { ChatThread.count }.by(1) - new_thread = ChatThread.last + expect { move! }.to change { Chat::Thread.count }.by(1) + new_thread = Chat::Thread.last expect(message4.reload.thread_id).to eq(new_thread.id) expect(message5.reload.thread_id).to eq(new_thread.id) expect(new_thread.channel).to eq(source_channel) @@ -214,9 +224,9 @@ describe Chat::MessageMover do Fabricate(:chat_thread, channel: source_channel, original_message: message4) message4.update!(thread: other_thread) message5.update!(thread: other_thread) - expect { move!([message1.id, message4.id]) }.to change { ChatThread.count }.by(2) + expect { move!([message1.id, message4.id]) }.to change { Chat::Thread.count }.by(2) - new_threads = ChatThread.order(:created_at).last(2) + new_threads = Chat::Thread.order(:created_at).last(2) expect(message3.reload.thread_id).to eq(new_threads.first.id) expect(message5.reload.thread_id).to eq(new_threads.second.id) expect(new_threads.first.channel).to eq(source_channel) diff --git a/plugins/chat/spec/lib/chat_message_processor_spec.rb b/plugins/chat/spec/lib/chat/message_processor_spec.rb similarity index 57% rename from plugins/chat/spec/lib/chat_message_processor_spec.rb rename to plugins/chat/spec/lib/chat/message_processor_spec.rb index 3fdbd19baa7..2fb51caf257 100644 --- a/plugins/chat/spec/lib/chat_message_processor_spec.rb +++ b/plugins/chat/spec/lib/chat/message_processor_spec.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -RSpec.describe Chat::ChatMessageProcessor do +RSpec.describe Chat::MessageProcessor do fab!(:message) { Fabricate(:chat_message) } it "cooks using the last_editor_id of the message" do - ChatMessage.expects(:cook).with(message.message, user_id: message.last_editor_id) + Chat::Message.expects(:cook).with(message.message, user_id: message.last_editor_id) described_class.new(message) end end diff --git a/plugins/chat/spec/lib/chat_message_reactor_spec.rb b/plugins/chat/spec/lib/chat/message_reactor_spec.rb similarity index 78% rename from plugins/chat/spec/lib/chat_message_reactor_spec.rb rename to plugins/chat/spec/lib/chat/message_reactor_spec.rb index 565fab80db1..c6715c3ad16 100644 --- a/plugins/chat/spec/lib/chat_message_reactor_spec.rb +++ b/plugins/chat/spec/lib/chat/message_reactor_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatMessageReactor do +describe Chat::MessageReactor do fab!(:reacting_user) { Fabricate(:user) } fab!(:channel) { Fabricate(:category_channel) } fab!(:reactor) { described_class.new(reacting_user, channel) } @@ -29,14 +29,14 @@ describe Chat::ChatMessageReactor do end it "raises an error if the channel status is not open" do - channel.update!(status: ChatChannel.statuses[:archived]) + channel.update!(status: Chat::Channel.statuses[:archived]) expect { subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") }.to raise_error(Discourse::InvalidAccess) - channel.update!(status: ChatChannel.statuses[:open]) + channel.update!(status: Chat::Channel.statuses[:open]) expect { subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") - }.to change(ChatMessageReaction, :count).by(1) + }.to change(Chat::MessageReaction, :count).by(1) end it "raises an error if the reaction is not valid" do @@ -59,9 +59,9 @@ describe Chat::ChatMessageReactor do context "when max reactions has been reached" do before do - emojis = Emoji.all.slice(0, Chat::ChatMessageReactor::MAX_REACTIONS_LIMIT) + emojis = Emoji.all.slice(0, described_class::MAX_REACTIONS_LIMIT) emojis.each do |emoji| - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message_1, user: reacting_user, emoji: ":#{emoji.name}:", @@ -93,47 +93,51 @@ describe Chat::ChatMessageReactor do it "creates a membership when not present" do expect { reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") - }.to change(UserChatChannelMembership, :count).by(1) + }.to change(Chat::UserChatChannelMembership, :count).by(1) end it "doesn’t create a membership when present" do - UserChatChannelMembership.create!(user: reacting_user, chat_channel: channel, following: true) + Chat::UserChatChannelMembership.create!( + user: reacting_user, + chat_channel: channel, + following: true, + ) expect { reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") - }.not_to change(UserChatChannelMembership, :count) + }.not_to change(Chat::UserChatChannelMembership, :count) end it "can add a reaction" do expect { reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") - }.to change(ChatMessageReaction, :count).by(1) + }.to change(Chat::MessageReaction, :count).by(1) end it "doesn’t duplicate reactions" do - ChatMessageReaction.create!(chat_message: message_1, user: reacting_user, emoji: ":heart:") + Chat::MessageReaction.create!(chat_message: message_1, user: reacting_user, emoji: ":heart:") expect { reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") - }.not_to change(ChatMessageReaction, :count) + }.not_to change(Chat::MessageReaction, :count) end it "can remove an existing reaction" do - ChatMessageReaction.create!(chat_message: message_1, user: reacting_user, emoji: ":heart:") + Chat::MessageReaction.create!(chat_message: message_1, user: reacting_user, emoji: ":heart:") expect { reactor.react!(message_id: message_1.id, react_action: :remove, emoji: ":heart:") - }.to change(ChatMessageReaction, :count).by(-1) + }.to change(Chat::MessageReaction, :count).by(-1) end it "does nothing when removing if no reaction found" do expect { reactor.react!(message_id: message_1.id, react_action: :remove, emoji: ":heart:") - }.not_to change(ChatMessageReaction, :count) + }.not_to change(Chat::MessageReaction, :count) end it "publishes the reaction" do - ChatPublisher.expects(:publish_reaction!).once + Chat::Publisher.expects(:publish_reaction!).once reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") end diff --git a/plugins/chat/spec/lib/chat_notifier_spec.rb b/plugins/chat/spec/lib/chat/notifier_spec.rb similarity index 99% rename from plugins/chat/spec/lib/chat_notifier_spec.rb rename to plugins/chat/spec/lib/chat/notifier_spec.rb index 75b008a6209..f9ff6d7e1a2 100644 --- a/plugins/chat/spec/lib/chat_notifier_spec.rb +++ b/plugins/chat/spec/lib/chat/notifier_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatNotifier do +describe Chat::Notifier do describe "#notify_new" do fab!(:channel) { Fabricate(:category_channel) } fab!(:user_1) { Fabricate(:user) } @@ -23,7 +23,7 @@ describe Chat::ChatNotifier do end def build_cooked_msg(message_body, user, chat_channel: channel) - ChatMessage.create( + Chat::Message.create( chat_channel: chat_channel, user: user, message: message_body, @@ -233,7 +233,7 @@ describe Chat::ChatNotifier do Fabricate(:muted_user, user: user_2, muted_user: user_1) msg = build_cooked_msg("hey @#{user_2.username} stop muting me!", user_1) - ChatPublisher.expects(:publish_new_mention).never + Chat::Publisher.expects(:publish_new_mention).never to_notify = described_class.new(msg, msg.created_at).notify_new end diff --git a/plugins/chat/spec/lib/post_notification_handler_spec.rb b/plugins/chat/spec/lib/chat/post_notification_handler_spec.rb similarity index 94% rename from plugins/chat/spec/lib/post_notification_handler_spec.rb rename to plugins/chat/spec/lib/chat/post_notification_handler_spec.rb index 620fe991e0c..4d7488bc4b7 100644 --- a/plugins/chat/spec/lib/post_notification_handler_spec.rb +++ b/plugins/chat/spec/lib/chat/post_notification_handler_spec.rb @@ -6,7 +6,7 @@ describe Chat::PostNotificationHandler do let(:acting_user) { Fabricate(:user) } let(:post) { Fabricate(:post) } let(:notified_users) { [] } - let(:subject) { Chat::PostNotificationHandler.new(post, notified_users) } + let(:subject) { described_class.new(post, notified_users) } fab!(:channel) { Fabricate(:category_channel) } fab!(:message1) do @@ -30,7 +30,7 @@ describe Chat::PostNotificationHandler do def update_post_with_chat_quote(messages) quote_markdown = - ChatTranscriptService.new(channel, acting_user, messages_or_ids: messages).generate_markdown + Chat::TranscriptService.new(channel, acting_user, messages_or_ids: messages).generate_markdown post.update!(raw: post.raw + "\n\n" + quote_markdown) end diff --git a/plugins/chat/spec/lib/chat_review_queue_spec.rb b/plugins/chat/spec/lib/chat/review_queue_spec.rb similarity index 94% rename from plugins/chat/spec/lib/chat_review_queue_spec.rb rename to plugins/chat/spec/lib/chat/review_queue_spec.rb index 5559543c52a..672b7dff64d 100644 --- a/plugins/chat/spec/lib/chat_review_queue_spec.rb +++ b/plugins/chat/spec/lib/chat/review_queue_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatReviewQueue do +describe Chat::ReviewQueue do fab!(:message_poster) { Fabricate(:user) } fab!(:flagger) { Fabricate(:user) } fab!(:chat_channel) { Fabricate(:category_channel) } @@ -32,7 +32,7 @@ describe Chat::ChatReviewQueue do it "stores the message cooked content inside the reviewable" do queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.last expect(reviewable.payload["message_cooked"]).to eq(message.cooked) end @@ -73,7 +73,7 @@ describe Chat::ChatReviewQueue do queue.flag_message(message, admin_guardian, ReviewableScore.types[:off_topic]) expect(second_flag_result).to include success: true - reviewable = ReviewableChatMessage.find_by(target: message) + reviewable = Chat::ReviewableMessage.find_by(target: message) scores = reviewable.reviewable_scores expect(scores.size).to eq(2) @@ -99,7 +99,7 @@ describe Chat::ChatReviewQueue do before do queue.flag_message(message, guardian, ReviewableScore.types[:spam]) - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.last reviewable.perform(admin, :ignore) end @@ -109,14 +109,14 @@ describe Chat::ChatReviewQueue do end it "allows the user to re-flag after the cooldown period" do - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.last reviewable.update!(updated_at: (SiteSetting.cooldown_hours_until_reflag.to_i + 1).hours.ago) expect(second_flag_result).to include success: true end it "ignores the cooldown window when the message is edited" do - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: Guardian.new(message.user), chat_message: message, new_content: "I'm editing this message. Please flag it.", @@ -157,7 +157,7 @@ describe Chat::ChatReviewQueue do .map(&:data) flag_msg = messages.detect { |m| m["type"] == "flag" } - new_reviewable = ReviewableChatMessage.find_by(target: message) + new_reviewable = Chat::ReviewableMessage.find_by(target: message) expect(flag_msg["chat_message_id"]).to eq(message.id) expect(flag_msg["reviewable_id"]).to eq(new_reviewable.id) @@ -261,7 +261,7 @@ describe Chat::ChatReviewQueue do take_action: true, ) - reviewable = ReviewableChatMessage.find_by(target: message) + reviewable = Chat::ReviewableMessage.find_by(target: message) expect(reviewable.approved?).to eq(true) expect(message.reload.trashed?).to eq(true) @@ -288,7 +288,8 @@ describe Chat::ChatReviewQueue do it "agrees with other flags on the same message" do queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) - reviewable = ReviewableChatMessage.includes(:reviewable_scores).find_by(target: message) + reviewable = + Chat::ReviewableMessage.includes(:reviewable_scores).find_by(target_id: message) scores = reviewable.reviewable_scores expect(scores.size).to eq(1) @@ -323,7 +324,8 @@ describe Chat::ChatReviewQueue do queue_for_review: true, ) - reviewable = ReviewableChatMessage.includes(:reviewable_scores).find_by(target: message) + reviewable = + Chat::ReviewableMessage.includes(:reviewable_scores).find_by(target_id: message) score = reviewable.reviewable_scores.first expect(score.reason).to eq("chat_message_queued_by_staff") @@ -397,8 +399,7 @@ describe Chat::ChatReviewQueue do it "includes a transcript of the previous 10 message for the rest of the flags" do queue.flag_message(dm_message_12, guardian, ReviewableScore.types[:off_topic]) - - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.last expect(reviewable.target).to eq(dm_message_12) transcript_post = Post.find_by(topic_id: reviewable.payload["transcript_topic_id"]) @@ -410,7 +411,7 @@ describe Chat::ChatReviewQueue do it "doesn't include a transcript if there a no previous messages" do queue.flag_message(dm_message_1, guardian, ReviewableScore.types[:off_topic]) - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.last expect(reviewable.payload["transcript_topic_id"]).to be_nil end @@ -423,7 +424,7 @@ describe Chat::ChatReviewQueue do queue.flag_message(dm_message_12, guardian, ReviewableScore.types[:off_topic]) - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.last transcript_topic = Topic.find(reviewable.payload["transcript_topic_id"]) expect(guardian.can_see_topic?(transcript_topic)).to eq(false) diff --git a/plugins/chat/spec/lib/slack_compatibility_spec.rb b/plugins/chat/spec/lib/chat/slack_compatibility_spec.rb similarity index 100% rename from plugins/chat/spec/lib/slack_compatibility_spec.rb rename to plugins/chat/spec/lib/chat/slack_compatibility_spec.rb diff --git a/plugins/chat/spec/lib/chat_statistics_spec.rb b/plugins/chat/spec/lib/chat/statistics_spec.rb similarity index 100% rename from plugins/chat/spec/lib/chat_statistics_spec.rb rename to plugins/chat/spec/lib/chat/statistics_spec.rb diff --git a/plugins/chat/spec/lib/steps_inspector_spec.rb b/plugins/chat/spec/lib/chat/steps_inspector_spec.rb similarity index 86% rename from plugins/chat/spec/lib/steps_inspector_spec.rb rename to plugins/chat/spec/lib/chat/steps_inspector_spec.rb index 4fa9fa5fcb2..c452d8433d1 100644 --- a/plugins/chat/spec/lib/steps_inspector_spec.rb +++ b/plugins/chat/spec/lib/chat/steps_inspector_spec.rb @@ -2,7 +2,7 @@ RSpec.describe Chat::StepsInspector do class DummyService - include Chat::Service::Base + include Service::Base model :model policy :policy @@ -34,7 +34,7 @@ RSpec.describe Chat::StepsInspector do end describe "#inspect" do - subject(:output) { inspector.inspect } + subject(:output) { inspector.inspect.strip } context "when service runs without error" do it "outputs all the steps of the service" do @@ -62,12 +62,12 @@ RSpec.describe Chat::StepsInspector do it "shows the failing step" do expect(output).to eq <<~OUTPUT.chomp [1/7] [model] 'model' ❌ - [2/7] [policy] 'policy' - [3/7] [contract] 'default' + [2/7] [policy] 'policy' + [3/7] [contract] 'default' [4/7] [transaction] - [5/7] [step] 'in_transaction_step_1' - [6/7] [step] 'in_transaction_step_2' - [7/7] [step] 'final_step' + [5/7] [step] 'in_transaction_step_1' + [6/7] [step] 'in_transaction_step_2' + [7/7] [step] 'final_step' OUTPUT end end @@ -85,11 +85,11 @@ RSpec.describe Chat::StepsInspector do expect(output).to eq <<~OUTPUT.chomp [1/7] [model] 'model' ✅ [2/7] [policy] 'policy' ❌ - [3/7] [contract] 'default' + [3/7] [contract] 'default' [4/7] [transaction] - [5/7] [step] 'in_transaction_step_1' - [6/7] [step] 'in_transaction_step_2' - [7/7] [step] 'final_step' + [5/7] [step] 'in_transaction_step_1' + [6/7] [step] 'in_transaction_step_2' + [7/7] [step] 'final_step' OUTPUT end end @@ -103,9 +103,9 @@ RSpec.describe Chat::StepsInspector do [2/7] [policy] 'policy' ✅ [3/7] [contract] 'default' ❌ [4/7] [transaction] - [5/7] [step] 'in_transaction_step_1' - [6/7] [step] 'in_transaction_step_2' - [7/7] [step] 'final_step' + [5/7] [step] 'in_transaction_step_1' + [6/7] [step] 'in_transaction_step_2' + [7/7] [step] 'final_step' OUTPUT end end @@ -127,7 +127,7 @@ RSpec.describe Chat::StepsInspector do [4/7] [transaction] [5/7] [step] 'in_transaction_step_1' ✅ [6/7] [step] 'in_transaction_step_2' ❌ - [7/7] [step] 'final_step' + [7/7] [step] 'final_step' OUTPUT end end @@ -163,11 +163,11 @@ RSpec.describe Chat::StepsInspector do expect(output).to eq <<~OUTPUT.chomp [1/7] [model] 'model' ✅ [2/7] [policy] 'policy' ❌ ⚠️ <= expected to return true but got false instead - [3/7] [contract] 'default' + [3/7] [contract] 'default' [4/7] [transaction] - [5/7] [step] 'in_transaction_step_1' - [6/7] [step] 'in_transaction_step_2' - [7/7] [step] 'final_step' + [5/7] [step] 'in_transaction_step_1' + [6/7] [step] 'in_transaction_step_2' + [7/7] [step] 'final_step' OUTPUT end end diff --git a/plugins/chat/spec/lib/chat_transcript_service_spec.rb b/plugins/chat/spec/lib/chat/transcript_service_spec.rb similarity index 97% rename from plugins/chat/spec/lib/chat_transcript_service_spec.rb rename to plugins/chat/spec/lib/chat/transcript_service_spec.rb index c9bf1b832e1..c99809cf108 100644 --- a/plugins/chat/spec/lib/chat_transcript_service_spec.rb +++ b/plugins/chat/spec/lib/chat/transcript_service_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe ChatTranscriptService do +describe Chat::TranscriptService do let(:acting_user) { Fabricate(:user) } let(:user1) { Fabricate(:user, username: "martinchat") } let(:user2) { Fabricate(:user, username: "brucechat") } @@ -206,27 +206,27 @@ describe ChatTranscriptService do message3 = Fabricate(:chat_message, user: user2, chat_channel: channel, message: "a new perspective") - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message, user: Fabricate(:user, username: "bjorn"), emoji: "heart", ) - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message, user: Fabricate(:user, username: "sigurd"), emoji: "heart", ) - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message, user: Fabricate(:user, username: "hvitserk"), emoji: "+1", ) - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message2, user: Fabricate(:user, username: "ubbe"), emoji: "money_mouth_face", ) - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message3, user: Fabricate(:user, username: "ivar"), emoji: "sob", diff --git a/plugins/chat/spec/lib/service_runner_spec.rb b/plugins/chat/spec/lib/service_runner_spec.rb index 4c3fd28d813..82ca9d87c62 100644 --- a/plugins/chat/spec/lib/service_runner_spec.rb +++ b/plugins/chat/spec/lib/service_runner_spec.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true -RSpec.describe Chat::ServiceRunner do +RSpec.describe ServiceRunner do class SuccessService - include Chat::Service::Base + include Service::Base end class FailureService - include Chat::Service::Base + include Service::Base step :fail_step @@ -16,7 +16,7 @@ RSpec.describe Chat::ServiceRunner do end class FailedPolicyService - include Chat::Service::Base + include Service::Base policy :test @@ -26,7 +26,7 @@ RSpec.describe Chat::ServiceRunner do end class SuccessPolicyService - include Chat::Service::Base + include Service::Base policy :test @@ -36,7 +36,7 @@ RSpec.describe Chat::ServiceRunner do end class FailedContractService - include Chat::Service::Base + include Service::Base class Contract attribute :test @@ -47,13 +47,13 @@ RSpec.describe Chat::ServiceRunner do end class SuccessContractService - include Chat::Service::Base + include Service::Base contract end class FailureWithModelService - include Chat::Service::Base + include Service::Base model :fake_model, :fetch_fake_model @@ -65,7 +65,7 @@ RSpec.describe Chat::ServiceRunner do end class SuccessWithModelService - include Chat::Service::Base + include Service::Base model :fake_model, :fetch_fake_model @@ -77,7 +77,7 @@ RSpec.describe Chat::ServiceRunner do end class FailureWithCollectionModelService - include Chat::Service::Base + include Service::Base model :fake_model, :fetch_fake_model @@ -89,7 +89,7 @@ RSpec.describe Chat::ServiceRunner do end class SuccessWithCollectionModelService - include Chat::Service::Base + include Service::Base model :fake_model, :fetch_fake_model @@ -109,7 +109,7 @@ RSpec.describe Chat::ServiceRunner do let(:actions) { "proc {}" } let(:object) do Class - .new(Chat::Api) do + .new(Chat::ApiController) do def request OpenStruct.new end @@ -126,7 +126,7 @@ RSpec.describe Chat::ServiceRunner do it "runs the provided service in the context of a controller" do runner - expect(result).to be_a Chat::Service::Base::Context + expect(result).to be_a Service::Base::Context expect(result).to be_a_success end diff --git a/plugins/chat/spec/mailers/user_notifications_spec.rb b/plugins/chat/spec/mailers/user_notifications_spec.rb index 19b980f84bb..12fcfbec3a2 100644 --- a/plugins/chat/spec/mailers/user_notifications_spec.rb +++ b/plugins/chat/spec/mailers/user_notifications_spec.rb @@ -54,7 +54,10 @@ describe UserNotifications do user: another_participant, chat_channel: channel, ) - DirectMessageUser.create!(direct_message: channel.chatable, user: another_participant) + Chat::DirectMessageUser.create!( + direct_message: channel.chatable, + user: another_participant, + ) expected_subject = I18n.t( "user_notifications.chat_summary.subject.direct_message_from_1", @@ -168,7 +171,7 @@ describe UserNotifications do # Sometimes it's not enough to just fabricate a message # and we have to create it like here. In this case all the necessary # db records for mentions and notifications will be created under the hood. - Chat::ChatMessageCreator.create(chat_channel: channel, user: sender, content: content) + Chat::MessageCreator.create(chat_channel: channel, user: sender, content: content) end it "returns email for @all mention by default" do diff --git a/plugins/chat/spec/models/category_spec.rb b/plugins/chat/spec/models/category_spec.rb index bf16ff3e618..d02d3036524 100644 --- a/plugins/chat/spec/models/category_spec.rb +++ b/plugins/chat/spec/models/category_spec.rb @@ -5,7 +5,7 @@ require "rails_helper" RSpec.describe Category do it_behaves_like "a chatable model" do fab!(:chatable) { Fabricate(:category) } - let(:channel_class) { CategoryChannel } + let(:channel_class) { Chat::CategoryChannel } end it { is_expected.to have_one(:category_channel).dependent(:destroy) } diff --git a/plugins/chat/spec/models/category_channel_spec.rb b/plugins/chat/spec/models/chat/category_channel_spec.rb similarity index 99% rename from plugins/chat/spec/models/category_channel_spec.rb rename to plugins/chat/spec/models/chat/category_channel_spec.rb index e78681eb8bc..1408d18829d 100644 --- a/plugins/chat/spec/models/category_channel_spec.rb +++ b/plugins/chat/spec/models/chat/category_channel_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe CategoryChannel do +RSpec.describe Chat::CategoryChannel do subject(:channel) { Fabricate.build(:category_channel) } it_behaves_like "a chat channel model" diff --git a/plugins/chat/spec/models/chat_channel_spec.rb b/plugins/chat/spec/models/chat/channel_spec.rb similarity index 98% rename from plugins/chat/spec/models/chat_channel_spec.rb rename to plugins/chat/spec/models/chat/channel_spec.rb index 8cb1a96d587..efd0a835df7 100644 --- a/plugins/chat/spec/models/chat_channel_spec.rb +++ b/plugins/chat/spec/models/chat/channel_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe ChatChannel do +RSpec.describe Chat::Channel do fab!(:category_channel1) { Fabricate(:category_channel) } fab!(:dm_channel1) { Fabricate(:direct_message_channel) } diff --git a/plugins/chat/spec/models/deleted_chat_user_spec.rb b/plugins/chat/spec/models/chat/deleted_chat_user_spec.rb similarity index 92% rename from plugins/chat/spec/models/deleted_chat_user_spec.rb rename to plugins/chat/spec/models/chat/deleted_chat_user_spec.rb index 387eb5c89f9..3d3284728e9 100644 --- a/plugins/chat/spec/models/deleted_chat_user_spec.rb +++ b/plugins/chat/spec/models/chat/deleted_chat_user_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe DeletedChatUser do +describe Chat::DeletedUser do describe "#username" do it "returns a default username" do expect(subject.username).to eq(I18n.t("chat.deleted_chat_username")) diff --git a/plugins/chat/spec/models/direct_message_channel_spec.rb b/plugins/chat/spec/models/chat/direct_message_channel_spec.rb similarity index 97% rename from plugins/chat/spec/models/direct_message_channel_spec.rb rename to plugins/chat/spec/models/chat/direct_message_channel_spec.rb index 227a143d60d..0c45370f374 100644 --- a/plugins/chat/spec/models/direct_message_channel_spec.rb +++ b/plugins/chat/spec/models/chat/direct_message_channel_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe DirectMessageChannel do +RSpec.describe Chat::DirectMessageChannel do subject(:channel) { Fabricate.build(:direct_message_channel) } it_behaves_like "a chat channel model" diff --git a/plugins/chat/spec/models/direct_message_spec.rb b/plugins/chat/spec/models/chat/direct_message_spec.rb similarity index 96% rename from plugins/chat/spec/models/direct_message_spec.rb rename to plugins/chat/spec/models/chat/direct_message_spec.rb index 8e8443cf90d..9d526f3bb1c 100644 --- a/plugins/chat/spec/models/direct_message_spec.rb +++ b/plugins/chat/spec/models/chat/direct_message_spec.rb @@ -2,14 +2,14 @@ require "rails_helper" -describe DirectMessage do +describe Chat::DirectMessage do fab!(:user1) { Fabricate(:user, username: "chatdmfellow1") } fab!(:user2) { Fabricate(:user, username: "chatdmuser") } fab!(:chat_channel) { Fabricate(:direct_message_channel) } it_behaves_like "a chatable model" do fab!(:chatable) { Fabricate(:direct_message) } - let(:channel_class) { DirectMessageChannel } + let(:channel_class) { Chat::DirectMessageChannel } end describe "#chat_channel_title_for_user" do diff --git a/plugins/chat/spec/models/chat_draft_spec.rb b/plugins/chat/spec/models/chat/draft_spec.rb similarity index 94% rename from plugins/chat/spec/models/chat_draft_spec.rb rename to plugins/chat/spec/models/chat/draft_spec.rb index 27794bafaec..4694c1ff7e8 100644 --- a/plugins/chat/spec/models/chat_draft_spec.rb +++ b/plugins/chat/spec/models/chat/draft_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe ChatDraft do +RSpec.describe Chat::Draft do before { SiteSetting.max_chat_draft_length = 100 } it "errors when data.value is greater than `max_chat_draft_length`" do diff --git a/plugins/chat/spec/models/chat_message_spec.rb b/plugins/chat/spec/models/chat/message_spec.rb similarity index 91% rename from plugins/chat/spec/models/chat_message_spec.rb rename to plugins/chat/spec/models/chat/message_spec.rb index f12d2308e54..e6e50052524 100644 --- a/plugins/chat/spec/models/chat_message_spec.rb +++ b/plugins/chat/spec/models/chat/message_spec.rb @@ -2,38 +2,38 @@ require "rails_helper" -describe ChatMessage do +describe Chat::Message do fab!(:message) { Fabricate(:chat_message, message: "hey friend, what's up?!") } it { is_expected.to have_many(:chat_mentions).dependent(:destroy) } describe ".cook" do it "does not support HTML tags" do - cooked = ChatMessage.cook("

test

") + cooked = described_class.cook("

test

") expect(cooked).to eq("

<h1>test</h1>

") end it "does not support headings" do - cooked = ChatMessage.cook("## heading 2") + cooked = described_class.cook("## heading 2") expect(cooked).to eq("

## heading 2

") end it "does not support horizontal rules" do - cooked = ChatMessage.cook("---") + cooked = described_class.cook("---") expect(cooked).to eq("

---

") end it "supports backticks rule" do - cooked = ChatMessage.cook("`test`") + cooked = described_class.cook("`test`") expect(cooked).to eq("

test

") end it "supports fence rule" do - cooked = ChatMessage.cook(<<~RAW) + cooked = described_class.cook(<<~RAW) ``` something = test ``` @@ -46,7 +46,7 @@ describe ChatMessage do end it "supports fence rule with language support" do - cooked = ChatMessage.cook(<<~RAW) + cooked = described_class.cook(<<~RAW) ```ruby Widget.triangulate(argument: "no u") ``` @@ -59,13 +59,13 @@ describe ChatMessage do end it "supports code rule" do - cooked = ChatMessage.cook(" something = test") + cooked = described_class.cook(" something = test") expect(cooked).to eq("
something = test\n
") end it "supports blockquote rule" do - cooked = ChatMessage.cook("> a quote") + cooked = described_class.cook("> a quote") expect(cooked).to eq("
\n

a quote

\n
") end @@ -77,7 +77,7 @@ describe ChatMessage do avatar_src = "//test.localhost#{User.system_avatar_template(post.user.username).gsub("{size}", "40")}" - cooked = ChatMessage.cook(<<~RAW) + cooked = described_class.cook(<<~RAW) [quote="#{post.user.username}, post:#{post.post_number}, topic:#{topic.id}"] Mark me...this will go down in history. [/quote] @@ -120,8 +120,8 @@ describe ChatMessage do ) other_messages_to_quote = [msg1, msg2] cooked = - ChatMessage.cook( - ChatTranscriptService.new( + described_class.cook( + Chat::TranscriptService.new( chat_channel, Fabricate(:user), messages_or_ids: other_messages_to_quote.map(&:id), @@ -166,13 +166,13 @@ describe ChatMessage do end it "supports strikethrough rule" do - cooked = ChatMessage.cook("~~test~~") + cooked = described_class.cook("~~test~~") expect(cooked).to eq("

test

") end it "supports emphasis rule" do - cooked = ChatMessage.cook("**bold**") + cooked = described_class.cook("**bold**") expect(cooked).to eq("

bold

") end @@ -186,7 +186,7 @@ describe ChatMessage do end it "supports table markdown plugin" do - cooked = ChatMessage.cook(<<~RAW) + cooked = described_class.cook(<<~RAW) | Command | Description | | --- | --- | | git status | List all new or modified files | @@ -215,7 +215,7 @@ describe ChatMessage do end it "supports onebox markdown plugin" do - cooked = ChatMessage.cook("https://www.example.com") + cooked = described_class.cook("https://www.example.com") expect(cooked).to eq( "

https://www.example.com

", @@ -223,7 +223,7 @@ describe ChatMessage do end it "supports emoji plugin" do - cooked = ChatMessage.cook(":grin:") + cooked = described_class.cook(":grin:") expect(cooked).to eq( "

\":grin:\"

", @@ -231,7 +231,7 @@ describe ChatMessage do end it "supports mentions plugin" do - cooked = ChatMessage.cook("@mention") + cooked = described_class.cook("@mention") expect(cooked).to eq("

@mention

") end @@ -242,7 +242,7 @@ describe ChatMessage do category = Fabricate(:category) - cooked = ChatMessage.cook("##{category.slug}") + cooked = described_class.cook("##{category.slug}") expect(cooked).to eq( "

##{category.slug}

", @@ -256,7 +256,7 @@ describe ChatMessage do category = Fabricate(:category) user = Fabricate(:user) - cooked = ChatMessage.cook("##{category.slug}", user_id: user.id) + cooked = described_class.cook("##{category.slug}", user_id: user.id) expect(cooked).to eq( "

#{category.name}

", @@ -266,7 +266,7 @@ describe ChatMessage do it "supports censored plugin" do watched_word = Fabricate(:watched_word, action: WatchedWord.actions[:censor]) - cooked = ChatMessage.cook(watched_word.word) + cooked = described_class.cook(watched_word.word) expect(cooked).to eq("

■■■■■

") end @@ -293,13 +293,13 @@ describe ChatMessage do gif = Fabricate(:upload, original_filename: "cat.gif", width: 400, height: 300, extension: "gif") message = Fabricate(:chat_message, message: "") - UploadReference.create(target: message, upload: gif) + message.attach_uploads([gif]) expect(message.excerpt).to eq "cat.gif" end it "supports autolink with <>" do - cooked = ChatMessage.cook("") + cooked = described_class.cook("") expect(cooked).to eq( "

https://github.com/discourse/discourse-chat/pull/468

", @@ -307,7 +307,7 @@ describe ChatMessage do end it "supports lists" do - cooked = ChatMessage.cook(<<~MSG) + cooked = described_class.cook(<<~MSG) wow look it's a list * item 1 @@ -324,14 +324,14 @@ describe ChatMessage do end it "supports inline emoji" do - cooked = ChatMessage.cook(":D") + cooked = described_class.cook(":D") expect(cooked).to eq(<<~HTML.chomp)

:smiley:

HTML end it "supports emoji shortcuts" do - cooked = ChatMessage.cook("this is a replace test :P :|") + cooked = described_class.cook("this is a replace test :P :|") expect(cooked).to eq(<<~HTML.chomp)

this is a replace test :stuck_out_tongue: :expressionless:

HTML @@ -339,7 +339,8 @@ describe ChatMessage do it "supports spoilers" do if SiteSetting.respond_to?(:spoiler_enabled) && SiteSetting.spoiler_enabled - cooked = ChatMessage.cook("[spoiler]the planet of the apes was earth all along[/spoiler]") + cooked = + described_class.cook("[spoiler]the planet of the apes was earth all along[/spoiler]") expect(cooked).to eq( "
\n

the planet of the apes was earth all along

\n
", @@ -352,7 +353,7 @@ describe ChatMessage do it "cooks unicode mentions" do user = Fabricate(:unicode_user) - cooked = ChatMessage.cook("

@#{user.username}

") + cooked = described_class.cook("

@#{user.username}

") expect(cooked).to eq("

<h1>@#{user.username}</h1>

") end @@ -375,8 +376,7 @@ describe ChatMessage do ) image2 = Fabricate(:upload, original_filename: "meme.jpg", width: 10, height: 10, extension: "jpg") - UploadReference.create!(target: message, upload: image) - UploadReference.create!(target: message, upload: image2) + message.attach_uploads([image, image2]) expect(message.to_markdown).to eq(<<~MSG.chomp) hey friend, what's up?! @@ -388,12 +388,12 @@ describe ChatMessage do describe ".push_notification_excerpt" do it "truncates to 400 characters" do - message = ChatMessage.new(message: "Hello, World!" * 40) + message = described_class.new(message: "Hello, World!" * 40) expect(message.push_notification_excerpt.size).to eq(400) end it "encodes emojis" do - message = ChatMessage.new(message: ":grinning:") + message = described_class.new(message: ":grinning:") expect(message.push_notification_excerpt).to eq("😀") end end @@ -407,7 +407,8 @@ describe ChatMessage do it "blocks duplicate messages for the message, channel user, and message age requirements" do Fabricate(:chat_message, message: "this is duplicate", chat_channel: channel, user: user1) - message = ChatMessage.new(message: "this is duplicate", chat_channel: channel, user: user2) + message = + described_class.new(message: "this is duplicate", chat_channel: channel, user: user2) message.validate_message(has_uploads: false) expect(message.errors.full_messages).to include(I18n.t("chat.errors.duplicate_message")) end @@ -482,7 +483,7 @@ describe ChatMessage do end describe "bookmarks" do - before { register_test_bookmarkable(ChatMessageBookmarkable) } + before { register_test_bookmarkable(Chat::MessageBookmarkable) } after { DiscoursePluginRegistry.reset_register!(:bookmarkables) } @@ -539,11 +540,11 @@ describe ChatMessage do expect(chat_upload_count([upload_1, upload_2])).to eq(0) expect(upload_references.count).to eq(2) expect(upload_references.map(&:target_id).uniq).to eq([chat_message.id]) - expect(upload_references.map(&:target_type).uniq).to eq(["ChatMessage"]) + expect(upload_references.map(&:target_type).uniq).to eq([Chat::Message.sti_name]) end it "does nothing if the message record is new" do - expect { ChatMessage.new.attach_uploads([upload_1, upload_2]) }.to not_change { + expect { described_class.new.attach_uploads([upload_1, upload_2]) }.to not_change { chat_upload_count }.and not_change { UploadReference.count } end diff --git a/plugins/chat/spec/models/reviewable_chat_message_spec.rb b/plugins/chat/spec/models/chat/reviewable_chat_message_spec.rb similarity index 91% rename from plugins/chat/spec/models/reviewable_chat_message_spec.rb rename to plugins/chat/spec/models/chat/reviewable_chat_message_spec.rb index 95e7fab29a0..d7f6a951018 100644 --- a/plugins/chat/spec/models/reviewable_chat_message_spec.rb +++ b/plugins/chat/spec/models/chat/reviewable_chat_message_spec.rb @@ -2,13 +2,13 @@ require "rails_helper" -RSpec.describe ReviewableChatMessage, type: :model do +RSpec.describe Chat::ReviewableMessage, type: :model do fab!(:moderator) { Fabricate(:moderator) } fab!(:user) { Fabricate(:user) } fab!(:chat_channel) { Fabricate(:chat_channel) } fab!(:chat_message) { Fabricate(:chat_message, chat_channel: chat_channel, user: user) } fab!(:reviewable) do - Fabricate(:reviewable_chat_message, target: chat_message, created_by: moderator) + Fabricate(:chat_reviewable_message, target: chat_message, created_by: moderator) end it "agree_and_keep agrees with the flag and doesn't delete the message" do @@ -23,7 +23,7 @@ RSpec.describe ReviewableChatMessage, type: :model do reviewable.perform(moderator, :agree_and_delete) expect(reviewable).to be_approved - expect(ChatMessage.with_deleted.find_by(id: chat_message_id).deleted_at).to be_present + expect(Chat::Message.with_deleted.find_by(id: chat_message_id).deleted_at).to be_present end it "agree_and_restore agrees with the flag and restores the message" do diff --git a/plugins/chat/spec/plugin_helper.rb b/plugins/chat/spec/plugin_helper.rb index c3c52cf6a5e..e9662c7555a 100644 --- a/plugins/chat/spec/plugin_helper.rb +++ b/plugins/chat/spec/plugin_helper.rb @@ -29,7 +29,7 @@ module ChatSystemHelpers thread_id = i.zero? ? nil : last_message.thread_id last_user = last_user.present? ? (users - [last_user]).sample : users.sample creator = - Chat::ChatMessageCreator.new( + Chat::MessageCreator.new( chat_channel: channel, in_reply_to_id: in_reply_to, thread_id: thread_id, @@ -49,4 +49,9 @@ end RSpec.configure do |config| config.include ChatSystemHelpers, type: :system config.include Chat::ServiceMatchers + + config.expect_with :rspec do |c| + # Or a very large value, if you do want to truncate at some point + c.max_formatted_output_length = nil + end end diff --git a/plugins/chat/spec/plugin_spec.rb b/plugins/chat/spec/plugin_spec.rb index b90f1861e64..9d2a5d82cd0 100644 --- a/plugins/chat/spec/plugin_spec.rb +++ b/plugins/chat/spec/plugin_spec.rb @@ -17,7 +17,7 @@ describe Chat do fab!(:unused_upload) { Fabricate(:upload, user: user, created_at: 1.month.ago) } let!(:chat_message) do - Chat::ChatMessageCreator.create( + Chat::MessageCreator.create( chat_channel: chat_channel, user: user, in_reply_to_id: nil, @@ -43,7 +43,7 @@ describe Chat do fab!(:unused_upload) { Fabricate(:upload, user: user, created_at: 1.month.ago) } let!(:chat_message) do - Chat::ChatMessageCreator.create( + Chat::MessageCreator.create( chat_channel: chat_channel, user: user, in_reply_to_id: nil, @@ -53,7 +53,7 @@ describe Chat do end let!(:draft_message) do - ChatDraft.create!( + Chat::Draft.create!( user: user, chat_channel: chat_channel, data: @@ -135,7 +135,7 @@ describe Chat do fab!(:user_4) { Fabricate(:user, suspended_till: 3.weeks.from_now) } let!(:chat_message) do - Chat::ChatMessageCreator.create( + Chat::MessageCreator.create( chat_channel: chat_channel, user: user, in_reply_to_id: nil, @@ -170,7 +170,7 @@ describe Chat do user_2.user_chat_channel_memberships.create!(chat_channel: chat_channel, following: true) user_3.user_chat_channel_memberships.create!(chat_channel: chat_channel, following: true) user_4.user_chat_channel_memberships.create!(chat_channel: chat_channel, following: true) - Jobs::UpdateUserCountsForChatChannels.new.execute({}) + Jobs::Chat::UpdateUserCountsForChannels.new.execute({}) expect(Oneboxer.preview(chat_url)).to match_html <<~HTML