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! }