diff --git a/config/initializers/000-zeitwerk.rb b/config/initializers/000-zeitwerk.rb index b9b7c171a62..4664929cf77 100644 --- a/config/initializers/000-zeitwerk.rb +++ b/config/initializers/000-zeitwerk.rb @@ -44,6 +44,7 @@ Rails.autoloaders.each do |autoloader| "ssrf_detector" => "SSRFDetector", "http" => "HTTP", "gc_stat_instrumenter" => "GCStatInstrumenter", + "chat_sdk" => "ChatSDK", ) end Rails.autoloaders.main.ignore( diff --git a/plugins/chat/app/controllers/chat/api/channels_messages_streaming_controller.rb b/plugins/chat/app/controllers/chat/api/channels_messages_streaming_controller.rb new file mode 100644 index 00000000000..cbbde2c393c --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/channels_messages_streaming_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Chat::Api::ChannelsMessagesStreamingController < Chat::Api::ChannelsController + def destroy + with_service(Chat::StopMessageStreaming) do + on_model_not_found(:message) { raise Discourse::NotFound } + on_failed_policy(:can_join_channel) { raise Discourse::InvalidAccess } + on_failed_policy(:can_stop_streaming) { raise Discourse::InvalidAccess } + end + end +end diff --git a/plugins/chat/app/helpers/chat/with_service_helper.rb b/plugins/chat/app/helpers/chat/with_service_helper.rb index 7faf0aeff07..7647ced6206 100644 --- a/plugins/chat/app/helpers/chat/with_service_helper.rb +++ b/plugins/chat/app/helpers/chat/with_service_helper.rb @@ -19,7 +19,10 @@ module Chat end def run_service(service, dependencies) - @_result = service.call(params.to_unsafe_h.merge(guardian: guardian, **dependencies)) + params = self.try(:params) || ActionController::Parameters.new + + @_result = + service.call(params.to_unsafe_h.merge(guardian: self.try(:guardian) || nil, **dependencies)) end def default_actions_for_service diff --git a/plugins/chat/app/serializers/chat/message_serializer.rb b/plugins/chat/app/serializers/chat/message_serializer.rb index 75ebbe0ce96..f9ae2e69e46 100644 --- a/plugins/chat/app/serializers/chat/message_serializer.rb +++ b/plugins/chat/app/serializers/chat/message_serializer.rb @@ -12,6 +12,7 @@ module Chat deleted_by_id thread_id chat_channel_id + streaming ] attributes( *( diff --git a/plugins/chat/app/services/chat/create_message.rb b/plugins/chat/app/services/chat/create_message.rb index eeab69a0f8b..962cafb84f5 100644 --- a/plugins/chat/app/services/chat/create_message.rb +++ b/plugins/chat/app/services/chat/create_message.rb @@ -58,6 +58,8 @@ module Chat attribute :staged_id, :string attribute :upload_ids, :array attribute :thread_id, :string + attribute :streaming, :boolean, default: false + attribute :enforce_membership, :boolean, default: false attribute :incoming_chat_webhook attribute :process_inline, :boolean, default: Rails.env.test? @@ -75,12 +77,8 @@ module Chat Chat::Channel.find_by_id_or_slug(contract.chat_channel_id) end - def allowed_to_join_channel(guardian:, channel:, **) - guardian.can_join_chat_channel?(channel) - end - - def enforce_system_membership(guardian:, channel:, **) - if guardian.user&.is_system_user? + def enforce_system_membership(guardian:, channel:, contract:, **) + if guardian.user&.is_system_user? || contract.enforce_membership channel.add(guardian.user) if channel.direct_message_channel? @@ -89,6 +87,10 @@ module Chat end end + def allowed_to_join_channel(guardian:, channel:, **) + guardian.can_join_chat_channel?(channel) + end + def fetch_channel_membership(guardian:, channel:, **) Chat::ChannelMembershipManager.new(channel).find_for_user(guardian.user) end @@ -138,6 +140,7 @@ module Chat thread: thread, cooked: ::Chat::Message.cook(contract.message, user_id: guardian.user.id), cooked_version: ::Chat::Message::BAKED_VERSION, + streaming: contract.streaming, ) end diff --git a/plugins/chat/app/services/chat/list_channel_thread_messages.rb b/plugins/chat/app/services/chat/list_channel_thread_messages.rb index b592787e09f..5fedc020474 100644 --- a/plugins/chat/app/services/chat/list_channel_thread_messages.rb +++ b/plugins/chat/app/services/chat/list_channel_thread_messages.rb @@ -11,10 +11,8 @@ module Chat include Service::Base # @!method call(guardian:) - # @param [Integer] channel_id # @param [Guardian] guardian # @option optional_params [Integer] thread_id - # @option optional_params [Integer] channel_id # @return [Service::Base::Context] contract diff --git a/plugins/chat/app/services/chat/stop_message_streaming.rb b/plugins/chat/app/services/chat/stop_message_streaming.rb new file mode 100644 index 00000000000..b0322cbf21f --- /dev/null +++ b/plugins/chat/app/services/chat/stop_message_streaming.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for stopping streaming of a message. + # + # @example + # Chat::StopMessageStreaming.call(message_id: 3, guardian: guardian) + # + class StopMessageStreaming + include ::Service::Base + + # @!method call(message_id:, guardian:) + # @param [Integer] message_id + # @param [Guardian] guardian + # @return [Service::Base::Context] + contract + model :message + policy :can_join_channel + policy :can_stop_streaming + step :stop_message_streaming + step :publish_message_streaming_state + + # @!visibility private + class Contract + attribute :message_id, :integer + + validates :message_id, presence: true + end + + private + + def fetch_message(contract:, **) + ::Chat::Message.find_by(id: contract.message_id) + end + + def can_join_channel(guardian:, message:, **) + guardian.can_join_chat_channel?(message.chat_channel) + end + + def can_stop_streaming(guardian:, message:, **) + guardian.is_admin? || message.in_reply_to && message.in_reply_to.user_id == guardian.user.id + end + + def stop_message_streaming(message:, **) + message.update!(streaming: false) + end + + def publish_message_streaming_state(guardian:, message:, contract:, **) + ::Chat::Publisher.publish_edit!(message.chat_channel, message) + end + end +end diff --git a/plugins/chat/app/services/chat/update_message.rb b/plugins/chat/app/services/chat/update_message.rb index 871f17d66cb..4054a67d51c 100644 --- a/plugins/chat/app/services/chat/update_message.rb +++ b/plugins/chat/app/services/chat/update_message.rb @@ -38,6 +38,8 @@ module Chat attribute :upload_ids, :array + attribute :streaming, :boolean, default: false + attribute :process_inline, :boolean, default: Rails.env.test? end @@ -98,6 +100,8 @@ module Chat end def save_revision(message:, guardian:, **) + return false if message.streaming_before_last_save + prev_message = message.message_before_last_save || message.message_was return if !should_create_revision(message, prev_message, guardian) @@ -135,6 +139,7 @@ module Chat edit_timestamp = context.revision&.created_at&.iso8601(6) || Time.zone.now.iso8601(6) ::Chat::Publisher.publish_edit!(message.chat_channel, message) + DiscourseEvent.trigger(:chat_message_edited, message, message.chat_channel, message.user) if contract.process_inline diff --git a/plugins/chat/app/services/chat/update_thread.rb b/plugins/chat/app/services/chat/update_thread.rb index b4ccad8673e..fef6740de26 100644 --- a/plugins/chat/app/services/chat/update_thread.rb +++ b/plugins/chat/app/services/chat/update_thread.rb @@ -7,7 +7,7 @@ module Chat # Only the thread title can be updated. # # @example - # Chat::UpdateThread.call(thread_id: 88, channel_id: 2, guardian: guardian, title: "Restaurant for Saturday") + # Chat::UpdateThread.call(thread_id: 88, guardian: guardian, title: "Restaurant for Saturday") # class UpdateThread include Service::Base @@ -30,17 +30,16 @@ module Chat # @!visibility private class Contract attribute :thread_id, :integer - attribute :channel_id, :integer attribute :title, :string - validates :thread_id, :channel_id, presence: true + validates :thread_id, presence: true validates :title, length: { maximum: Chat::Thread::MAX_TITLE_LENGTH } end private def fetch_thread(contract:, **) - Chat::Thread.find_by(id: contract.thread_id, channel_id: contract.channel_id) + Chat::Thread.find_by(id: contract.thread_id) end def can_view_channel(guardian:, thread:, **) diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.gjs b/plugins/chat/assets/javascripts/discourse/components/chat-message.gjs index c9fc1014ece..166b509b5c1 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.gjs @@ -2,6 +2,7 @@ import Component from "@glimmer/component"; import { tracked } from "@glimmer/tracking"; import { getOwner } from "@ember/application"; import { Input } from "@ember/component"; +import { fn } from "@ember/helper"; import { on } from "@ember/modifier"; import { action } from "@ember/object"; import didInsert from "@ember/render-modifiers/modifiers/did-insert"; @@ -484,6 +485,19 @@ export default class ChatMessage extends Component { return this.args.context === MESSAGE_CONTEXT_THREAD; } + get shouldRenderStopMessageStreamingButton() { + return ( + this.args.message.streaming && + (this.currentUser.admin || + this.args.message.user.id === this.currentUser.id) + ); + } + + @action + stopMessageStreaming(message) { + this.chatApi.stopMessageStreaming(message.channel.id, message.id); + } + #teardownMentionedUsers() { this.args.message.mentionedUsers.forEach((user) => { user.statusManager.stopTrackingStatus(); @@ -504,6 +518,7 @@ export default class ChatMessage extends Component { "chat-message-container" (if this.pane.selectingMessages "-selectable") (if @message.highlighted "-highlighted") + (if @message.streaming "-streaming") (if (eq @message.user.id this.currentUser.id) "is-by-current-user") (if @message.staged "-staged" "-persisted") (if @message.processed "-processed" "-not-processed") @@ -607,6 +622,18 @@ export default class ChatMessage extends Component { {{/if}} + {{#if this.shouldRenderStopMessageStreamingButton}} +
+ + +
+ {{/if}} + "channel_messages#index" put "/channels/:channel_id/messages/:message_id" => "channel_messages#update" post "/channels/:channel_id/messages/moves" => "channels_messages_moves#create" + delete "/channels/:channel_id/messages/:message_id/streaming" => + "channels_messages_streaming#destroy" post "/channels/:channel_id/invites" => "channels_invites#create" post "/channels/:channel_id/archives" => "channels_archives#create" get "/channels/:channel_id/memberships" => "channels_memberships#index" diff --git a/plugins/chat/db/migrate/20240213175713_add_streaming_to_message.rb b/plugins/chat/db/migrate/20240213175713_add_streaming_to_message.rb new file mode 100644 index 00000000000..3c3ddd834ad --- /dev/null +++ b/plugins/chat/db/migrate/20240213175713_add_streaming_to_message.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddStreamingToMessage < ActiveRecord::Migration[7.0] + def change + add_column :chat_messages, :streaming, :boolean, null: false, default: false + end +end diff --git a/plugins/chat/lib/chat_sdk/channel.rb b/plugins/chat/lib/chat_sdk/channel.rb new file mode 100644 index 00000000000..ce521253ef9 --- /dev/null +++ b/plugins/chat/lib/chat_sdk/channel.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module ChatSDK + class Channel + include Chat::WithServiceHelper + + # Retrieves messages from a specified channel. + # + # @param channel_id [Integer] The ID of the chat channel from which to fetch messages. + # @param guardian [Guardian] The guardian object representing the user's permissions. + # @return [Array] An array of message objects from the specified channel. + # + # @example Fetching messages from a channel with additional parameters + # ChatSDK::Channel.messages(channel_id: 1, guardian: Guardian.new) + # + def self.messages(channel_id:, guardian:, **params) + new.messages(channel_id: channel_id, guardian: guardian, **params) + end + + def messages(channel_id:, guardian:, **params) + with_service( + Chat::ListChannelMessages, + channel_id: channel_id, + guardian: guardian, + **params, + direction: "future", + ) do + on_success { result.messages } + on_failure do + p Chat::StepsInspector.new(result) + raise "Unexpected error" + end + on_failed_policy(:can_view_channel) { raise "Guardian can't view channel" } + on_failed_policy(:target_message_exists) { raise "Target message doesn't exist" } + end + end + end +end diff --git a/plugins/chat/lib/chat_sdk/message.rb b/plugins/chat/lib/chat_sdk/message.rb new file mode 100644 index 00000000000..05639896411 --- /dev/null +++ b/plugins/chat/lib/chat_sdk/message.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module ChatSDK + class Message + include Chat::WithServiceHelper + + # Creates a new message in a chat channel. + # + # @param raw [String] The content of the message. + # @param channel_id [Integer] The ID of the chat channel. + # @param guardian [Guardian] The user's guardian object, for policy enforcement. + # @param in_reply_to_id [Integer, nil] The ID of the message this is in reply to (optional). + # @param thread_id [Integer, nil] The ID of the thread this message belongs to (optional). + # @param upload_ids [Array, nil] The IDs of any uploads associated with the message (optional). + # @param streaming [Boolean] Whether the message is part of a streaming operation (default: false). + # @param enforce_membership [Boolean] Allows to ensure the guardian will be allowed in the channel (default: false). + # @yield [helper, message] Offers a block with a helper and the message for streaming operations. + # @yieldparam helper [Helper] The helper object for streaming operations. + # @yieldparam message [Message] The newly created message object. + # @return [ChMessage] The created message object. + # + # @example Creating a simple message + # ChatSDK::Message.create(raw: "Hello, world!", channel_id: 1, guardian: Guardian.new) + # + # @example Creating a message with a block for streaming + # Message.create_with_stream(raw: "Streaming message", channel_id: 1, guardian: Guardian.new) do |helper, message| + # helper.stream(raw: "Continuation of the message") + # end + def self.create(**params, &block) + new.create(**params, &block) + end + + # Creates a new message with streaming enabled by default. + # + # This method is a convenience wrapper around `create` with `streaming: true` set by default. + # It supports all the same parameters and block usage as `create`. + # + # @see #create + def self.create_with_stream(**params, &block) + self.create(**params, streaming: true, &block) + end + + def create( + raw:, + channel_id:, + guardian:, + in_reply_to_id: nil, + thread_id: nil, + upload_ids: nil, + streaming: false, + enforce_membership: false, + &block + ) + message = + with_service( + Chat::CreateMessage, + message: raw, + guardian: guardian, + chat_channel_id: channel_id, + in_reply_to_id: in_reply_to_id, + thread_id: thread_id, + upload_ids: upload_ids, + streaming: streaming, + enforce_membership: enforce_membership, + ) do + on_model_not_found(:channel) { raise "Couldn't find channel with id: `#{channel_id}`" } + on_model_not_found(:channel_membership) do + raise "User with id: `#{guardian.user.id}` has no membership to this channel" + end + on_failed_policy(:ensure_valid_thread_for_channel) do + raise "Couldn't find thread with id: `#{thread_id}`" + end + on_failed_policy(:allowed_to_join_channel) do + raise "User with id: `#{guardian.user.id}` can't join this channel" + end + on_failed_contract { |contract| raise contract.errors.full_messages.join(", ") } + on_success { result.message_instance } + on_failure do + p Chat::StepsInspector.new(result) + raise "Unexpected error" + end + end + + if streaming && block_given? + helper = Helper.new(message) + block.call(helper, message) + end + + message + ensure + if message && streaming + message.update!(streaming: false) + ::Chat::Publisher.publish_edit!(message.chat_channel, message.reload) + end + end + end + + class Helper + include Chat::WithServiceHelper + + attr_reader :message + + def initialize(message) + @message = message + end + + def stream(raw: nil) + return false unless self.message.reload.streaming + + with_service( + Chat::UpdateMessage, + message_id: self.message.id, + message: raw ? self.message.reload.message + " " + raw : self.message.message, + guardian: self.message.user.guardian, + streaming: true, + ) do + on_failure do + p Chat::StepsInspector.new(result) + raise "Unexpected error" + end + end + + self.message + end + end +end diff --git a/plugins/chat/lib/chat_sdk/thread.rb b/plugins/chat/lib/chat_sdk/thread.rb new file mode 100644 index 00000000000..570d82a9e87 --- /dev/null +++ b/plugins/chat/lib/chat_sdk/thread.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module ChatSDK + class Thread + include Chat::WithServiceHelper + + # Updates the title of a specified chat thread. + # + # @param title [String] The new title for the chat thread. + # @param thread_id [Integer] The ID of the chat thread to be updated. + # @param guardian [Guardian] The guardian object representing the user's permissions. + # @return [Chat::Thread] The updated thread object with the new title. + # + # @example Updating the title of a chat thread + # ChatSDK::Thread.update_title(title: "New Thread Title", thread_id: 1, guardian: Guardian.new) + def self.update_title(**params) + new.update(title: params[:title], thread_id: params[:thread_id], guardian: params[:guardian]) + end + + def self.update(**params) + new.update(**params) + end + + # Retrieves messages from a specified thread. + # + # @param thread_id [Integer] The ID of the chat thread from which to fetch messages. + # @param guardian [Guardian] The guardian object representing the user's permissions. + # @return [Array] An array of message objects from the specified thread. + # + # @example Fetching messages from a thread with additional parameters + # ChatSDK::Thread.messages(thread_id: 1, guardian: Guardian.new) + # + def self.messages(thread_id:, guardian:, **params) + new.messages(thread_id: thread_id, guardian: guardian, **params) + end + + def messages(thread_id:, guardian:, **params) + with_service( + Chat::ListChannelThreadMessages, + thread_id: thread_id, + guardian: guardian, + **params, + direction: "future", + ) do + on_success { result.messages } + on_failed_policy(:can_view_thread) { raise "Guardian can't view thread" } + on_failed_policy(:target_message_exists) { raise "Target message doesn't exist" } + on_failed_policy(:ensure_thread_enabled) do + raise "Threading is not enabled for this channel" + end + on_failure do + p Chat::StepsInspector.new(result) + raise "Unexpected error" + end + end + end + + def update(**params) + with_service(Chat::UpdateThread, **params) do + on_model_not_found(:channel) do + raise "Couldn’t find channel with id: `#{params[:channel_id]}`" + end + on_model_not_found(:thread) do + raise "Couldn’t find thread with id: `#{params[:thread_id]}`" + end + on_failed_policy(:can_view_channel) { raise "Guardian can't view channel" } + on_failed_policy(:can_edit_thread) { raise "Guardian can't edit thread" } + on_failed_policy(:threading_enabled_for_channel) do + raise "Threading is not enabled for this channel" + end + on_failed_contract { |contract| raise contract.errors.full_messages.join(", ") } + on_success { result.thread_instance } + on_failure do + p Chat::StepsInspector.new(result) + raise "Unexpected error" + end + end + end + end +end diff --git a/plugins/chat/plugin.rb b/plugins/chat/plugin.rb index e3399cf8970..c79c39767af 100644 --- a/plugins/chat/plugin.rb +++ b/plugins/chat/plugin.rb @@ -24,6 +24,7 @@ register_svg_icon "clipboard" register_svg_icon "file-audio" register_svg_icon "file-video" register_svg_icon "file-image" +register_svg_icon "stop-circle" # route: /admin/plugins/chat add_admin_route "chat.admin.title", "chat" diff --git a/plugins/chat/spec/fabricators/chat_fabricator.rb b/plugins/chat/spec/fabricators/chat_fabricator.rb index d2fb91c3c6e..328a9276be0 100644 --- a/plugins/chat/spec/fabricators/chat_fabricator.rb +++ b/plugins/chat/spec/fabricators/chat_fabricator.rb @@ -216,7 +216,7 @@ Fabricator(:chat_thread, class_name: "Chat::Thread") do original_message do |attrs| Fabricate( :chat_message, - chat_channel: attrs[:channel] || Fabricate(:chat_channel), + chat_channel: attrs[:channel] || Fabricate(:chat_channel, threading_enabled: true), user: attrs[:original_message_user] || Fabricate(:user), use_service: attrs[:use_service], ) diff --git a/plugins/chat/spec/lib/chat_sdk/channel_spec.rb b/plugins/chat/spec/lib/chat_sdk/channel_spec.rb new file mode 100644 index 00000000000..fa99218ef2b --- /dev/null +++ b/plugins/chat/spec/lib/chat_sdk/channel_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatSDK::Channel do + describe ".messages" do + fab!(:channel_1) { Fabricate(:chat_channel) } + fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) } + fab!(:message_2) { Fabricate(:chat_message, chat_channel: channel_1) } + + let(:params) { { channel_id: channel_1.id, guardian: Discourse.system_user.guardian } } + + it "loads the messages" do + messages = described_class.messages(**params) + + expect(messages).to eq([message_1, message_2]) + end + + it "accepts page_size" do + messages = described_class.messages(**params, page_size: 1) + + expect(messages).to eq([message_1]) + end + + context "when guardian can't see the channel" do + fab!(:channel_1) { Fabricate(:private_category_channel) } + + it "fails" do + params[:guardian] = Fabricate(:user).guardian + + expect { described_class.messages(**params) }.to raise_error("Guardian can't view channel") + end + end + + context "when target_message doesn’t exist" do + it "fails" do + expect { described_class.messages(**params, target_message_id: -999) }.to raise_error( + "Target message doesn't exist", + ) + end + end + end +end diff --git a/plugins/chat/spec/lib/chat_sdk/message_spec.rb b/plugins/chat/spec/lib/chat_sdk/message_spec.rb new file mode 100644 index 00000000000..f8b58f4dc6b --- /dev/null +++ b/plugins/chat/spec/lib/chat_sdk/message_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatSDK::Message do + describe ".create" do + fab!(:channel_1) { Fabricate(:chat_channel) } + + let(:guardian) { Discourse.system_user.guardian } + let(:params) do + { enforce_membership: false, raw: "something", channel_id: channel_1.id, guardian: guardian } + end + + it "creates the message" do + message = described_class.create(**params) + + expect(message.message).to eq("something") + end + + context "when thread_id is present" do + fab!(:thread_1) { Fabricate(:chat_thread, channel: channel_1) } + + it "creates the message in a thread" do + message = described_class.create(**params, thread_id: thread_1.id) + + expect(message.thread_id).to eq(thread_1.id) + end + end + + context "when channel doesn’t exist" do + it "fails" do + expect { described_class.create(**params, channel_id: -999) }.to raise_error( + "Couldn't find channel with id: `-999`", + ) + end + end + + context "when user can't join channel" do + it "fails" do + params[:guardian] = Fabricate(:user).guardian + + expect { described_class.create(**params) }.to raise_error( + "User with id: `#{params[:guardian].user.id}` can't join this channel", + ) + end + end + + context "when membership is enforced" do + it "works" do + params[:enforce_membership] = true + params[:guardian] = Fabricate(:user).guardian + SiteSetting.chat_allowed_groups = [Group::AUTO_GROUPS[:everyone]] + + message = described_class.create(**params) + + expect(message.message).to eq("something") + end + end + + context "when thread doesn't exist" do + it "fails" do + expect { described_class.create(**params, thread_id: -999) }.to raise_error( + "Couldn't find thread with id: `-999`", + ) + end + end + + context "when params are invalid" do + it "fails" do + expect { described_class.create(**params, raw: nil, channel_id: nil) }.to raise_error( + "Chat channel can't be blank, Message can't be blank", + ) + end + end + end + + describe ".create_with_stream" do + fab!(:channel_1) { Fabricate(:chat_channel) } + + let(:guardian) { Discourse.system_user.guardian } + let(:params) { { raw: "something", channel_id: channel_1.id, guardian: guardian } } + + it "allows streaming" do + created_message = + described_class.create_with_stream(**params) do |helper, message| + expect(message.streaming).to eq(true) + + edit = + MessageBus + .track_publish("/chat/#{channel_1.id}") { helper.stream(raw: "test") } + .find { |m| m.data["type"] == "edit" } + + expect(edit.data["chat_message"]["message"]).to eq("something test") + end + + expect(created_message.streaming).to eq(false) + expect(created_message.message).to eq("something test") + end + end +end diff --git a/plugins/chat/spec/lib/chat_sdk/thread_spec.rb b/plugins/chat/spec/lib/chat_sdk/thread_spec.rb new file mode 100644 index 00000000000..0cf9653ab69 --- /dev/null +++ b/plugins/chat/spec/lib/chat_sdk/thread_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe ChatSDK::Thread do + describe ".update_title" do + fab!(:thread_1) { Fabricate(:chat_thread) } + + let(:params) do + { + title: "New Title", + channel_id: thread_1.channel_id, + thread_id: thread_1.id, + guardian: Discourse.system_user.guardian, + } + end + + it "changes the title" do + expect { described_class.update_title(**params) }.to change { thread_1.reload.title }.from( + thread_1.title, + ).to(params[:title]) + end + + context "when missing param" do + it "fails" do + params.delete(:thread_id) + + expect { described_class.update_title(**params) }.to raise_error("Thread can't be blank") + end + end + + context "when guardian can't see the channel" do + fab!(:thread_1) { Fabricate(:chat_thread, channel: Fabricate(:private_category_channel)) } + + it "fails" do + params[:guardian] = Fabricate(:user).guardian + + expect { described_class.update_title(**params) }.to raise_error( + "Guardian can't view channel", + ) + end + end + + context "when guardian can't edit the thread" do + it "fails" do + params[:guardian] = Fabricate(:user).guardian + + expect { described_class.update_title(**params) }.to raise_error( + "Guardian can't edit thread", + ) + end + end + + context "when the threadind is not enabled" do + before { thread_1.channel.update!(threading_enabled: false) } + + it "fails" do + expect { described_class.update_title(**params) }.to raise_error( + "Threading is not enabled for this channel", + ) + end + end + + context "when the thread doesn't exist" do + it "fails" do + params[:thread_id] = -999 + expect { described_class.update_title(**params) }.to raise_error( + "Couldn’t find thread with id: `-999`", + ) + end + end + + context "when target_message doesn’t exist" do + it "fails" do + expect { described_class.messages(**params, target_message_id: -999) }.to raise_error( + "Target message doesn't exist", + ) + end + end + end +end diff --git a/plugins/chat/spec/requests/chat/api/channels_messages_streaming_controller_spec.rb b/plugins/chat/spec/requests/chat/api/channels_messages_streaming_controller_spec.rb new file mode 100644 index 00000000000..2fe19bda7c0 --- /dev/null +++ b/plugins/chat/spec/requests/chat/api/channels_messages_streaming_controller_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Chat::Api::ChannelsMessagesStreamingController do + fab!(:channel_1) { Fabricate(:chat_channel) } + fab!(:current_user) { Fabricate(:user) } + + before do + SiteSetting.chat_enabled = true + SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] + end + + describe "#destroy" do + before { sign_in(current_user) } + + context "when chat is not enabled" do + it "returns a 404 error" do + SiteSetting.chat_enabled = false + + delete "/chat/api/channels/-/messages/-/streaming" + + expect(response.status).to eq(404) + end + end + + context "when user is not logged" do + it "returns a 404 error" do + sign_out + + delete "/chat/api/channels/-/messages/-/streaming" + + expect(response.status).to eq(404) + end + end + + context "when the message doesnt exist" do + it "returns a 404 error" do + delete "/chat/api/channels/#{channel_1.id}/messages/-999/streaming" + + expect(response.status).to eq(404) + end + end + + context "when the user can’t stop" do + fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) } + + it "returns a 403 error" do + delete "/chat/api/channels/#{channel_1.id}/messages/#{message_1.id}/streaming" + + expect(response.status).to eq(403) + end + end + + context "when the user can stop" do + fab!(:current_user) { Fabricate(:admin) } + fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1) } + + it "returns a 200" do + delete "/chat/api/channels/#{channel_1.id}/messages/#{message_1.id}/streaming" + + expect(response.status).to eq(200) + end + end + end +end diff --git a/plugins/chat/spec/services/chat/create_message_spec.rb b/plugins/chat/spec/services/chat/create_message_spec.rb index 27151cef679..bb6829d54c9 100644 --- a/plugins/chat/spec/services/chat/create_message_spec.rb +++ b/plugins/chat/spec/services/chat/create_message_spec.rb @@ -35,6 +35,7 @@ RSpec.describe Chat::CreateMessage do let(:context_post_ids) { nil } let(:params) do { + enforce_membership: false, guardian: guardian, chat_channel_id: channel.id, message: content, @@ -212,6 +213,17 @@ RSpec.describe Chat::CreateMessage do it { is_expected.to be_a_success } end + context "when membership is enforced" do + fab!(:user) { Fabricate(:user) } + + before do + SiteSetting.chat_allowed_groups = [Group::AUTO_GROUPS[:everyone]] + params[:enforce_membership] = true + end + + it { is_expected.to be_a_success } + end + context "when user can join channel" do before { user.groups << Group.find(Group::AUTO_GROUPS[:trust_level_1]) } diff --git a/plugins/chat/spec/services/chat/stop_message_streaming_spec.rb b/plugins/chat/spec/services/chat/stop_message_streaming_spec.rb new file mode 100644 index 00000000000..d8a224dfc3f --- /dev/null +++ b/plugins/chat/spec/services/chat/stop_message_streaming_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +RSpec.describe Chat::StopMessageStreaming do + describe ".call" do + subject(:result) { described_class.call(params) } + + let(:params) { { guardian: guardian } } + let(:guardian) { Guardian.new(current_user) } + + fab!(:current_user) { Fabricate(:user) } + fab!(:channel_1) { Fabricate(:chat_channel) } + + before { SiteSetting.chat_allowed_groups = [Group::AUTO_GROUPS[:everyone]] } + + context "with valid params" do + fab!(:current_user) { Fabricate(:admin) } + fab!(:message_1) { Fabricate(:chat_message, chat_channel: channel_1, streaming: true) } + + let(:params) { { guardian: guardian, channel_id: channel_1.id, message_id: message_1.id } } + + it { is_expected.to be_a_success } + + it "updates the streaming attribute to false" do + expect { result }.to change { message_1.reload.streaming }.to eq(false) + end + + it "publishes an event" do + messages = MessageBus.track_publish { result } + + expect(messages.find { |m| m.channel == "/chat/#{channel_1.id}" }.data).to include( + { "type" => "edit" }, + ) + end + end + + context "when the channel_id is not provided" do + it { is_expected.to fail_a_contract } + end + + context "when the message_id is not provided" do + let(:params) { { guardian: guardian, channel_id: channel_1.id } } + + it { is_expected.to fail_a_contract } + end + + context "when the message doesnt exist" do + let(:params) { { guardian: guardian, channel_id: channel_1.id, message_id: -999 } } + + it { is_expected.to fail_to_find_a_model(:message) } + end + + context "when the message is a reply" do + let(:params) { { guardian: guardian, channel_id: channel_1.id, message_id: reply.id } } + + context "when the OM is from current user" do + fab!(:original_message) do + Fabricate(:chat_message, chat_channel: channel_1, user: current_user) + end + fab!(:reply) do + Fabricate(:chat_message, chat_channel: channel_1, in_reply_to: original_message) + end + + it { is_expected.to be_a_success } + end + + context "when the OM is not from current user" do + fab!(:original_message) do + Fabricate(:chat_message, chat_channel: channel_1, user: Fabricate(:user)) + end + fab!(:reply) do + Fabricate(:chat_message, chat_channel: channel_1, in_reply_to: original_message) + end + + context "when current user is a regular user" do + it { is_expected.to fail_a_policy(:can_stop_streaming) } + end + + context "when current user is an admin" do + fab!(:current_user) { Fabricate(:admin) } + + it { is_expected.to be_a_success } + end + end + end + + context "when the message is not a reply" do + let(:params) { { guardian: guardian, channel_id: channel_1.id, message_id: message.id } } + + fab!(:message) { Fabricate(:chat_message, chat_channel: channel_1) } + + context "when current user is a regular user" do + it { is_expected.to fail_a_policy(:can_stop_streaming) } + end + + context "when current user is an admin" do + fab!(:current_user) { Fabricate(:admin) } + + it { is_expected.to be_a_success } + end + end + end +end diff --git a/plugins/chat/spec/services/chat/update_thread_spec.rb b/plugins/chat/spec/services/chat/update_thread_spec.rb index 2d5c121ba31..6d69765b77e 100644 --- a/plugins/chat/spec/services/chat/update_thread_spec.rb +++ b/plugins/chat/spec/services/chat/update_thread_spec.rb @@ -2,7 +2,6 @@ RSpec.describe Chat::UpdateThread do describe Chat::UpdateThread::Contract, type: :model do - it { is_expected.to validate_presence_of :channel_id } it { is_expected.to validate_presence_of :thread_id } end @@ -17,9 +16,7 @@ RSpec.describe Chat::UpdateThread do let(:guardian) { Guardian.new(current_user) } let(:title) { "some new title :D" } - let(:params) do - { guardian: guardian, thread_id: thread.id, channel_id: thread.channel_id, title: title } - end + let(:params) { { guardian: guardian, thread_id: thread.id, title: title } } context "when all steps pass" do it "sets the service result as successful" do @@ -53,12 +50,6 @@ RSpec.describe Chat::UpdateThread do it { is_expected.to fail_a_contract } end - context "when thread is not found because the channel ID differs" do - before { params[:thread_id] = other_thread.id } - - it { is_expected.to fail_to_find_a_model(:thread) } - end - context "when thread is not found" do before { thread.destroy! }