DEV: adds blocks support to chat messages (#29782)

Blocks allow BOTS to augment the capacities of a chat message. At the moment only one block is available: `actions`, accepting only one type of element: `button`.

<img width="708" alt="Screenshot 2024-11-15 at 19 14 02" src="https://github.com/user-attachments/assets/63f32a29-05b1-4f32-9edd-8d8e1007d705">

# Usage

```ruby
Chat::CreateMessage.call(
  params: {
    message: "Welcome!",
    chat_channel_id: 2,
    blocks: [
      {
         type: "actions",
         elements: [
           { value: "foo", type: "button", text: { text: "How can I install themes?", type: "plain_text" } }
         ]
      }
    ]
  },
  guardian: Discourse.system_user.guardian
)
```

# Documentation

## Blocks

### Actions

Holds interactive elements: button.

#### Fields

| Field | Type | Description | Required? |
|--------|--------|--------|--------|
| type | string | For an actions block, type is always `actions` | Yes |
| elements | array | An array of interactive elements, maximum 10 elements | Yes |
| block_id | string | An unique identifier for the block, will be generated if not specified. It has to be unique per message | No |

#### Example

```json
{
  "type": "actions",
  "block_id": "actions_1",
  "elements": [...]
}
```

## Elements

### Button

#### Fields

| Field | Type | Description | Required? |
|--------|--------|--------|--------|
| type | string | For a button, type is always `button` | Yes |
| text | object | A text object holding the type and text. Max 75 characters | Yes |
| value | string | The value returned after the interaction has been validated. Maximum length is 2000 characters | No |
| style | string | Can be `primary` ,  `success` or `danger` | No |
| action_id | string | An unique identifier for the action, will be generated if not specified. It has to be unique per message | No |

#### Example

```json
{
  "type": "actions",
  "block_id": "actions_1",
  "elements": [
    {
      "type": "button",
      "text": {
          "type": "plain_text",
          "text": "Ok"
      },
      "value": "ok",
      "action_id": "button_1"
    }
  ]
}
```

## Interactions

When a user interactions with a button the following flow will happen:

- We send an interaction request to the server
- Server checks if the user can make this interaction
- If the user can make this interaction, the server will:

  * `DiscourseEvent.trigger(:chat_message_interaction, interaction)`
  * return a JSON document
  
  ```json
  {
    "interaction": {
        "user": {
            "id": 1,
            "username": "j.jaffeux"
        },
        "channel": {
            "id": 1,
            "title": "Staff"
        },
        "message": {
            "id": 1,
            "text": "test",
            "user_id": -1
        },
        "action": {
            "text": {
                "text": "How to install themes?",
                "type": "plain_text"
            },
            "type": "button",
            "value": "click_me_123",
            "action_id": "bf4f30b9-de99-4959-b3f5-632a6a1add04"
        }
    }
  }
  ```
  * Fire a `appEvents.trigger("chat:message_interaction", interaction)`
This commit is contained in:
Joffrey JAFFEUX
2024-11-19 07:07:58 +01:00
committed by GitHub
parent 04bac33ed9
commit 582de0ffe3
33 changed files with 915 additions and 18 deletions

View File

@ -120,10 +120,6 @@
&.is-loading { &.is-loading {
&.btn-text { &.btn-text {
.d-button-label {
font-size: var(--font-down-2);
}
&.btn-small { &.btn-small {
.loading-icon { .loading-icon {
font-size: var(--font-down-1); font-size: var(--font-down-1);

View File

@ -75,6 +75,7 @@ class Chat::Api::ChannelMessagesController < Chat::ApiController
on_failed_policy(:no_silenced_user) { raise Discourse::InvalidAccess } on_failed_policy(:no_silenced_user) { raise Discourse::InvalidAccess }
on_model_not_found(:channel) { raise Discourse::NotFound } on_model_not_found(:channel) { raise Discourse::NotFound }
on_failed_policy(:allowed_to_join_channel) { raise Discourse::InvalidAccess } 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_model_not_found(:membership) { raise Discourse::NotFound }
on_failed_policy(:ensure_reply_consistency) { raise Discourse::NotFound } on_failed_policy(:ensure_reply_consistency) { raise Discourse::NotFound }
on_failed_policy(:allowed_to_create_message_in_channel) do |policy| on_failed_policy(:allowed_to_create_message_in_channel) do |policy|

View File

@ -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

View File

@ -19,6 +19,10 @@ module Chat
belongs_to :last_editor, class_name: "User" belongs_to :last_editor, class_name: "User"
belongs_to :thread, class_name: "Chat::Thread", optional: true, autosave: true 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, has_many :replies,
class_name: "Chat::Message", class_name: "Chat::Message",
foreign_key: "in_reply_to_id", foreign_key: "in_reply_to_id",
@ -91,11 +95,28 @@ module Chat
before_save { ensure_last_editor_id } before_save { ensure_last_editor_id }
validates :cooked, length: { maximum: 20_000 } normalizes :blocks,
validate :validate_message 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 } def self.polymorphic_class_mapping = { "ChatMessage" => Chat::Message }
validates :cooked, length: { maximum: 20_000 }
validates_with Chat::MessageBlocksValidator
validate :validate_message
def validate_message def validate_message
WatchedWordsValidator.new(attributes: [:message]).validate(self) WatchedWordsValidator.new(attributes: [:message]).validate(self)

View File

@ -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)
#

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -27,6 +27,7 @@ module Chat
reviewable_id reviewable_id
edited edited
thread thread
blocks
] ]
), ),
) )
@ -163,6 +164,15 @@ module Chat
user_flag_status.present? user_flag_status.present?
end end
def blocks
ActiveModel::ArraySerializer.new(
object.blocks || [],
each_serializer: Chat::BlockSerializer,
scope:,
root: false,
).as_json
end
def available_flags def available_flags
return [] if !scope.can_flag_chat_message?(object) return [] if !scope.can_flag_chat_message?(object)
return [] if reviewable_id.present? && user_flag_status == ReviewableScore.statuses[:pending] return [] if reviewable_id.present? && user_flag_status == ReviewableScore.statuses[:pending]

View File

@ -34,6 +34,8 @@ module Chat
end end
policy :no_silenced_user policy :no_silenced_user
policy :accept_blocks
params do params do
attribute :chat_channel_id, :string attribute :chat_channel_id, :string
attribute :in_reply_to_id, :string attribute :in_reply_to_id, :string
@ -43,9 +45,10 @@ module Chat
attribute :staged_id, :string attribute :staged_id, :string
attribute :upload_ids, :array attribute :upload_ids, :array
attribute :thread_id, :string attribute :thread_id, :string
attribute :blocks, :array
validates :chat_channel_id, presence: true 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 after_validation do
next if message.blank? next if message.blank?
@ -57,6 +60,7 @@ module Chat
) )
end end
end end
model :channel model :channel
step :enforce_membership step :enforce_membership
model :membership model :membership
@ -85,6 +89,10 @@ module Chat
private private
def accept_blocks(guardian:, params:)
params[:blocks] ? guardian.user.bot? : true
end
def no_silenced_user(guardian:) def no_silenced_user(guardian:)
!guardian.is_silenced? !guardian.is_silenced?
end end
@ -154,6 +162,7 @@ module Chat
cooked: ::Chat::Message.cook(params.message, user_id: guardian.user.id), cooked: ::Chat::Message.cook(params.message, user_id: guardian.user.id),
cooked_version: ::Chat::Message::BAKED_VERSION, cooked_version: ::Chat::Message::BAKED_VERSION,
streaming: options.streaming, streaming: options.streaming,
blocks: params.blocks,
) )
end end

View File

@ -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

View File

@ -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 ChatMessageError from "discourse/plugins/chat/discourse/components/chat/message/error";
import ChatMessageInfo from "discourse/plugins/chat/discourse/components/chat/message/info"; import ChatMessageInfo from "discourse/plugins/chat/discourse/components/chat/message/info";
import ChatMessageLeftGutter from "discourse/plugins/chat/discourse/components/chat/message/left-gutter"; 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 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 ChatMessageInReplyToIndicator from "discourse/plugins/chat/discourse/components/chat-message-in-reply-to-indicator";
import ChatMessageReaction from "discourse/plugins/chat/discourse/components/chat-message-reaction"; import ChatMessageReaction from "discourse/plugins/chat/discourse/components/chat-message-reaction";
@ -674,6 +675,8 @@ export default class ChatMessage extends Component {
</div> </div>
{{/if}} {{/if}}
<ChatMessageBlocks @message={{@message}} />
<ChatMessageError <ChatMessageError
@message={{@message}} @message={{@message}}
@onRetry={{@resendStagedMessage}} @onRetry={{@resendStagedMessage}}

View File

@ -0,0 +1,20 @@
import Element from "./element";
const Actions = <template>
<div class="block__actions-wrapper">
<div class="block__actions">
{{#each @definition.elements as |elementDefinition|}}
<div class="block__action-wrapper">
<div class="block__action">
<Element
@createInteraction={{@createInteraction}}
@definition={{elementDefinition}}
/>
</div>
</div>
{{/each}}
</div>
</div>
</template>;
export default Actions;

View File

@ -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}`);
}
}
<template>
<div class="chat-message__block-wrapper">
<div class="chat-message__block">
<this.blockForType
@createInteraction={{@createInteraction}}
@definition={{@definition}}
/>
</div>
</div>
</template>
}

View File

@ -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}`);
}
}
<template>
<this.elementForType
@createInteraction={{@createInteraction}}
@definition={{@definition}}
/>
</template>
}

