slack_sender 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 320590e57f220cb0e85688e3987c1fdee1b9f1bc014a9907099ec277568a06f6
4
- data.tar.gz: b478553cd49da506001a5223673cc9a2ca0d2adf79cba6d6fc4e5a317f33db89
3
+ metadata.gz: 00e8fd0b67a215e7066b491b3dc85afae96e4eec260bb156bf8a8835c1a96269
4
+ data.tar.gz: ed385d6feb6cfd4fadd85d05f4a7716ec021b97c64e26156fa798cda9d7bd80b
5
5
  SHA512:
6
- metadata.gz: a5153bb970a492024ac5ddc16f1fab9e6debe046636f87330c4ab19ebade650702123e2edc7a7d3473e9e31a84f51ac6e0830cae0be2add5c18542996ae89b8d
7
- data.tar.gz: 5c5737bfa9581c9766d0eb66b4d20367501006f2b40acff2f41aafabc3455be2db2c9174b1fa3feff31f5d70db032124b4928a7c2d1fb2140975f792cd8a614c
6
+ metadata.gz: 3ba5e9b7ddf025229eef2707025aa6871044c1f17dfb74e330d5e95852aa08c4589b719743c23b4e72fa2fe83b61b1c8cef3d051b4d1f32882576a12db2bcfcf
7
+ data.tar.gz: b15b68b15932c145ccbcb855609dcd3e405716288565f43493528b943fef0c7be2a3ddd2978641798392bf4a562fff4b664d6fc99962b7226df82c74faf0ad5e
data/CHANGELOG.md CHANGED
@@ -1,6 +1,62 @@
1
1
  ## [Unreleased]
2
2
 
