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:
parent
04bac33ed9
commit
582de0ffe3
|
@ -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);
|
||||
|
|
|
@ -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|
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
#
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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 {
|
|||
</div>
|
||||
{{/if}}
|
||||
|
||||
<ChatMessageBlocks @message={{@message}} />
|
||||
|
||||
<ChatMessageError
|
||||
@message={{@message}}
|
||||
@onRetry={{@resendStagedMessage}}
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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] = "" }
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue