mirror of
https://github.com/discourse/discourse.git
synced 2025-05-21 18:12:32 +08:00
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:
@ -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)
|
||||
|
||||
|
27
plugins/chat/app/models/chat/message_interaction.rb
Normal file
27
plugins/chat/app/models/chat/message_interaction.rb
Normal 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)
|
||||
#
|
27
plugins/chat/app/serializers/chat/block_serializer.rb
Normal file
27
plugins/chat/app/serializers/chat/block_serializer.rb
Normal 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
|
@ -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
|
||||
|
||||
|
62
plugins/chat/app/services/chat/create_message_interaction.rb
Normal file
62
plugins/chat/app/services/chat/create_message_interaction.rb
Normal 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
|
Reference in New Issue
Block a user