diff --git a/app/assets/stylesheets/common/components/buttons.scss b/app/assets/stylesheets/common/components/buttons.scss index ddbc3c8c7d1..5e0eed1aa0f 100644 --- a/app/assets/stylesheets/common/components/buttons.scss +++ b/app/assets/stylesheets/common/components/buttons.scss @@ -120,10 +120,6 @@ &.is-loading { &.btn-text { - .d-button-label { - font-size: var(--font-down-2); - } - &.btn-small { .loading-icon { font-size: var(--font-down-1); diff --git a/plugins/chat/app/controllers/chat/api/channel_messages_controller.rb b/plugins/chat/app/controllers/chat/api/channel_messages_controller.rb index f98d693acf5..c754100058c 100644 --- a/plugins/chat/app/controllers/chat/api/channel_messages_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channel_messages_controller.rb @@ -75,6 +75,7 @@ class Chat::Api::ChannelMessagesController < Chat::ApiController on_failed_policy(:no_silenced_user) { raise Discourse::InvalidAccess } on_model_not_found(:channel) { raise Discourse::NotFound } on_failed_policy(:allowed_to_join_channel) { raise Discourse::InvalidAccess } + on_failed_policy(:accept_blocks) { raise Discourse::InvalidAccess } on_model_not_found(:membership) { raise Discourse::NotFound } on_failed_policy(:ensure_reply_consistency) { raise Discourse::NotFound } on_failed_policy(:allowed_to_create_message_in_channel) do |policy| diff --git a/plugins/chat/app/controllers/chat/api/channels_messages_interactions_controller.rb b/plugins/chat/app/controllers/chat/api/channels_messages_interactions_controller.rb new file mode 100644 index 00000000000..af802b8d49d --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/channels_messages_interactions_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Chat::Api::ChannelsMessagesInteractionsController < Chat::ApiController + def create + Chat::CreateMessageInteraction.call(service_params) do + on_success do |interaction:| + render_serialized(interaction, Chat::MessageInteractionSerializer, root: "interaction") + end + on_failure { render(json: failed_json, status: 422) } + on_model_not_found(:message) { raise Discourse::NotFound } + on_model_not_found(:action) { raise Discourse::NotFound } + on_failed_contract do |contract| + render(json: failed_json.merge(errors: contract.errors.full_messages), status: 400) + end + end + end +end diff --git a/plugins/chat/app/models/chat/message.rb b/plugins/chat/app/models/chat/message.rb index 5fbd225018a..271c233f41c 100644 --- a/plugins/chat/app/models/chat/message.rb +++ b/plugins/chat/app/models/chat/message.rb @@ -19,6 +19,10 @@ module Chat belongs_to :last_editor, class_name: "User" belongs_to :thread, class_name: "Chat::Thread", optional: true, autosave: true + has_many :interactions, + class_name: "Chat::MessageInteraction", + dependent: :destroy, + foreign_key: :chat_message_id has_many :replies, class_name: "Chat::Message", foreign_key: "in_reply_to_id", @@ -91,11 +95,28 @@ module Chat before_save { ensure_last_editor_id } - validates :cooked, length: { maximum: 20_000 } - validate :validate_message + normalizes :blocks, + with: ->(blocks) do + return if !blocks + + # automatically assigns unique IDs + blocks.each do |block| + block["schema_version"] = 1 + block["block_id"] ||= SecureRandom.uuid + block["elements"].each do |element| + element["schema_version"] = 1 + element["action_id"] ||= SecureRandom.uuid if element["type"] == "button" + end + end + end def self.polymorphic_class_mapping = { "ChatMessage" => Chat::Message } + validates :cooked, length: { maximum: 20_000 } + + validates_with Chat::MessageBlocksValidator + + validate :validate_message def validate_message WatchedWordsValidator.new(attributes: [:message]).validate(self) diff --git a/plugins/chat/app/models/chat/message_interaction.rb b/plugins/chat/app/models/chat/message_interaction.rb new file mode 100644 index 00000000000..f1876da5e2f --- /dev/null +++ b/plugins/chat/app/models/chat/message_interaction.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Chat + class MessageInteraction < ActiveRecord::Base + self.table_name = "chat_message_interactions" + + belongs_to :user + belongs_to :message, class_name: "Chat::Message", foreign_key: "chat_message_id" + end +end + +# == Schema Information +# +# Table name: chat_message_interactions +# +# id :bigint not null, primary key +# user_id :bigint not null +# chat_message_id :bigint not null +# action :jsonb not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_chat_message_interactions_on_chat_message_id (chat_message_id) +# index_chat_message_interactions_on_user_id (user_id) +# diff --git a/plugins/chat/app/serializers/chat/block_serializer.rb b/plugins/chat/app/serializers/chat/block_serializer.rb new file mode 100644 index 00000000000..bc508ef9ed1 --- /dev/null +++ b/plugins/chat/app/serializers/chat/block_serializer.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Chat + class BlockSerializer < ApplicationSerializer + attributes :type, :elements + + def type + object["type"] + end + + def elements + object["elements"].map do |element| + serializer = self.class.element_serializer_for(element["type"]) + serializer.new(element, root: false).as_json + end + end + + def self.element_serializer_for(type) + case type + when "button" + Chat::Blocks::Elements::ButtonSerializer + else + raise "no serializer for #{type}" + end + end + end +end diff --git a/plugins/chat/app/serializers/chat/blocks/elements/button_serializer.rb b/plugins/chat/app/serializers/chat/blocks/elements/button_serializer.rb new file mode 100644 index 00000000000..6731330b39a --- /dev/null +++ b/plugins/chat/app/serializers/chat/blocks/elements/button_serializer.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Chat + module Blocks + module Elements + class ButtonSerializer < ApplicationSerializer + attributes :action_id, :type, :text, :style + + def action_id + object["action_id"] + end + + def type + object["type"] + end + + def style + object["style"] + end + + def text + Chat::Blocks::Elements::TextSerializer.new(object["text"], root: false).as_json + end + end + end + end +end diff --git a/plugins/chat/app/serializers/chat/blocks/elements/text_serializer.rb b/plugins/chat/app/serializers/chat/blocks/elements/text_serializer.rb new file mode 100644 index 00000000000..a94ef93a087 --- /dev/null +++ b/plugins/chat/app/serializers/chat/blocks/elements/text_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Chat + module Blocks + module Elements + class TextSerializer < ApplicationSerializer + attributes :text, :type + + def type + object["type"] + end + + def text + object["text"] + end + end + end + end +end diff --git a/plugins/chat/app/serializers/chat/message_interaction_serializer.rb b/plugins/chat/app/serializers/chat/message_interaction_serializer.rb new file mode 100644 index 00000000000..8b470905c66 --- /dev/null +++ b/plugins/chat/app/serializers/chat/message_interaction_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Chat + class MessageInteractionSerializer < ::ApplicationSerializer + attributes :user, :channel, :message, :action + + def user + { id: object.user.id, username: object.user.username } + end + + def channel + { id: object.message.chat_channel.id, title: object.message.chat_channel.title } + end + + def message + { id: object.message.id, text: object.message.message, user_id: object.message.user.id } + end + end +end diff --git a/plugins/chat/app/serializers/chat/message_serializer.rb b/plugins/chat/app/serializers/chat/message_serializer.rb index 11894de7ca5..f3e095dc1d8 100644 --- a/plugins/chat/app/serializers/chat/message_serializer.rb +++ b/plugins/chat/app/serializers/chat/message_serializer.rb @@ -27,6 +27,7 @@ module Chat reviewable_id edited thread + blocks ] ), ) @@ -163,6 +164,15 @@ module Chat user_flag_status.present? end + def blocks + ActiveModel::ArraySerializer.new( + object.blocks || [], + each_serializer: Chat::BlockSerializer, + scope:, + root: false, + ).as_json + end + def available_flags return [] if !scope.can_flag_chat_message?(object) return [] if reviewable_id.present? && user_flag_status == ReviewableScore.statuses[:pending] diff --git a/plugins/chat/app/services/chat/create_message.rb b/plugins/chat/app/services/chat/create_message.rb index 5a59872b82d..21c6ae80d77 100644 --- a/plugins/chat/app/services/chat/create_message.rb +++ b/plugins/chat/app/services/chat/create_message.rb @@ -34,6 +34,8 @@ module Chat end policy :no_silenced_user + policy :accept_blocks + params do attribute :chat_channel_id, :string attribute :in_reply_to_id, :string @@ -43,9 +45,10 @@ module Chat attribute :staged_id, :string attribute :upload_ids, :array attribute :thread_id, :string + attribute :blocks, :array validates :chat_channel_id, presence: true - validates :message, presence: true, if: -> { upload_ids.blank? } + validates :message, presence: true, if: -> { upload_ids.blank? && blocks.blank? } after_validation do next if message.blank? @@ -57,6 +60,7 @@ module Chat ) end end + model :channel step :enforce_membership model :membership @@ -85,6 +89,10 @@ module Chat private + def accept_blocks(guardian:, params:) + params[:blocks] ? guardian.user.bot? : true + end + def no_silenced_user(guardian:) !guardian.is_silenced? end @@ -154,6 +162,7 @@ module Chat cooked: ::Chat::Message.cook(params.message, user_id: guardian.user.id), cooked_version: ::Chat::Message::BAKED_VERSION, streaming: options.streaming, + blocks: params.blocks, ) end diff --git a/plugins/chat/app/services/chat/create_message_interaction.rb b/plugins/chat/app/services/chat/create_message_interaction.rb new file mode 100644 index 00000000000..983c67ebd10 --- /dev/null +++ b/plugins/chat/app/services/chat/create_message_interaction.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for creating and validating a new interaction between a user and a message. + # + # @example + # Chat::CreateMessageInteraction.call(params: { message_id: 3, action_id: "xxx" }, guardian: guardian) + # + class CreateMessageInteraction + include ::Service::Base + + # @!method self.call(guardian:, params:) + # @param [Guardian] guardian + # @param [Hash] params + # @option params [Integer] :message_id + # @option params [Integer] :action_id + # @return [Service::Base::Context] + params do + attribute :message_id, :integer + attribute :action_id, :string + + validates :action_id, presence: true + validates :message_id, presence: true + end + + model :message + policy :can_interact_with_message + model :action + + transaction do + model :interaction + step :trigger_interaction + end + + private + + def fetch_message(params:) + Chat::Message.find_by(id: params.message_id) + end + + def fetch_action(params:, message:) + message.blocks&.each do |item| + found_element = + item["elements"]&.find { |element| element["action_id"] == params.action_id } + return found_element if found_element + end + nil + end + + def can_interact_with_message(guardian:, message:) + guardian.can_preview_chat_channel?(message.chat_channel) + end + + def fetch_interaction(guardian:, message:, action:) + Chat::MessageInteraction.create(user: guardian.user, message:, action:) + end + + def trigger_interaction(interaction:) + DiscourseEvent.trigger(:chat_message_interaction, interaction) + end + end +end diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.gjs b/plugins/chat/assets/javascripts/discourse/components/chat-message.gjs index cec8341a2ae..16538badcbb 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.gjs @@ -26,6 +26,7 @@ import ChatMessageAvatar from "discourse/plugins/chat/discourse/components/chat/ import ChatMessageError from "discourse/plugins/chat/discourse/components/chat/message/error"; import ChatMessageInfo from "discourse/plugins/chat/discourse/components/chat/message/info"; import ChatMessageLeftGutter from "discourse/plugins/chat/discourse/components/chat/message/left-gutter"; +import ChatMessageBlocks from "discourse/plugins/chat/discourse/components/chat-message/blocks"; import ChatMessageActionsMobileModal from "discourse/plugins/chat/discourse/components/chat-message-actions-mobile"; import ChatMessageInReplyToIndicator from "discourse/plugins/chat/discourse/components/chat-message-in-reply-to-indicator"; import ChatMessageReaction from "discourse/plugins/chat/discourse/components/chat-message-reaction"; @@ -674,6 +675,8 @@ export default class ChatMessage extends Component { {{/if}} + + +
+
+ {{#each @definition.elements as |elementDefinition|}} +
+
+ +
+
+ {{/each}} +
+
+; + +export default Actions; diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message/blocks/block.gjs b/plugins/chat/assets/javascripts/discourse/components/chat-message/blocks/block.gjs new file mode 100644 index 00000000000..95165e41eb1 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message/blocks/block.gjs @@ -0,0 +1,24 @@ +import { default as GlimmerComponent } from "@glimmer/component"; +import Actions from "./actions"; + +export default class Block extends GlimmerComponent { + get blockForType() { + switch (this.args.definition.type) { + case "actions": + return Actions; + default: + throw new Error(`Unknown block type: ${this.args.definition.type}`); + } + } + + +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message/blocks/element.gjs b/plugins/chat/assets/javascripts/discourse/components/chat-message/blocks/element.gjs new file mode 100644 index 00000000000..da88e987d01 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message/blocks/element.gjs @@ -0,0 +1,20 @@ +import Component from "@glimmer/component"; +import Button from "./elements/button"; + +export default class Element extends Component { + get elementForType() { + switch (this.args.definition.type) { + case "button": + return Button; + default: + throw new Error(`Unknown element type: ${this.args.definition.type}`); + } + } + + +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message/blocks/elements/button.gjs b/plugins/chat/assets/javascripts/discourse/components/chat-message/blocks/elements/button.gjs new file mode 100644 index 00000000000..cf2a8bb4895 --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message/blocks/elements/button.gjs @@ -0,0 +1,34 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { concat } from "@ember/helper"; +import { action } from "@ember/object"; +import DButton from "discourse/components/d-button"; +import concatClass from "discourse/helpers/concat-class"; +import replaceEmoji from "discourse/helpers/replace-emoji"; + +export default class Button extends Component { + @tracked interacting = false; + + @action + async createInteraction() { + this.interacting = true; + try { + await this.args.createInteraction(this.args.definition.action_id); + } finally { + this.interacting = false; + } + } + + +} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message/blocks/index.gjs b/plugins/chat/assets/javascripts/discourse/components/chat-message/blocks/index.gjs new file mode 100644 index 00000000000..c2cc672668d --- /dev/null +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message/blocks/index.gjs @@ -0,0 +1,40 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import Block from "./block"; + +export default class Blocks extends Component { + @service appEvents; + @service chatApi; + + @action + async createInteraction(id) { + try { + const result = await this.chatApi.createInteraction( + this.args.message.channel.id, + this.args.message.id, + { action_id: id } + ); + + this.appEvents.trigger("chat:message_interaction", result.interaction); + } catch (e) { + popupAjaxError(e); + } + } + + +} diff --git a/plugins/chat/assets/javascripts/discourse/models/chat-message.js b/plugins/chat/assets/javascripts/discourse/models/chat-message.js index 649afefa97b..597a7359332 100644 --- a/plugins/chat/assets/javascripts/discourse/models/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/models/chat-message.js @@ -95,6 +95,7 @@ export default class ChatMessage { this.user = this.#initUserModel(args.user); this.bookmark = args.bookmark ? Bookmark.create(args.bookmark) : null; this.mentionedUsers = this.#initMentionedUsers(args.mentioned_users); + this.blocks = args.blocks; if (args.thread) { this.thread = args.thread; diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-api.js b/plugins/chat/assets/javascripts/discourse/services/chat-api.js index 9c906f0688c..7b66576754e 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-api.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-api.js @@ -271,6 +271,21 @@ export default class ChatApi extends Service { }); } + /** + * Creates a message interaction. + * @param {number} channelId - The ID of the channel. + * @param {number} messageId - The ID of the message. + * @param {object} data - Params of the intereaction. + * @param {string} data.action_id - The ID of the action. + * @returns {Promise} + */ + createInteraction(channelId, messageId, data = {}) { + return this.#postRequest( + `/channels/${channelId}/messages/${messageId}/interactions`, + data + ); + } + /** * Updates the status of a channel. * @param {number} channelId - The ID of the channel. diff --git a/plugins/chat/assets/stylesheets/common/chat-message-blocks.scss b/plugins/chat/assets/stylesheets/common/chat-message-blocks.scss new file mode 100644 index 00000000000..331eadac509 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/chat-message-blocks.scss @@ -0,0 +1,20 @@ +.chat-message__blocks { + padding-block: 0.5em; + display: flex; + flex-direction: column; + gap: 0.5em; +} + +.chat-message__block { + .block__actions { + display: flex; + gap: 0.5em; + } + + .block__button { + .emoji { + height: 18px; + width: 18px; + } + } +} diff --git a/plugins/chat/assets/stylesheets/common/index.scss b/plugins/chat/assets/stylesheets/common/index.scss index 9f9de91d375..a2ddcd1f510 100644 --- a/plugins/chat/assets/stylesheets/common/index.scss +++ b/plugins/chat/assets/stylesheets/common/index.scss @@ -16,6 +16,7 @@ @import "chat-composer-uploads"; @import "chat-composer"; @import "chat-composer-button"; +@import "chat-message-blocks"; @import "chat-drawer"; @import "chat-emoji-picker"; @import "chat-form"; diff --git a/plugins/chat/config/routes.rb b/plugins/chat/config/routes.rb index d024f8a5e4e..783592a212e 100644 --- a/plugins/chat/config/routes.rb +++ b/plugins/chat/config/routes.rb @@ -11,6 +11,8 @@ Chat::Engine.routes.draw do put "/channels/:channel_id/read" => "channels_read#update" post "/channels/:channel_id/messages/:message_id/flags" => "channels_messages_flags#create" post "/channels/:channel_id/drafts" => "channels_drafts#create" + post "/channels/:channel_id/messages/:message_id/interactions" => + "channels_messages_interactions#create" delete "/channels/:channel_id" => "channels#destroy" put "/channels/:channel_id" => "channels#update" get "/channels/:channel_id" => "channels#show" diff --git a/plugins/chat/db/migrate/20241110120303_add_blocks_to_chat_messages.rb b/plugins/chat/db/migrate/20241110120303_add_blocks_to_chat_messages.rb new file mode 100644 index 00000000000..6a33ea3659f --- /dev/null +++ b/plugins/chat/db/migrate/20241110120303_add_blocks_to_chat_messages.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddBlocksToChatMessages < ActiveRecord::Migration[7.1] + def change + add_column :chat_messages, :blocks, :jsonb, null: true, default: nil + end +end diff --git a/plugins/chat/db/migrate/20241111022618_create_chat_message_interactions.rb b/plugins/chat/db/migrate/20241111022618_create_chat_message_interactions.rb new file mode 100644 index 00000000000..72149e2d46f --- /dev/null +++ b/plugins/chat/db/migrate/20241111022618_create_chat_message_interactions.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateChatMessageInteractions < ActiveRecord::Migration[7.1] + def change + create_table :chat_message_interactions, id: :bigint do |t| + t.bigint :user_id, null: false + t.bigint :chat_message_id, null: false + t.jsonb :action, null: false + + t.timestamps + end + + add_index :chat_message_interactions, :user_id + add_index :chat_message_interactions, :chat_message_id + end +end diff --git a/plugins/chat/lib/chat/message_blocks_validator.rb b/plugins/chat/lib/chat/message_blocks_validator.rb new file mode 100644 index 00000000000..51b3ec26eae --- /dev/null +++ b/plugins/chat/lib/chat/message_blocks_validator.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Chat + class MessageBlocksValidator < ActiveModel::Validator + def validate(record) + # ensures we don't validate on read + return unless record.new_record? || record.changed? + + return if !record.blocks + + schemer = JSONSchemer.schema(Chat::Schemas::MessageBlocks) + if !schemer.valid?(record.blocks) + record.errors.add(:blocks, schemer.validate(record.blocks).map { _1.fetch("error") }) + return + end + + block_ids = Set.new + action_ids = Set.new + record.blocks.each do |block| + block_id = block["block_id"] + if block_ids.include?(block_id) + record.errors.add(:blocks, "have duplicated block_id: #{block_id}") + next + end + block_ids.add(block_id) + + block["elements"].each do |element| + action_id = element["action_id"] + next unless action_id + if action_ids.include?(action_id) + record.errors.add(:blocks, "have duplicated action_id: #{action_id}") + next + end + action_ids.add(action_id) + end + end + end + end +end diff --git a/plugins/chat/lib/chat/schemas/message_blocks.rb b/plugins/chat/lib/chat/schemas/message_blocks.rb new file mode 100644 index 00000000000..e8f0e193900 --- /dev/null +++ b/plugins/chat/lib/chat/schemas/message_blocks.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Chat + module Schemas + Text = { + type: "object", + properties: { + type: { + type: "string", + enum: ["plain_text"], + }, + text: { + type: "string", + maxLength: 75, + }, + }, + required: %w[type text], + additionalProperties: false, + } + + ButtonV1 = { + type: "object", + properties: { + action_id: { + type: "string", + maxLength: 255, + }, + schema_version: { + type: "integer", + }, + type: { + type: "string", + enum: ["button"], + }, + text: Text, + value: { + type: "string", + maxLength: 2000, + private: true, + }, + style: { + type: "string", + enum: %w[primary danger], + }, + }, + required: %w[schema_version type text], + additionalProperties: false, + } + + ActionsV1 = { + type: "object", + properties: { + type: { + type: "string", + enum: ["actions"], + }, + schema_version: { + type: "integer", + }, + block_id: { + type: "string", + maxLength: 255, + }, + elements: { + type: "array", + maxItems: 10, + items: { + oneOf: [ButtonV1], + }, + }, + }, + required: %w[schema_version type elements], + additionalProperties: false, + } + + MessageBlocks = { type: "array", maxItems: 5, items: { oneOf: [ActionsV1] } } + end +end diff --git a/plugins/chat/spec/fabricators/chat_fabricator.rb b/plugins/chat/spec/fabricators/chat_fabricator.rb index 4e1cd9d4b91..717f74558d4 100644 --- a/plugins/chat/spec/fabricators/chat_fabricator.rb +++ b/plugins/chat/spec/fabricators/chat_fabricator.rb @@ -81,7 +81,8 @@ Fabricator(:chat_message_with_service, class_name: "Chat::CreateMessage") do :in_reply_to, :thread, :upload_ids, - :incoming_chat_webhook + :incoming_chat_webhook, + :blocks initialize_with do |transients| channel = @@ -101,6 +102,7 @@ Fabricator(:chat_message_with_service, class_name: "Chat::CreateMessage") do thread_id: transients[:thread]&.id, in_reply_to_id: transients[:in_reply_to]&.id, upload_ids: transients[:upload_ids], + blocks: transients[:blocks], }, options: { process_inline: true, diff --git a/plugins/chat/spec/models/chat/message_spec.rb b/plugins/chat/spec/models/chat/message_spec.rb index b31e61722a2..a650fdaebd4 100644 --- a/plugins/chat/spec/models/chat/message_spec.rb +++ b/plugins/chat/spec/models/chat/message_spec.rb @@ -13,10 +13,124 @@ describe Chat::Message do expect(Chat::MessageCustomField.first.message.id).to eq(message.id) end + describe "normalization" do + context "when normalizing blocks" do + it "adds a schema version to the blocks" do + message.update!( + blocks: [ + { + type: "actions", + elements: [{ text: { text: "Foo", type: "plain_text" }, type: "button" }], + }, + ], + ) + + expect(message.blocks[0]["schema_version"]).to eq(1) + end + + it "adds a schema version to the elements" do + message.update!( + blocks: [ + { + type: "actions", + elements: [{ text: { text: "Foo", type: "plain_text" }, type: "button" }], + }, + ], + ) + + expect(message.blocks[0]["elements"][0]["schema_version"]).to eq(1) + end + + it "adds a block_id if not present" do + message.update!( + blocks: [ + { + type: "actions", + elements: [{ text: { text: "Foo", type: "plain_text" }, type: "button" }], + }, + ], + ) + + expect(message.blocks[0]["block_id"]).to be_present + end + + it "adds an action_id if not present" do + message.update!( + blocks: [ + { + type: "actions", + elements: [{ text: { text: "Foo", type: "plain_text" }, type: "button" }], + }, + ], + ) + + expect(message.blocks[0]["elements"][0]["action_id"]).to be_present + end + end + end + describe "validations" do subject(:message) { described_class.new(message: "") } + let(:blocks) { nil } + it { is_expected.to validate_length_of(:cooked).is_at_most(20_000) } + + context "when blocks format is invalid" do + let(:blocks) { [{ type: "actions", elements: [{ type: "buttoxn" }] }] } + + it do + is_expected.to_not allow_value(blocks).for(:blocks).with_message( + [ + "value at `/0/elements/0/type` is not one of: [\"button\"]", + "object at `/0/elements/0` is missing required properties: text", + ], + ) + end + end + + context "when action_id is duplicated" do + let(:blocks) do + [ + { + type: "actions", + elements: [ + { type: "button", text: { text: "Foo", type: "plain_text" }, action_id: "foo" }, + { type: "button", text: { text: "Foo", type: "plain_text" }, action_id: "foo" }, + ], + }, + ] + end + + it do + is_expected.to_not allow_value(blocks).for(:blocks).with_message( + "have duplicated action_id: foo", + ) + end + end + + context "when block_id is duplicated" do + let(:blocks) do + [ + { + type: "actions", + block_id: "foo", + elements: [{ type: "button", text: { text: "Foo", type: "plain_text" } }], + }, + { + type: "actions", + block_id: "foo", + elements: [{ type: "button", text: { text: "Foo", type: "plain_text" } }], + }, + ] + end + + it do + is_expected.to_not allow_value(blocks).for(:blocks).with_message( + "have duplicated block_id: foo", + ) + end + end end describe ".in_thread?" do diff --git a/plugins/chat/spec/requests/chat/api/channel_messages_controller_spec.rb b/plugins/chat/spec/requests/chat/api/channel_messages_controller_spec.rb index 25e6b3846d1..be6400487b8 100644 --- a/plugins/chat/spec/requests/chat/api/channel_messages_controller_spec.rb +++ b/plugins/chat/spec/requests/chat/api/channel_messages_controller_spec.rb @@ -75,20 +75,50 @@ RSpec.describe Chat::Api::ChannelMessagesController do end describe "#create" do + let(:blocks) { nil } + let(:message) { "test" } + let(:force_thread) { nil } + let(:in_reply_to_id) { nil } + let(:params) do + { + in_reply_to_id: in_reply_to_id, + message: message, + blocks: blocks, + force_thread: force_thread, + } + end + + before { sign_in(current_user) } + context "when force_thread param is given" do let!(:message) { Fabricate(:chat_message, chat_channel: channel) } - before { sign_in(current_user) } + let(:force_thread) { true } + let(:in_reply_to_id) { message.id } it "ignores it" do - expect { - post "/chat/#{channel.id}.json", - params: { - in_reply_to_id: message.id, - message: "test", - force_thread: true, - } - }.not_to change { Chat::Thread.where(force: true).count } + expect { post "/chat/#{channel.id}.json", params: params }.not_to change { + Chat::Thread.where(force: true).count + } + end + end + + context "when blocks is provided" do + context "when user is not a bot" do + let(:blocks) do + [ + { + type: "actions", + elements: [{ type: "button", text: { type: "plain_text", text: "Click Me" } }], + }, + ] + end + + it "raises invalid acces" do + post "/chat/#{channel.id}.json", params: params + + expect(response.status).to eq(403) + end end end end diff --git a/plugins/chat/spec/services/chat/create_message_interaction_spec.rb b/plugins/chat/spec/services/chat/create_message_interaction_spec.rb new file mode 100644 index 00000000000..b47fb6e5b01 --- /dev/null +++ b/plugins/chat/spec/services/chat/create_message_interaction_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +RSpec.describe Chat::CreateMessageInteraction do + describe described_class::Contract, type: :model do + it { is_expected.to validate_presence_of :message_id } + it { is_expected.to validate_presence_of :action_id } + end + + describe ".call" do + subject(:result) { described_class.call(params:, **dependencies) } + + fab!(:current_user) { Fabricate(:user) } + fab!(:message) do + Fabricate( + :chat_message, + user: Discourse.system_user, + blocks: [ + { + type: "actions", + elements: [ + { + action_id: "xxx", + value: "foo", + type: "button", + text: { + type: "plain_text", + text: "Click Me", + }, + }, + ], + }, + ], + ) + end + + let(:guardian) { Guardian.new(current_user) } + let(:params) { { message_id: message.id, action_id: "xxx" } } + let(:dependencies) { { guardian: } } + + context "when all steps pass" do + before { message.chat_channel.add(current_user) } + + it { is_expected.to run_successfully } + + it "creates the interaction" do + expect(result.interaction).to have_attributes( + user: current_user, + message: message, + action: message.blocks[0]["elements"][0], + ) + end + + it "triggers an event" do + events = DiscourseEvent.track_events { result } + + expect(events).to include( + event_name: :chat_message_interaction, + params: [result.interaction], + ) + end + end + + context "when user doesn't have access to the channel" do + fab!(:channel) { Fabricate(:private_category_channel) } + + before { message.update!(chat_channel: channel) } + + it { is_expected.to fail_a_policy(:can_interact_with_message) } + end + + context "when the action doesn’t exist" do + before { params[:action_id] = "yyy" } + + it { is_expected.to fail_to_find_a_model(:action) } + end + + context "when the message doesn’t exist" do + before { params[:message_id] = 0 } + + it { is_expected.to fail_to_find_a_model(:message) } + end + + context "when mandatory parameters are missing" do + before { params[:message_id] = nil } + + it { is_expected.to fail_a_contract } + 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 9e4765481f9..bb3ea888187 100644 --- a/plugins/chat/spec/services/chat/create_message_spec.rb +++ b/plugins/chat/spec/services/chat/create_message_spec.rb @@ -2,9 +2,10 @@ RSpec.describe Chat::CreateMessage do describe described_class::Contract, type: :model do - subject(:contract) { described_class.new(upload_ids: upload_ids) } + subject(:contract) { described_class.new(upload_ids: upload_ids, blocks: blocks) } let(:upload_ids) { nil } + let(:blocks) { nil } it { is_expected.to validate_presence_of :chat_channel_id } @@ -17,6 +18,12 @@ RSpec.describe Chat::CreateMessage do it { is_expected.not_to validate_presence_of :message } end + + context "when blocks are provided" do + let(:blocks) { [{ type: "actions" }] } + + it { is_expected.not_to validate_presence_of :message } + end end describe ".call" do @@ -33,6 +40,7 @@ RSpec.describe Chat::CreateMessage do let(:content) { "A new message @#{other_user.username_lower}" } let(:context_topic_id) { nil } let(:context_post_ids) { nil } + let(:blocks) { nil } let(:params) do { chat_channel_id: channel.id, @@ -40,6 +48,7 @@ RSpec.describe Chat::CreateMessage do upload_ids: [upload.id], context_topic_id: context_topic_id, context_post_ids: context_post_ids, + blocks: blocks, } end let(:options) { { enforce_membership: false, force_thread: false } } @@ -221,6 +230,48 @@ RSpec.describe Chat::CreateMessage do it { is_expected.to fail_a_policy(:no_silenced_user) } end + context "when providing blocks" do + let(:blocks) do + [ + { + type: "actions", + elements: [{ type: "button", value: "foo", text: { type: "plain_text", text: "Foo" } }], + }, + ] + end + + context "when user is not a bot" do + it { is_expected.to fail_a_policy(:accept_blocks) } + end + + context "when user is a bot" do + fab!(:user) { Discourse.system_user } + + it { is_expected.to run_successfully } + + it "saves the blocks" do + result + + expect(message.blocks[0]).to include( + "type" => "actions", + "schema_version" => 1, + "elements" => [ + { + "schema_version" => 1, + "type" => "button", + "value" => "foo", + "action_id" => an_instance_of(String), + "text" => { + "type" => "plain_text", + "text" => "Foo", + }, + }, + ], + ) + end + end + end + context "when user is not silenced" do context "when mandatory parameters are missing" do before { params[:chat_channel_id] = "" } diff --git a/plugins/chat/spec/system/chat_message_interaction_spec.rb b/plugins/chat/spec/system/chat_message_interaction_spec.rb new file mode 100644 index 00000000000..a39c8114a94 --- /dev/null +++ b/plugins/chat/spec/system/chat_message_interaction_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +RSpec.describe "Interacting with a message", type: :system do + fab!(:current_user) { Fabricate(:user) } + fab!(:channel_1) { Fabricate(:chat_channel) } + fab!(:message_1) do + Fabricate( + :chat_message, + user: Discourse.system_user, + chat_channel: channel_1, + blocks: [ + { + type: "actions", + elements: [ + { value: "foo value", type: "button", text: { type: "plain_text", text: "Click Me" } }, + ], + }, + ], + ) + end + + let(:chat_page) { PageObjects::Pages::Chat.new } + let(:chat_channel_page) { PageObjects::Pages::ChatChannel.new } + + before do + chat_system_bootstrap + channel_1.add(current_user) + sign_in(current_user) + end + + it "creates an interaction" do + action_id = nil + blk = + Proc.new do |interaction| + action_id = interaction.action["action_id"] + Chat::CreateMessage.call( + params: { + message: "#{action_id}: #{interaction.action["value"]}", + chat_channel_id: channel_1.id, + }, + guardian: current_user.guardian, + ) + end + + chat_page.visit_channel(channel_1) + + begin + DiscourseEvent.on(:chat_message_interaction, &blk) + find(".block__button").click + + try_until_success { expect(chat_channel_page.messages).to have_text(action_id) } + ensure + DiscourseEvent.off(:chat_message_interaction, &blk) + end + end +end