3
- * N/A
3
+ ## [0.1.1] - 2026-08-05
4
+
5
+ - `sandbox_mode` is now a per-class-overridable setting. An Axn action using the `:slack` strategy
6
+ can opt in/out of sandbox for its own sends via `configure(:slack_sender) { |c| c.sandbox_mode = false }`
7
+ (the axn `PRO-2880` namespaced-config DSL); the override is resolved at the origin action when it
8
+ sends, threaded down to `DeliveryAxn`, and inherits into subclasses. Actions that declare none are
9
+ unaffected and follow global config. Applies to the strategy delivery path only, not
10
+ `SlackSender.group_link`. Internally, `Configuration#sandbox_mode` moved to the `Axn::Configurable`
11
+ DSL (a dynamic Proc `default:` preserving the `Rails.env`-derived behavior); `sandbox_mode?` is unchanged.
12
+ `sandbox_mode?` is now the DSL-generated predicate reader (PRO-2888) rather than a hand-written
13
+ alias. Requires an `axn` with `Axn::Configurable` namespaced config (PRO-2880) and predicate
14
+ readers (PRO-2888).
15
+ - Source `axn` from the published `0.1.0-alpha.5` release on RubyGems instead of the temporary
16
+ `github: teamshares/axn, branch: main` git pin. The gemspec lower bound is now `>= 0.1.0-alpha.5`
17
+ — the first release where a Proc `default:` is dynamic on its own (the `callable:` kwarg removed),
18
+ which `Configuration#sandbox_mode` relies on. No behavior change.
19
+ - Adopt the upstream `Axn::Configurable::Settings` DSL for the simple, declarative
20
+ `SlackSender::Configuration` settings (`enabled`, `silence_archived_channel_exceptions`,
21
+ `sandbox_default_behavior`, `use_slack_notifiers_namespace`). Bespoke/computed settings
22
+ (`sandbox_mode?`, `async_backend`, `max_async_file_upload_size`) remain hand-written.
23
+ - **Behavior change:** invalid `sandbox_default_behavior` values now raise the DSL's
24
+ `ArgumentError` (`sandbox_default_behavior must be one of ...`) instead of the previous
25
+ hand-written `Unsupported sandbox behavior` message. `Profile`'s per-profile
26
+ `sandbox: { behavior: ... }` validation now shares `Configuration::SUPPORTED_SANDBOX_BEHAVIORS`
27
+ and raises the matching wording (`sandbox.behavior must be one of ...`), so the two no longer
28
+ diverge.
29
+ - Requires a release of upstream `axn` that includes `Axn::Configurable` (PRO-2769) and the error
30
+ message presentation behavior from PRO-2820 (#132) and PRO-2832 (#134) — the base/reason prefix
31
+ and nested-`call!` header aggregation that the base-message behavior below relies on. The gemspec
32
+ runtime dependency lower bound is raised to `axn >= 0.1.0-alpha.5` accordingly (see the sourcing
33
+ note above), so consumers can't resolve an older `axn` and `NameError` on `require`.
34
+ - **Behavior change:** failed deliveries now carry a consistent base message on `result.error`.
35
+ Every failure reason is prefixed as `"Unable to send Slack message: <reason>"`, and unexpected
36
+ errors with no specific reason handler surface `"Unable to send Slack message"` instead of axn's
37
+ generic `"Something went wrong"`. This is presentation-only — error classification, retries, and
38
+ exception reporting are unchanged. The `MISSING_SCOPE`/`MISSING_SCOPE_UNKNOWN` messages were
39
+ rewritten as complete, self-contained sentences (`"Missing required Slack scope '%s'. ..."`) so
40
+ they read cleanly both prefixed by the base (`DeliveryAxn`'s text-post path) and standalone
41
+ (`FileUploader`'s async pre-upload path, which has no base to attach to).
42
+ - Add a `slack_options:` passthrough hash for forwarding arbitrary `chat.postMessage` options
43
+ (`unfurl_links`, `unfurl_media`, `reply_broadcast`, `metadata`, …) straight to Slack. Managed
44
+ keys (channel/text/blocks/attachments/icon_emoji/thread_ts) take precedence; applies to the
45
+ text-post path only, not file uploads. Like `blocks`/`attachments`, `slack_options` is
46
+ deep-stringified before an async job is enqueued, so a symbol-keyed hash (e.g.
47
+ `slack_options: { unfurl_links: false }`) doesn't get rejected by Sidekiq's strict argument
48
+ checks before the message is ever sent. A call with blank `text:` but a non-empty
49
+ `slack_options:` (and no real content) now fails fast with the `NO_CONTENT_PROVIDED` validation
50
+ error instead of being silently dropped as a no-op — `slack_options` are message modifiers, not
51
+ content, so forwarding a content-less call to Slack would only return `no_text` and burn async
52
+ retries. The error is an `InvalidArgumentsError`, so async jobs discard it rather than retrying.
53
+ `slack_options` is also available in the `Notifier` `notify do … end` DSL
54
+ (`slack_options unfurl_links: false`).
55
+ - **Behavior change:** async deliveries that fail argument validation inside a `preprocess` lambda
56
+ (e.g. an unknown channel) are now discarded instead of retried. `axn` wraps such errors in an
57
+ `Axn::ContractViolation::PreprocessingError`, so the previous `InvalidArgumentsError`-only
58
+ Sidekiq `sidekiq_retry_in` / ActiveJob `discard_on` checks missed them and burned all 5 retries
59
+ on a permanently-invalid job.
4
60
 
5
61
  ## [0.1.0] - 2026-02-19
6
62
 
data/README.md CHANGED
@@ -20,7 +20,7 @@ bundle install
20
20
 
21
21
  **Requirements:**
22
22
  - Ruby >= 3.2.1
23
- - A Slack Bot User OAuth Token with `chat:write` scope (see [Configuration](docs/configuration.md#required-slack-scopes) for full scope list)
23
+ - A Slack Bot User OAuth Token with `chat:write` scope (see [Configuration](https://github.com/teamshares/slack_sender/blob/main/docs/configuration.md#required-slack-scopes) for full scope list)
24
24
  - For async delivery: Sidekiq or ActiveJob (auto-detected)
25
25
 
26
26
  ## Quick Start - Minimal
@@ -67,18 +67,19 @@ That's it. SlackSender handles rate limits, retries, and sandbox redirection (if
67
67
 
68
68
  | Guide | Description |
69
69
  |-------|-------------|
70
- | [Usage Guide](docs/usage.md) | Messages, files, threading, multi-channel delivery |
71
- | [Configuration](docs/configuration.md) | Profiles, sandbox mode, global settings |
72
- | [Axn Integration](docs/axn_integration.md) | `use :slack` strategy and `SlackSender::Notifier` |
73
- | [Troubleshooting](docs/troubleshooting.md) | Common errors and FAQ |
70
+ | [Usage Guide](https://github.com/teamshares/slack_sender/blob/main/docs/usage.md) | Messages, files, threading, multi-channel delivery |
71
+ | [Configuration](https://github.com/teamshares/slack_sender/blob/main/docs/configuration.md) | Profiles, sandbox mode, global settings |
72
+ | [Axn Integration](https://github.com/teamshares/slack_sender/blob/main/docs/axn_integration.md) | `use :slack` strategy and `SlackSender::Notifier` |
73
+ | [Troubleshooting](https://github.com/teamshares/slack_sender/blob/main/docs/troubleshooting.md) | Common errors and FAQ |
74
74
 
75
75
  ## Features
76
76
 
77
77
  - **Background dispatch** with automatic rate-limit retries via Sidekiq or ActiveJob
78
78
  - **Multi-channel delivery** — broadcast to multiple channels efficiently
79
- - **Sandbox mode** — redirect or suppress messages in non-production environments
79
+ - **Sandbox mode** — redirect or suppress messages in non-production environments, with per-action overrides via `configure(:slack_sender)`
80
80
  - **File uploads** — sync and async, with automatic size handling
81
81
  - **Multiple profiles** — manage multiple Slack workspaces
82
+ - **Full option passthrough** — forward any [`chat.postMessage`](https://www.rubydoc.info/gems/slack-ruby-client/Slack/Web/Api/Endpoints/Chat#chat_postMessage-instance_method) option via [`slack_options:`](https://github.com/teamshares/slack_sender/blob/main/docs/usage.md#other-chatpostmessage-options)
82
83
  - **Axn integration** — `use :slack` strategy and dedicated `Notifier` base class
83
84
 
84
85
  ## Development
@@ -2,6 +2,16 @@
2
2
 
3
3
  module SlackSender
4
4
  class Configuration
5
+ # Validated instance settings via the upstream Axn::Configurable DSL (class flavor).
6
+ # Only the simple settings are declared here; everything bespoke below stays hand-written.
7
+ extend Axn::Configurable::Settings
8
+
9
+ # Per-class overrides (e.g. `sandbox_mode`) live under the :slack_sender namespace, reached via
10
+ # `MyAction.configure(:slack_sender) { |c| c.sandbox_mode = false }` on any `use :slack` action.
11
+ # Must precede any `overridable: true` setting.
12
+ config_namespace :slack_sender
13
+ overridable_config_source { SlackSender.config }
14
+
5
15
  SUPPORTED_ASYNC_BACKENDS = %i[sidekiq active_job].freeze
6
16
  SUPPORTED_SANDBOX_BEHAVIORS = %i[noop redirect passthrough].freeze
7
17
 
@@ -11,42 +21,62 @@ module SlackSender
11
21
  # Default file size threshold for async uploads
12
22
  DEFAULT_MAX_ASYNC_FILE_UPLOAD_SIZE = 26_214_400 # 25 MB
13
23
 
14
- attr_writer :sandbox_mode
15
- attr_accessor :enabled, :silence_archived_channel_exceptions
24
+ setting :enabled, default: true
25
+ setting :silence_archived_channel_exceptions
26
+ setting :sandbox_default_behavior, default: :noop, one_of: SUPPORTED_SANDBOX_BEHAVIORS
16
27
 
17
28
  # Whether to autoload files in app/slack_notifiers under the SlackNotifiers namespace.
18
29
  # When true (default): app/slack_notifiers/foo.rb defines SlackNotifiers::Foo
19
30
  # When false: app/slack_notifiers/foo.rb defines Foo (standard Rails behavior)
20
- attr_accessor :use_slack_notifiers_namespace
21
-
22
- def initialize
23
- # Default values
24
- @enabled = true
25
- @sandbox_default_behavior = :noop
26
- @max_async_file_upload_size = DEFAULT_MAX_ASYNC_FILE_UPLOAD_SIZE
27
- @use_slack_notifiers_namespace = true
28
- end
29
-
30
- def sandbox_mode?
31
- return @sandbox_mode unless @sandbox_mode.nil?
32
-
33
- if defined?(Rails) && Rails.respond_to?(:env)
34
- !Rails.env.production?
31
+ setting :use_slack_notifiers_namespace, default: true
32
+
33
+ # Whether messages are redirected/suppressed for non-production. The Proc default is dynamic:
34
+ # axn's Configurable re-derives it from Rails.env on every read while the setting is unset. An
35
+ # explicit true/false wins; an explicit nil resets to the default (custom writer below). Marked
36
+ # overridable so an individual action can opt in/out of sandbox for its own sends via
37
+ # `configure(:slack_sender)` (resolved in the strategy and threaded down to DeliveryAxn).
38
+ setting :sandbox_mode,
39
+ default: -> { defined?(Rails) && Rails.respond_to?(:env) ? !Rails.env.production? : true },
40
+ overridable: true
41
+
42
+ # nil is a documented value (`Boolean or nil`) meaning "use the default". The DSL writer would
43
+ # store an explicit nil as-is, making `sandbox_mode?` return `!!nil == false` and silently
44
+ # disabling sandbox in non-production. Treat an assigned nil as a reset to the dynamic default
45
+ # (remove the ivar so the Proc default re-derives), matching the pre-DSL hand-written reader.
46
+ def sandbox_mode=(value)
47
+ if value.nil?
48
+ remove_instance_variable(:@sandbox_mode) if instance_variable_defined?(:@sandbox_mode)
35
49
  else
36
- true
50
+ @sandbox_mode = value
37
51
  end
38
52
  end
39
53
 
40
- attr_reader :sandbox_default_behavior
54
+ def initialize
55
+ # Bespoke settings (not backed by the DSL) need their defaults set here.
56
+ @max_async_file_upload_size = DEFAULT_MAX_ASYNC_FILE_UPLOAD_SIZE
57
+ end
41
58
 
42
- def sandbox_default_behavior=(value)
43
- unless SUPPORTED_SANDBOX_BEHAVIORS.include?(value)
44
- raise ArgumentError,
45
- "Unsupported sandbox behavior: #{value.inspect}. " \
46
- "Supported behaviors: #{SUPPORTED_SANDBOX_BEHAVIORS.inspect}"
59
+ # `sandbox_mode?` is generated by the Axn::Configurable DSL (predicate reader, PRO-2888) as
60
+ # `!!sandbox_mode`; it remains the public API used throughout the codebase.
61
+
62
+ # Returns [found, value] for the per-class `name` override declared via `configure(:slack_sender)`
63
+ # on `origin` (or the nearest ancestor), found=false when none. Reads axn's per-class override
64
+ # store directly: axn exposes resolution (`resolve_override_for`) but not presence, and the
65
+ # delivery path must diverge from global config *only* when an action actually opted in.
66
+ def self.class_override(origin, name)
67
+ return [false, nil] unless origin.is_a?(Module)
68
+
69
+ klass = origin
70
+ while klass.is_a?(Module)
71
+ if klass.instance_variable_defined?(:@_axn_config_overrides)
72
+ slot = klass.instance_variable_get(:@_axn_config_overrides)[config_namespace]
73
+ return [true, slot[name]] if slot&.key?(name)
74
+ end
75
+ break unless klass.is_a?(Class) && klass.superclass
76
+
77
+ klass = klass.superclass
47
78
  end
48
-
49
- @sandbox_default_behavior = value
79
+ [false, nil]
50
80
  end
51
81
 
52
82
  def async_backend
@@ -19,21 +19,24 @@ module SlackSender
19
19
  # Backend is already validated by Configuration#async_backend=
20
20
  case backend
21
21
  when :sidekiq
22
- async :sidekiq, retry: 5, dead: false
23
- # Configure Sidekiq-specific retry logic (including skipping retries for InvalidArgumentsError)
24
- if defined?(Sidekiq::Job) && respond_to?(:sidekiq_retry_in)
22
+ # Configure Sidekiq-specific retry logic (including skipping retries for InvalidArgumentsError).
23
+ # On axn's current Sidekiq adapter the action is not itself a Sidekiq::Job; sidekiq_retry_in is a
24
+ # worker-subclass hook, so it must be declared inside the `async :sidekiq do…end` block (the block
25
+ # is class_eval'd onto the generated AxnSidekiqWorker).
26
+ async :sidekiq, retry: 5, dead: false do
25
27
  sidekiq_retry_in do |_count, exception|
26
- # Don't retry invalid arguments
27
- next :discard if exception.is_a?(SlackSender::InvalidArgumentsError)
28
+ # Don't retry invalid arguments (incl. an InvalidArgumentsError raised from a
29
+ # preprocess lambda, which the worker re-raises wrapped in a PreprocessingError).
30
+ next :discard if SlackSender::Util.invalid_arguments_error?(exception)
28
31
 
29
32
  SlackSender::Util.parse_retry_delay_from_exception(exception)
30
33
  end
31
34
  end
32
35
  when :active_job
33
36
  async :active_job do
34
- # Skip retries for invalid arguments - these will never succeed
35
- discard_on SlackSender::InvalidArgumentsError
36
-
37
+ # ActiveJob reads rescue handlers bottom-to-top (last declared is matched first), so the
38
+ # catch-all retry_on StandardError MUST be declared before the specific discards below —
39
+ # otherwise it shadows them and permanent errors burn all attempts.
37
40
  retry_on StandardError, wait: :exponentially_longer, attempts: 5 do |_job, exception|
38
41
  retry_behavior = SlackSender::Util.parse_retry_delay_from_exception(exception)
39
42
  next if retry_behavior == :discard
@@ -42,6 +45,21 @@ module SlackSender
42
45
  retry_job wait: retry_behavior.seconds if retry_behavior.is_a?(Numeric) && retry_behavior.positive?
43
46
  # Otherwise, let ActiveJob use its default retry behavior
44
47
  end
48
+
49
+ # Skip retries for invalid arguments - these will never succeed. Declared after retry_on
50
+ # so this specific handler takes precedence over the catch-all above.
51
+ discard_on SlackSender::InvalidArgumentsError
52
+
53
+ # An InvalidArgumentsError raised from a preprocess lambda (e.g. an unknown channel)
54
+ # arrives wrapped in a PreprocessingError, which discard_on can't match by cause. Unwrap
55
+ # only those to the InvalidArgumentsError so the discard_on above catches them (no
56
+ # retries); every other preprocessing error propagates unchanged to retry_on/reporting,
57
+ # mirroring the Sidekiq path (Util.invalid_arguments_error?).
58
+ around_perform do |_job, block|
59
+ block.call
60
+ rescue Axn::ContractViolation::PreprocessingError => e
61
+ raise SlackSender::Util.invalid_arguments_error?(e) ? e.cause : e
62
+ end
45
63
  end
46
64
  end
47
65
  end
@@ -19,13 +19,19 @@ module SlackSender
19
19
 
20
20
  private
21
21
 
22
+ # slack_options are message *modifiers* (unfurl_links, metadata, …), not content, so they do
23
+ # NOT count here: a call with no text/blocks/attachments/files is content-blank even when it
24
+ # carries slack_options. Forwarding such a call to Slack would return no_text and burn async
25
+ # retries on a deterministic failure, so we fail fast with NO_CONTENT_PROVIDED instead.
22
26
  def content_blank? = text.blank? && blocks.blank? && attachments.blank? && files.blank? && file_ids.blank?
23
27
 
24
28
  def explicit_blank_text_only?
25
- # Caller explicitly passed `text:` but it's blank, and no other content.
29
+ # Caller explicitly passed `text:` but it's blank, with no other content. Treat as an
30
+ # intentional no-op so we don't error or retry on intent-only blank sends. But if
31
+ # slack_options are present the caller clearly meant to send something, so fall through to
32
+ # raise NO_CONTENT_PROVIDED rather than silently dropping the call.
26
33
  # Optional text is nil when omitted; preprocess returns "" when text: "" is passed.
27
- # Treat as no-op so we don't error or retry on intent-only blank sends.
28
- content_blank? && text.is_a?(String)
34
+ content_blank? && text.is_a?(String) && slack_options.blank?
29
35
  end
30
36
 
31
37
  # TODO: Add better validations against slack block kit API
@@ -9,6 +9,10 @@ module SlackSender
9
9
  class DeliveryAxn
10
10
  include Axn
11
11
 
12
+ # chat.postMessage keys SlackSender owns. slack_options may never set these — even when a
13
+ # managed key is blank (and thus dropped by compact_blank), the passthrough must not fill it.
14
+ MANAGED_POST_MESSAGE_KEYS = %i[channel text blocks attachments icon_emoji thread_ts].freeze
15
+
12
16
  # Class method modules (extend)
13
17
  extend AsyncConfiguration
14
18
 
@@ -17,10 +21,21 @@ module SlackSender
17
21
  include ErrorMessageParsing
18
22
  include Validation
19
23
 
20
- # Expose InvalidArgumentsError message directly (these errors skip retries)
21
- # Handle both direct raises and raises from preprocess lambdas (wrapped in PreprocessingError)
22
- error(if: InvalidArgumentsError, &:message)
23
- error(if: ->(exception:) { exception.is_a?(Axn::ContractViolation::PreprocessingError) && exception.cause.is_a?(InvalidArgumentsError) }) { |e| e.cause.message }
24
+ # Base/headline message. axn prefixes every failure *reason* with this as
25
+ # "Unable to send Slack message: <reason>", and uses it standalone as result.error for any
26
+ # unexpected error that has no specific reason handler (instead of axn's generic "Something
27
+ # went wrong"). It only affects the error-message *presentation* classification, retries,
28
+ # and on_exception reporting are unchanged.
29
+ error "Unable to send Slack message"
30
+
31
+ # Surface the underlying SlackSender::Error's message on result.error so it gets the base
32
+ # prefix too (InvalidArgumentsError, the re-raised missing-scope error, etc.). A direct raise
33
+ # matches on the exception itself; one raised from a preprocess lambda arrives wrapped in an
34
+ # Axn::ContractViolation::PreprocessingError, so #unwrapped_slack_error unwraps the cause. One
35
+ # resolver handles both cases, so there is no ordering dependency between separate handlers.
36
+ # Message presentation only — these stay exception-bucket (still reported + retried like any
37
+ # other StandardError).
38
+ error(if: ->(exception:) { unwrapped_slack_error(exception) }) { |exception| unwrapped_slack_error(exception).message }
24
39
 
25
40
  expects :profile, type: Profile, preprocess: lambda { |p|
26
41
  # If given a string/symbol (profile name), look it up in the registry
@@ -54,6 +69,18 @@ module SlackSender
54
69
  # Array of hashes with "id" and "title" keys.
55
70
  expects :file_ids, type: Array, optional: true
56
71
 
72
+ # Escape hatch: arbitrary chat.postMessage options forwarded straight to Slack
73
+ # (e.g. unfurl_links:, unfurl_media:, reply_broadcast:, metadata:). Managed keys
74
+ # (channel/text/blocks/attachments/icon_emoji/thread_ts) take precedence so sandbox
75
+ # redirection and text formatting can't be clobbered. Applies to the text-post path only,
76
+ # not file uploads (a different endpoint with a different option set).
77
+ expects :slack_options, type: Hash, optional: true
78
+
79
+ # Per-action sandbox override, resolved from the origin action's configure(:slack_sender) and
80
+ # threaded down by the strategy. Absent (nil) on the direct SlackSender.call path — then the
81
+ # global SlackSender.config.sandbox_mode? applies, as before.
82
+ expects :sandbox_mode, type: :boolean, optional: true
83
+
57
84
  exposes :thread_ts, type: String, optional: true
58
85
 
59
86
  def call
@@ -84,11 +111,14 @@ module SlackSender
84
111
 
85
112
  # Sandbox behavior handling
86
113
  def effective_sandbox_behavior
87
- return nil unless SlackSender.config.sandbox_mode?
114
+ return nil unless sandbox_mode_enabled?
88
115
 
89
116
  profile.resolved_sandbox_behavior
90
117
  end
91
118
 
119
+ # A per-action override (threaded from the strategy) wins; otherwise fall back to global config.
120
+ def sandbox_mode_enabled? = sandbox_mode.nil? ? SlackSender.config.sandbox_mode? : sandbox_mode
121
+
92
122
  def sandbox_noop? = effective_sandbox_behavior == :noop
93
123
  def sandbox_redirect? = effective_sandbox_behavior == :redirect
94
124
  def sandbox_passthrough? = effective_sandbox_behavior == :passthrough || effective_sandbox_behavior.nil?
@@ -210,8 +240,27 @@ module SlackSender
210
240
  thread_ts:,
211
241
  }.compact_blank
212
242
 
243
+ # Merge caller passthrough options. Managed keys always win: strip them from the passthrough
244
+ # first, so a blank managed key (dropped by compact_blank above) can't be shadowed by a
245
+ # same-named slack_options key. deep_symbolize_keys mirrors the deep_stringify_keys applied at
246
+ # enqueue (Profile#normalize_for_async_serialization!), so nested option hashes (e.g. metadata:)
247
+ # round-trip with symbol keys intact rather than staying string-keyed after the async hop.
248
+ if slack_options.present?
249
+ passthrough = slack_options.deep_symbolize_keys.except(*MANAGED_POST_MESSAGE_KEYS)
250
+ params = passthrough.merge(params)
251
+ end
252
+
213
253
  response = client.chat_postMessage(**params)
214
254
  expose thread_ts: response["ts"]
215
255
  end
256
+
257
+ # Returns the SlackSender::Error carried by an exception — the exception itself, or the
258
+ # unwrapped cause of an Axn preprocessing wrapper — or nil if there is none.
259
+ def unwrapped_slack_error(exception)
260
+ return exception if exception.is_a?(SlackSender::Error)
261
+ return exception.cause if exception.is_a?(Axn::ContractViolation::PreprocessingError) && exception.cause.is_a?(SlackSender::Error)
262
+
263
+ nil
264
+ end
216
265
  end
217
266
  end
@@ -23,10 +23,14 @@ module SlackSender
23
23
  FILES_EXCEED_ASYNC_LIMIT = "Total file size (%s bytes) exceeds max_async_file_upload_size (%s bytes). " \
24
24
  "Use SlackSender.call! for synchronous upload, or increase config.max_async_file_upload_size"
25
25
 
26
- # Slack API scope errors
27
- MISSING_SCOPE = "Slack API missing_scope error: required scope '%s' is not granted. " \
26
+ # Slack API scope errors.
27
+ # NOTE: these are shared by two call sites with different framing DeliveryAxn's text-post
28
+ # path (prefixed by its base message, "Unable to send Slack message: ...") and FileUploader's
29
+ # async pre-upload path (raised standalone, with no base to attach to). Written as complete,
30
+ # self-contained sentences so they read correctly either way.
31
+ MISSING_SCOPE = "Missing required Slack scope '%s'. " \
28
32
  "Add this scope to your Slack app at https://api.slack.com/apps and reinstall the app."
29
- MISSING_SCOPE_UNKNOWN = "Slack API missing_scope error (scope not specified in response). " \
33
+ MISSING_SCOPE_UNKNOWN = "Missing a required Slack scope (not specified in response). " \
30
34
  "Check your Slack app's OAuth scopes at https://api.slack.com/apps"
31
35
 
32
36
  # File upload errors
@@ -12,8 +12,9 @@ module SlackSender
12
12
  # If it's already a FileWrapper, return it as-is
13
13
  return file if file.instance_of?(self)
14
14
 
15
- # If it's a string file path, open it first
16
- file = File.open(file) if file.instance_of?(String) && File.exist?(file)
15
+ # If it's a string file path, open it first. Use the block form so the
16
+ # file descriptor is closed once initialize has read its content.
17
+ return File.open(file) { |f| new(f, index) } if file.instance_of?(String) && File.exist?(file)
17
18
 
18
19
  new(file, index)
19
20
  end
@@ -30,8 +30,11 @@ module SlackSender
30
30
  # Resolve payload
31
31
  resolved_payload = payload.transform_values { |v| resolve(v, notifier) }.compact
32
32
 
33
- # Validate: at least one payload field
34
- raise ArgumentError, "Missing payload in notify block. Add `text`, `blocks`, `attachments`, or `files`." if resolved_payload.empty?
33
+ # Validate: at least one real content field. slack_options are modifiers, not content, so a
34
+ # block that sets only them (or whose dynamic content resolver returned nil) has no payload
35
+ # and must fail fast here rather than enqueue a job that later fails with NO_CONTENT_PROVIDED.
36
+ content_payload = resolved_payload.except(*NotificationDSL::PASSTHROUGH_FIELDS)
37
+ raise ArgumentError, "Missing payload in notify block. Add `text`, `blocks`, `attachments`, or `files`." if content_payload.empty?
35
38
 
36
39
  # Resolve profile if specified
37
40
  resolved_profile = profile ? resolve(profile, notifier) : nil
@@ -5,7 +5,11 @@ module SlackSender
5
5
  # Builder DSL for the `notify do ... end` block.
6
6
  # Collects routing, payload, and condition settings into a NotificationDefinition.
7
7
  class NotificationDSL
8
- PAYLOAD_FIELDS = %i[text blocks attachments icon_emoji thread_ts files].freeze
8
+ PAYLOAD_FIELDS = %i[text blocks attachments icon_emoji thread_ts files slack_options].freeze
9
+
10
+ # Fields that modify a message rather than constitute its content. A notify block that sets
11
+ # only these has no real payload, so they don't count toward the "Missing payload" check.
12
+ PASSTHROUGH_FIELDS = %i[slack_options].freeze
9
13
 
10
14
  def initialize
11
15
  @channels = []
@@ -2,8 +2,6 @@
2
2
 
3
3
  module SlackSender
4
4
  class Profile # rubocop:disable Metrics/ClassLength
5
- SUPPORTED_SANDBOX_BEHAVIORS = %i[redirect noop passthrough].freeze
6
-
7
5
  # Valid kwargs accepted by Profile#call / Profile#call!
8
6
  # These are validated early (before backgrounding) to catch typos like `test:` instead of `text:`
9
7
  VALID_CALL_KWARGS = %i[
@@ -16,9 +14,14 @@ module SlackSender
16
14
  thread_ts
17
15
  file
18
16
  files
17
+ slack_options
19
18
  profile
20
19
  ].freeze
21
20
 
21
+ # Keys whose (possibly nested) symbol keys must be stringified before an async enqueue, so
22
+ # Sidekiq's strict argument checking doesn't reject them. Add new passthrough-style keys here.
23
+ ASYNC_SERIALIZABLE_KEYS = %i[blocks attachments slack_options].freeze
24
+
22
25
  attr_reader :default_channel, :channels, :user_groups, :slack_client_config, :key, :sandbox
23
26
 
24
27
  def initialize(key:, token:, default_channel: nil, channels: {}, user_groups: {}, slack_client_config: {}, sandbox: {})
@@ -62,13 +65,16 @@ module SlackSender
62
65
  user_group: normalize_sandbox_user_group(config[:user_group]),
63
66
  }.compact
64
67
 
65
- # Extract and validate behavior if present
68
+ # Extract and validate behavior if present. Shares Configuration::SUPPORTED_SANDBOX_BEHAVIORS
69
+ # (single source of truth) and mirrors its `one_of:` DSL wording, so a mistyped
70
+ # `sandbox: { behavior: ... }` and a mistyped `config.sandbox_default_behavior =` read the
71
+ # same way.
66
72
  if config[:behavior]
67
73
  behavior = config[:behavior].to_sym
68
- unless SUPPORTED_SANDBOX_BEHAVIORS.include?(behavior)
74
+ supported = Configuration::SUPPORTED_SANDBOX_BEHAVIORS
75
+ unless supported.include?(behavior)
69
76
  raise ArgumentError,
70
- "Unsupported sandbox behavior: #{behavior.inspect}. " \
71
- "Supported behaviors: #{SUPPORTED_SANDBOX_BEHAVIORS.inspect}"
77
+ "sandbox.behavior must be one of #{supported.map(&:inspect).join(", ")}; got #{behavior.inspect}"
72
78
  end
73
79
  result[:behavior] = behavior
74
80
  end
@@ -97,8 +103,11 @@ module SlackSender
97
103
 
98
104
  public
99
105
 
100
- def call(**)
101
- enabled, kwargs = enabled_and_preprocessed_kwargs(**)
106
+ # sandbox_mode: threads a per-action sandbox override down to DeliveryAxn (resolved by the
107
+ # strategy from the caller's configure(:slack_sender)). :inherit means "not overridden" — omit
108
+ # it so DeliveryAxn falls back to global config, preserving behavior for every other caller.
109
+ def call(sandbox_mode: :inherit, **kwargs)
110
+ enabled, kwargs = enabled_and_preprocessed_kwargs(**kwargs)
102
111
  return false unless enabled
103
112
 
104
113
  # Validate async backend is configured and available
@@ -114,6 +123,8 @@ module SlackSender
114
123
  "Profile must be registered before using async delivery. Register it with SlackSender.register(name, config)"
115
124
  end
116
125
 
126
+ kwargs[:sandbox_mode] = sandbox_mode unless sandbox_mode == :inherit
127
+
117
128
  if kwargs[:channels]
118
129
  dispatch_to_channels(kwargs)
119
130
  else
@@ -123,12 +134,14 @@ module SlackSender
123
134
  true
124
135
  end
125
136
 
126
- def call!(**)
127
- enabled, kwargs = enabled_and_preprocessed_kwargs(**)
137
+ def call!(sandbox_mode: :inherit, **kwargs)
138
+ enabled, kwargs = enabled_and_preprocessed_kwargs(**kwargs)
128
139
  return false unless enabled
129
140
 
130
141
  raise ArgumentError, ErrorMessages::MULTI_CHANNEL_SYNC_NOT_SUPPORTED if kwargs[:channels]
131
142
 
143
+ kwargs[:sandbox_mode] = sandbox_mode unless sandbox_mode == :inherit
144
+
132
145
  DeliveryAxn.call!(profile: self, **kwargs).thread_ts
133
146
  end
134
147
 
@@ -195,7 +208,7 @@ module SlackSender
195
208
  normalize_file_to_files!(kwargs)
196
209
  normalize_and_apply_channels!(kwargs)
197
210
  validate_and_handle_profile_parameter!(kwargs)
198
- preprocess_blocks_and_attachments!(kwargs)
211
+ normalize_async_serializable_keys!(kwargs)
199
212
  end
200
213
  end
201
214
 
@@ -283,11 +296,8 @@ module SlackSender
283
296
  kwargs[:validate_known_channel] = true
284
297
  end
285
298
 
286
- def preprocess_blocks_and_attachments!(kwargs)
287
- # Convert symbol keys to strings in blocks and attachments for JSON serialization
288
- # This ensures they're serializable for async jobs (Sidekiq/ActiveJob)
289
- normalize_for_async_serialization!(kwargs, :blocks)
290
- normalize_for_async_serialization!(kwargs, :attachments)
299
+ def normalize_async_serializable_keys!(kwargs)
300
+ ASYNC_SERIALIZABLE_KEYS.each { |key| normalize_for_async_serialization!(kwargs, key) }
291
301
  end
292
302
 
293
303
  def normalize_for_async_serialization!(kwargs, key)
@@ -86,10 +86,17 @@ module SlackSender
86
86
 
87
87
  raise ArgumentError, "No channel(s) specified and no default channel configured" unless channel || channels
88
88
 
89
+ # Resolve this action's per-class sandbox override (configure(:slack_sender)) at the origin,
90
+ # where the action class is known, and thread it down. :inherit when unset leaves global
91
+ # config in charge — unchanged for every action that doesn't opt in.
92
+ found, override = SlackSender::Configuration.class_override(self.class, :sandbox_mode)
93
+ sandbox_mode = found ? override : :inherit
94
+ target = SlackSender.profile(profile)
95
+
89
96
  if channels
90
- SlackSender.profile(profile).public_send(method, channels:, **merged)
97
+ target.public_send(method, channels:, sandbox_mode:, **merged)
91
98
  else
92
- SlackSender.profile(profile).public_send(method, channel:, **merged)
99
+ target.public_send(method, channel:, sandbox_mode:, **merged)
93
100
  end
94
101
  end
95
102
  end
@@ -13,6 +13,17 @@ module SlackSender
13
13
  NON_RETRYABLE_CHANNEL_ERRORS.any? { |klass| exception.is_a?(klass) }
14
14
  end
15
15
 
16
+ # True if the exception is a permanent InvalidArgumentsError — either raised directly, or
17
+ # wrapped by axn in an Axn::ContractViolation::PreprocessingError (which happens when it is
18
+ # raised from an `expects ... preprocess:` lambda, e.g. an unknown channel). The async worker
19
+ # re-raises result.exception, so the wrapped form is what the retry hooks see. Such jobs can
20
+ # never succeed on retry, so both async backends use this to discard them.
21
+ def self.invalid_arguments_error?(exception)
22
+ return true if exception.is_a?(SlackSender::InvalidArgumentsError)
23
+
24
+ exception.is_a?(Axn::ContractViolation::PreprocessingError) && exception.cause.is_a?(SlackSender::InvalidArgumentsError)
25
+ end
26
+
16
27
  # Checks if kwargs represent an explicit blank text-only call (no other content keys).
17
28
  # Used to treat such calls as no-ops rather than errors.
18
29
  # @param kwargs [Hash] The keyword arguments to check
@@ -26,7 +37,8 @@ module SlackSender
26
37
  kwargs[:blocks].blank? &&
27
38
  kwargs[:attachments].blank? &&
28
39
  kwargs[:file].blank? &&
29
- kwargs[:files].blank?
40
+ kwargs[:files].blank? &&
41
+ kwargs[:slack_options].blank?
30
42
  end
31
43
 
32
44
  # Determines retry behavior for Slack API exceptions
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SlackSender
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end