View File

@ -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;
}
}
<template>
<DButton
@id={{@definition.action_id}}
@isLoading={{this.interacting}}
@translatedLabel={{replaceEmoji @definition.text.text}}
@action={{this.createInteraction}}
class={{concatClass
"block__button"
(if @definition.style (concat "btn-" @definition.style))
}}
/>
</template>
}

View File

@ -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);
}
}
<template>
{{#if @message.blocks}}
<div class="chat-message__blocks-wrapper">
<div class="chat-message__blocks">
{{#each @message.blocks as |blockDefinition|}}
<Block
@createInteraction={{this.createInteraction}}
@definition={{blockDefinition}}
/>
{{/each}}
</div>
</div>
{{/if}}
</template>
}

View File

@ -95,6 +95,7 @@ export default class ChatMessage {
this.user = this.#initUserModel(args.user); this.user = this.#initUserModel(args.user);
this.bookmark = args.bookmark ? Bookmark.create(args.bookmark) : null; this.bookmark = args.bookmark ? Bookmark.create(args.bookmark) : null;
this.mentionedUsers = this.#initMentionedUsers(args.mentioned_users); this.mentionedUsers = this.#initMentionedUsers(args.mentioned_users);
this.blocks = args.blocks;
if (args.thread) { if (args.thread) {
this.thread = args.thread; this.thread = args.thread;

View File

@ -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. * Updates the status of a channel.
* @param {number} channelId - The ID of the channel. * @param {number} channelId - The ID of the channel.

View File

@ -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;
}
}
}

View File

@ -16,6 +16,7 @@
@import "chat-composer-uploads"; @import "chat-composer-uploads";
@import "chat-composer"; @import "chat-composer";
@import "chat-composer-button"; @import "chat-composer-button";
@import "chat-message-blocks";
@import "chat-drawer"; @import "chat-drawer";
@import "chat-emoji-picker"; @import "chat-emoji-picker";
@import "chat-form"; @import "chat-form";

View File

@ -11,6 +11,8 @@ Chat::Engine.routes.draw do
put "/channels/:channel_id/read" => "channels_read#update" put "/channels/:channel_id/read" => "channels_read#update"
post "/channels/:channel_id/messages/:message_id/flags" => "channels_messages_flags#create" post "/channels/:channel_id/messages/:message_id/flags" => "channels_messages_flags#create"
post "/channels/:channel_id/drafts" => "channels_drafts#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" delete "/channels/:channel_id" => "channels#destroy"
put "/channels/:channel_id" => "channels#update" put "/channels/:channel_id" => "channels#update"
get "/channels/:channel_id" => "channels#show" get "/channels/:channel_id" => "channels#show"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -81,7 +81,8 @@ Fabricator(:chat_message_with_service, class_name: "Chat::CreateMessage") do
:in_reply_to, :in_reply_to,
:thread, :thread,
:upload_ids, :upload_ids,
:incoming_chat_webhook :incoming_chat_webhook,
:blocks
initialize_with do |transients| initialize_with do |transients|
channel = channel =
@ -101,6 +102,7 @@ Fabricator(:chat_message_with_service, class_name: "Chat::CreateMessage") do
thread_id: transients[:thread]&.id, thread_id: transients[:thread]&.id,
in_reply_to_id: transients[:in_reply_to]&.id, in_reply_to_id: transients[:in_reply_to]&.id,
upload_ids: transients[:upload_ids], upload_ids: transients[:upload_ids],
blocks: transients[:blocks],
}, },
options: { options: {
process_inline: true, process_inline: true,

View File

@ -13,10 +13,124 @@ describe Chat::Message do
expect(Chat::MessageCustomField.first.message.id).to eq(message.id) expect(Chat::MessageCustomField.first.message.id).to eq(message.id)
end 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 describe "validations" do
subject(:message) { described_class.new(message: "") } subject(:message) { described_class.new(message: "") }
let(:blocks) { nil }
it { is_expected.to validate_length_of(:cooked).is_at_most(20_000) } 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 end
describe ".in_thread?" do describe ".in_thread?" do

View File

@ -75,20 +75,50 @@ RSpec.describe Chat::Api::ChannelMessagesController do
end end
describe "#create" do 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 context "when force_thread param is given" do
let!(:message) { Fabricate(:chat_message, chat_channel: channel) } 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 it "ignores it" do
expect { expect { post "/chat/#{channel.id}.json", params: params }.not_to change {
post "/chat/#{channel.id}.json", Chat::Thread.where(force: true).count
params: { }
in_reply_to_id: message.id, end
message: "test", end
force_thread: true,
} context "when blocks is provided" do
}.not_to change { Chat::Thread.where(force: true).count } 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 end
end end

View File

@ -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

View File

@ -2,9 +2,10 @@
RSpec.describe Chat::CreateMessage do RSpec.describe Chat::CreateMessage do
describe described_class::Contract, type: :model 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(:upload_ids) { nil }
let(:blocks) { nil }
it { is_expected.to validate_presence_of :chat_channel_id } 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 } it { is_expected.not_to validate_presence_of :message }
end end
context "when blocks are provided" do
let(:blocks) { [{ type: "actions" }] }
it { is_expected.not_to validate_presence_of :message }
end
end end
describe ".call" do describe ".call" do
@ -33,6 +40,7 @@ RSpec.describe Chat::CreateMessage do
let(:content) { "A new message @#{other_user.username_lower}" } let(:content) { "A new message @#{other_user.username_lower}" }
let(:context_topic_id) { nil } let(:context_topic_id) { nil }
let(:context_post_ids) { nil } let(:context_post_ids) { nil }
let(:blocks) { nil }
let(:params) do let(:params) do
{ {
chat_channel_id: channel.id, chat_channel_id: channel.id,
@ -40,6 +48,7 @@ RSpec.describe Chat::CreateMessage do
upload_ids: [upload.id], upload_ids: [upload.id],
context_topic_id: context_topic_id, context_topic_id: context_topic_id,
context_post_ids: context_post_ids, context_post_ids: context_post_ids,
blocks: blocks,
} }
end end
let(:options) { { enforce_membership: false, force_thread: false } } 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) } it { is_expected.to fail_a_policy(:no_silenced_user) }
end 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 user is not silenced" do
context "when mandatory parameters are missing" do context "when mandatory parameters are missing" do
before { params[:chat_channel_id] = "" } before { params[:chat_channel_id] = "" }

View File

@ -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