diff --git a/lib/slack/block_kit.rb b/lib/slack/block_kit.rb index 2dbb5b3..e2907a1 100644 --- a/lib/slack/block_kit.rb +++ b/lib/slack/block_kit.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative './block_kit/limits' + module Slack module BlockKit module Composition; end diff --git a/lib/slack/block_kit/block_kit_error.rb b/lib/slack/block_kit/block_kit_error.rb new file mode 100644 index 0000000..948d9ef --- /dev/null +++ b/lib/slack/block_kit/block_kit_error.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Slack + module BlockKit + class BlockKitError < StandardError + end + end +end diff --git a/lib/slack/block_kit/composition/option.rb b/lib/slack/block_kit/composition/option.rb index dda6db7..574e499 100644 --- a/lib/slack/block_kit/composition/option.rb +++ b/lib/slack/block_kit/composition/option.rb @@ -8,6 +8,8 @@ module Composition # https://api.slack.com/reference/messaging/composition-objects#option # https://api.slack.com/reference/messaging/block-elements#select class Option + prepend Limits::Limitable + def initialize(value:, text:, initial: false, emoji: nil, description: nil, url: nil) @text = PlainText.new(text: text, emoji: emoji) @value = value diff --git a/lib/slack/block_kit/element/static_select.rb b/lib/slack/block_kit/element/static_select.rb index c0e16a6..2610f6b 100644 --- a/lib/slack/block_kit/element/static_select.rb +++ b/lib/slack/block_kit/element/static_select.rb @@ -14,6 +14,7 @@ module Element # https://api.slack.com/reference/messaging/block-elements#static-select class StaticSelect include Composition::ConfirmationDialog::Confirmable + prepend Limits::Limitable TYPE = 'static_select' diff --git a/lib/slack/block_kit/limits.rb b/lib/slack/block_kit/limits.rb new file mode 100644 index 0000000..4cade26 --- /dev/null +++ b/lib/slack/block_kit/limits.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Slack + module BlockKit + module Limits + # See https://api.slack.com/reference/block-kit/blocks for limits + + MAX_STATIC_SELECT_OPTIONS = 100 + + MAX_OPTION_TEXT_LENGTH = 75 + MAX_OPTION_VALUE_LENGTH = 75 + MAX_OPTION_DESCRIPTION_LENGTH = 75 + + MAX_NUMBER_OF_MODAL_BLOCKS = 100 + + class << self + attr_writer :default_limiter + + def set_limiter(class_name, limiter) + registry[class_name] = limiter + end + + def limiter_for(block_kit_instance) + registry[block_kit_instance.class.name] || default_limiter + end + + def default_limiter + @default_limiter ||= Limiters::NoopLimiter.new + end + + private + + def registry + @registry ||= {} + end + end + + set_limiter 'Slack::BlockKit::Element::StaticSelect', Limiters::StaticSelectOptionsConfigRaiseOnErrorLimiter.new + set_limiter 'Slack::BlockKit::Composition::Option', Limiters::OptionTruncateLabelAndDescriptionLimiter.new + set_limiter 'Slack::Surfaces::Modal', Limiters::SurfaceRaiseOnTooManyBlocksLimiter.new(surface_type: :modal, max_blocks: MAX_NUMBER_OF_MODAL_BLOCKS) + end + end +end diff --git a/lib/slack/block_kit/limits/errors/incompatible_options_error.rb b/lib/slack/block_kit/limits/errors/incompatible_options_error.rb new file mode 100644 index 0000000..f7ba8f9 --- /dev/null +++ b/lib/slack/block_kit/limits/errors/incompatible_options_error.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Slack + module BlockKit + module Limits + module Errors + class IncompatibleOptionsError < ::Slack::BlockKit::BlockKitError + def initialize(msg = 'The parameters of this block are incompatible') + super + end + end + end + end + end +end diff --git a/lib/slack/block_kit/limits/errors/limit_exceeded_error.rb b/lib/slack/block_kit/limits/errors/limit_exceeded_error.rb new file mode 100644 index 0000000..41b3ab4 --- /dev/null +++ b/lib/slack/block_kit/limits/errors/limit_exceeded_error.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Slack + module BlockKit + module Limits + module Errors + class LimitExceededError < ::Slack::BlockKit::BlockKitError + end + end + end + end +end diff --git a/lib/slack/block_kit/limits/limitable.rb b/lib/slack/block_kit/limits/limitable.rb new file mode 100644 index 0000000..fd58ad8 --- /dev/null +++ b/lib/slack/block_kit/limits/limitable.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Slack + module BlockKit + module Limits + module Limitable + def as_json(*) + original_json = super + limiter.call(original_json) + end + + private + + def limiter + ::Slack::BlockKit::Limits.limiter_for(self) + end + end + end + end +end diff --git a/lib/slack/block_kit/limits/limiters/noop_limiter.rb b/lib/slack/block_kit/limits/limiters/noop_limiter.rb new file mode 100644 index 0000000..6e6debc --- /dev/null +++ b/lib/slack/block_kit/limits/limiters/noop_limiter.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Slack + module BlockKit + module Limits + module Limiters + class NoopLimiter + def call(json) + json + end + end + end + end + end +end diff --git a/lib/slack/block_kit/limits/limiters/option_truncate_label_and_description_limiter.rb b/lib/slack/block_kit/limits/limiters/option_truncate_label_and_description_limiter.rb new file mode 100644 index 0000000..d56a38a --- /dev/null +++ b/lib/slack/block_kit/limits/limiters/option_truncate_label_and_description_limiter.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Slack + module BlockKit + module Limits + module Limiters + class OptionTruncateLabelAndDescriptionLimiter < NoopLimiter + include Limits::TruncationHelper + + attr_reader :max_label_length, :max_value_length, :max_description_length, :label_truncation_replacement, :description_truncation_replacement + + def initialize(max_label_length: MAX_OPTION_TEXT_LENGTH, max_value_length: MAX_OPTION_VALUE_LENGTH, max_description_length: MAX_OPTION_DESCRIPTION_LENGTH, label_truncation_replacement: '…', description_truncation_replacement: '…') + super() + @max_label_length = max_label_length + @max_value_length = max_value_length + @max_description_length = max_description_length + @label_truncation_replacement = label_truncation_replacement + @description_truncation_replacement = description_truncation_replacement + end + + def call(option_json) + raise Errors::LimitExceededError, "Option values must be less than #{max_value_length} in size. Given value has size #{option_json[:value].size}" if option_json[:value].size > max_value_length + + truncate_label!(option_json) + truncate_description!(option_json) + + super(option_json) + end + + private + + def truncate_label!(option_json) + option_json[:text][:text] = truncate_string(option_json[:text][:text], max_label_length, omission: label_truncation_replacement) + end + + def truncate_description!(option_json) + option_json[:description][:text] = truncate_string(option_json[:description][:text], max_description_length, omission: description_truncation_replacement) unless option_json[:description].nil? + end + end + end + end + end +end diff --git a/lib/slack/block_kit/limits/limiters/static_select_options_config_raise_on_error_limiter.rb b/lib/slack/block_kit/limits/limiters/static_select_options_config_raise_on_error_limiter.rb new file mode 100644 index 0000000..19f8f96 --- /dev/null +++ b/lib/slack/block_kit/limits/limiters/static_select_options_config_raise_on_error_limiter.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Slack + module BlockKit + module Limits + module Limiters + class StaticSelectOptionsConfigRaiseOnErrorLimiter < NoopLimiter + attr_reader :max_options + + def initialize(max_options: MAX_STATIC_SELECT_OPTIONS) + super() + @max_options = max_options + end + + def call(static_select_json) + check_one_of_options_or_option_groups_is_set!(static_select_json) + check_not_both_options_and_option_groups_are_set!(static_select_json) + + check_options!(static_select_json) unless static_select_json[:options].nil? + + super(static_select_json) + end + + private + + def check_one_of_options_or_option_groups_is_set!(static_select_json) + raise Errors::IncompatibleOptionsError.new("Either 'options' or 'option_groups' must have items. Both are empty.", parameter_names: %w[options option_groups]) if static_select_json[:options]&.empty? && static_select_json[:option_groups]&.empty? + end + + def check_not_both_options_and_option_groups_are_set!(static_select_json) + raise Errors::IncompatibleOptionsError, "'options' and 'option_groups' cannot both be set" if !(static_select_json[:options].nil? || static_select_json[:options].empty?) && !(static_select_json[:option_groups].nil? || static_select_json[:option_groups].empty?) + end + + def check_options!(static_select_json) + # TODO: Check that 'initial' is one of the options + + raise Errors::LimitExceededError, "Static select elements are limited to #{max_options} options. #{static_select_json[:options].size} options provided" if static_select_json[:options].size > max_options + end + end + end + end + end +end diff --git a/lib/slack/block_kit/limits/limiters/surface_raise_on_too_many_blocks_limiter.rb b/lib/slack/block_kit/limits/limiters/surface_raise_on_too_many_blocks_limiter.rb new file mode 100644 index 0000000..9fc0342 --- /dev/null +++ b/lib/slack/block_kit/limits/limiters/surface_raise_on_too_many_blocks_limiter.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Slack + module BlockKit + module Limits + module Limiters + class SurfaceRaiseOnTooManyBlocksLimiter < NoopLimiter + attr_reader :surface_type, :max_blocks + + def initialize(surface_type:, max_blocks:) + super() + @surface_type = surface_type + @max_blocks = max_blocks + end + + def call(surface_json) + return surface_json if surface_json[:blocks].nil? + + raise Errors::LimitExceededError, "Surfaces with type '#{surface_type}' allow a maximum of #{max_blocks} blocks. #{surface_json[:blocks].size} blocks have been specified." if surface_json[:blocks].size > max_blocks + + super(surface_json) + end + end + end + end + end +end diff --git a/lib/slack/block_kit/limits/truncation_helper.rb b/lib/slack/block_kit/limits/truncation_helper.rb new file mode 100644 index 0000000..55a2b46 --- /dev/null +++ b/lib/slack/block_kit/limits/truncation_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Slack + module BlockKit + module Limits + module TruncationHelper + def truncate_string(str, truncate_at, omission: '…') + return str.dup unless str.length > truncate_at + + "#{str[0, truncate_at - omission.length]}#{omission}" + end + end + end + end +end diff --git a/lib/slack/surfaces/modal.rb b/lib/slack/surfaces/modal.rb index adc0e3a..1950047 100644 --- a/lib/slack/surfaces/modal.rb +++ b/lib/slack/surfaces/modal.rb @@ -12,6 +12,8 @@ module Surfaces # or using #title for detail setup # class Modal + prepend ::Slack::BlockKit::Limits::Limitable + TYPE = 'modal' def initialize( diff --git a/spec/lib/slack/block_kit/composition/option_spec.rb b/spec/lib/slack/block_kit/composition/option_spec.rb index 8a2778a..79bd0ff 100644 --- a/spec/lib/slack/block_kit/composition/option_spec.rb +++ b/spec/lib/slack/block_kit/composition/option_spec.rb @@ -35,6 +35,30 @@ end end + context 'when label is very long' do + it 'truncates the label' do + option_with_long_label = described_class.new( + text: 'a' * (::Slack::BlockKit::Limits::MAX_OPTION_TEXT_LENGTH + 1), + value: 'a value', + emoji: true + ) + + expect(option_with_long_label.as_json.dig(:text, :text)).to eq("#{'a' * (::Slack::BlockKit::Limits::MAX_OPTION_TEXT_LENGTH - '…'.length)}…") + end + end + + context 'when value is very long' do + it 'raises an error' do + options = described_class.new( + text: 'some text', + value: 'a' * (::Slack::BlockKit::Limits::MAX_OPTION_VALUE_LENGTH + 1), + emoji: true + ) + + expect { options.as_json }.to raise_error(::Slack::BlockKit::Limits::Errors::LimitExceededError) + end + end + context 'when description is set' do let(:expected) do { @@ -56,6 +80,17 @@ it 'includes description as a plain_text object in the payload' do expect(instance.as_json).to eq(expected) end + + it 'truncates long descriptions' do + option_with_long_description = described_class.new( + text: 'some text', + value: 'a value', + emoji: true, + description: 'a' * (::Slack::BlockKit::Limits::MAX_OPTION_DESCRIPTION_LENGTH + 1) + ) + + expect(option_with_long_description.as_json.dig(:description, :text)).to eq("#{'a' * (::Slack::BlockKit::Limits::MAX_OPTION_DESCRIPTION_LENGTH - '…'.length)}…") + end end context 'when url is set' do diff --git a/spec/lib/slack/block_kit/element/static_select_spec.rb b/spec/lib/slack/block_kit/element/static_select_spec.rb index 1ae3fed..9bac817 100644 --- a/spec/lib/slack/block_kit/element/static_select_spec.rb +++ b/spec/lib/slack/block_kit/element/static_select_spec.rb @@ -166,6 +166,19 @@ expected_json.merge(options: expected_options) ) end + + context 'when there are too many options' do + subject(:as_json) do + (::Slack::BlockKit::Limits::MAX_STATIC_SELECT_OPTIONS + 1).times do |i| + instance.option(value: "__VALUE_#{i}__", text: "__TEXT_#{i}__") + end + instance.as_json + end + + it 'raises an error when too many options are added' do + expect { as_json }.to raise_error(::Slack::BlockKit::Limits::Errors::LimitExceededError) + end + end end context 'with options and initial option' do diff --git a/spec/lib/slack/surfaces/modal_spec.rb b/spec/lib/slack/surfaces/modal_spec.rb index 3c17159..3194fdb 100644 --- a/spec/lib/slack/surfaces/modal_spec.rb +++ b/spec/lib/slack/surfaces/modal_spec.rb @@ -34,6 +34,20 @@ it 'correctly serializes' do expect(instance.as_json).to eq(expected_json) end + + context 'with too many blocks' do + let(:blocks) do + Slack::BlockKit.blocks do |block| + (Slack::BlockKit::Limits::MAX_NUMBER_OF_MODAL_BLOCKS + 1).times do |i| + block.image(url: image_url, alt_text: "__ALT_TEXT_#{i}__") + end + end + end + + it 'raises an error' do + expect { instance.as_json }.to raise_error(Slack::BlockKit::Limits::Errors::LimitExceededError) + end + end end context 'without blocks argument' do