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 +4 -4
- data/CHANGELOG.md +57 -1
- data/README.md +7 -6
- data/lib/slack_sender/configuration.rb +56 -26
- data/lib/slack_sender/delivery_axn/async_configuration.rb +26 -8
- data/lib/slack_sender/delivery_axn/validation.rb +9 -3
- data/lib/slack_sender/delivery_axn.rb +54 -5
- data/lib/slack_sender/error_messages.rb +7 -3
- data/lib/slack_sender/file_wrapper.rb +3 -2
- data/lib/slack_sender/notifier/notification_definition.rb +5 -2
- data/lib/slack_sender/notifier/notification_dsl.rb +5 -1
- data/lib/slack_sender/profile.rb +26 -16
- data/lib/slack_sender/strategy.rb +9 -2
- data/lib/slack_sender/util.rb +13 -1
- data/lib/slack_sender/version.rb +1 -1
- data/lib/slack_sender.rb +14 -16
- metadata +12 -13
- data/.husky/pre-commit +0 -1
- data/.lintstagedrc +0 -1
- data/Rakefile +0 -15
- data/docs/axn_integration.md +0 -168
- data/docs/configuration.md +0 -252
- data/docs/troubleshooting.md +0 -160
- data/docs/usage.md +0 -382
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 00e8fd0b67a215e7066b491b3dc85afae96e4eec260bb156bf8a8835c1a96269
|
|
4
|
+
data.tar.gz: ed385d6feb6cfd4fadd85d05f4a7716ec021b97c64e26156fa798cda9d7bd80b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3ba5e9b7ddf025229eef2707025aa6871044c1f17dfb74e330d5e95852aa08c4589b719743c23b4e72fa2fe83b61b1c8cef3d051b4d1f32882576a12db2bcfcf
|
|
7
|
+
data.tar.gz: b15b68b15932c145ccbcb855609dcd3e405716288565f43493528b943fef0c7be2a3ddd2978641798392bf4a562fff4b664d6fc99962b7226df82c74faf0ad5e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
50
|
+
@sandbox_mode = value
|
|
37
51
|
end
|
|
38
52
|
end
|
|
39
53
|
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
23
|
-
#
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
35
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
#
|
|
21
|
-
#
|
|
22
|
-
error(
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
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 = "
|
|
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
|
|
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
|
|
34
|
-
|
|
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 = []
|
data/lib/slack_sender/profile.rb
CHANGED
|
@@ -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
|
-
|
|
74
|
+
supported = Configuration::SUPPORTED_SANDBOX_BEHAVIORS
|
|
75
|
+
unless supported.include?(behavior)
|
|
69
76
|
raise ArgumentError,
|
|
70
|
-
"
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
287
|
-
|
|
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
|
-
|
|
97
|
+
target.public_send(method, channels:, sandbox_mode:, **merged)
|
|
91
98
|
else
|
|
92
|
-
|
|
99
|
+
target.public_send(method, channel:, sandbox_mode:, **merged)
|
|
93
100
|
end
|
|
94
101
|
end
|
|
95
102
|
end
|
data/lib/slack_sender/util.rb
CHANGED
|
@@ -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
|
data/lib/slack_sender/version.rb
CHANGED