slack_sender 0.1.0

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.
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackSender
4
+ # Axn strategy that provides `slack(...)` and `slack!(...)` methods for sending Slack messages.
5
+ #
6
+ # Usage:
7
+ # class MyAction
8
+ # include Axn
9
+ # use :slack, channel: :general
10
+ #
11
+ # on_success { slack "It worked!" }
12
+ #
13
+ # def call
14
+ # slack "Processing..."
15
+ # # ...
16
+ # end
17
+ # end
18
+ #
19
+ # Configuration options:
20
+ # channel: - Default channel for all slack() calls (can be overridden per-call)
21
+ # profile: - SlackSender profile to use (default: :default)
22
+ #
23
+ # All options can be overridden per-call. Call-time values take precedence over defaults.
24
+ #
25
+ # Methods:
26
+ # slack(...) - Async delivery via background job (recommended, enables auto-retry)
27
+ # slack!(...) - Sync delivery in foreground (immediate execution, no auto-retry)
28
+ #
29
+ module Strategy
30
+ def self.configure(**defaults)
31
+ Module.new do
32
+ extend ActiveSupport::Concern
33
+
34
+ included do
35
+ define_method(:__slack_defaults) { defaults }
36
+ private :__slack_defaults
37
+ end
38
+
39
+ # Send a Slack message asynchronously via background job.
40
+ # Enables automatic retries for failed sends.
41
+ #
42
+ # @param text [String, nil] Optional positional argument for message text (sugar for text: kwarg)
43
+ # @param kwargs [Hash] SlackSender options (channel:, profile:, blocks:, attachments:, icon_emoji:, etc.)
44
+ # @return [true, false] true if message was enqueued, false if sending is disabled
45
+ # @raise [ArgumentError] If no channel specified and no default configured
46
+ # @raise [SlackSender::Error] If no async backend is configured
47
+ #
48
+ # Examples:
49
+ # slack "Hello" # positional text, uses default channel
50
+ # slack "Hello", channel: :other # positional text, override channel
51
+ # slack text: "Hello", channel: :other # explicit kwargs
52
+ # slack channel: :foo, text: "Hi", blocks: [...] # full kwargs
53
+ # slack "Hi", profile: :other_profile # override profile for this call
54
+ #
55
+ def slack(text = nil, **kwargs)
56
+ __slack_deliver(text, :call, **kwargs)
57
+ end
58
+
59
+ # Send a Slack message synchronously in the foreground.
60
+ # Use when you need immediate execution or the thread_ts return value.
61
+ #
62
+ # @param text [String, nil] Optional positional argument for message text (sugar for text: kwarg)
63
+ # @param kwargs [Hash] SlackSender options (channel:, profile:, blocks:, attachments:, icon_emoji:, etc.)
64
+ # @return [String, nil] Thread timestamp from Slack response, or false if sending is disabled
65
+ # @raise [ArgumentError] If no channel specified and no default configured
66
+ #
67
+ # Examples:
68
+ # thread_ts = slack! "Hello" # get thread_ts for replies
69
+ # slack! "Urgent!", channel: :alerts # immediate delivery
70
+ #
71
+ def slack!(text = nil, **kwargs)
72
+ __slack_deliver(text, :call!, **kwargs)
73
+ end
74
+
75
+ private
76
+
77
+ def __slack_deliver(text, method, **kwargs)
78
+ kwargs[:text] = text if text
79
+
80
+ # Merge defaults with call-time kwargs (call-time wins)
81
+ merged = __slack_defaults.merge(kwargs)
82
+
83
+ channel = merged.delete(:channel)
84
+ channels = merged.delete(:channels)
85
+ profile = merged.delete(:profile) || :default
86
+
87
+ raise ArgumentError, "No channel(s) specified and no default channel configured" unless channel || channels
88
+
89
+ if channels
90
+ SlackSender.profile(profile).public_send(method, channels:, **merged)
91
+ else
92
+ SlackSender.profile(profile).public_send(method, channel:, **merged)
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackSender
4
+ module Util
5
+ # Channel-related errors that should not be retried (permanent failures)
6
+ NON_RETRYABLE_CHANNEL_ERRORS = [
7
+ ::Slack::Web::Api::Errors::NotInChannel,
8
+ ::Slack::Web::Api::Errors::ChannelNotFound,
9
+ ::Slack::Web::Api::Errors::IsArchived,
10
+ ].freeze
11
+
12
+ def self.non_retryable_channel_error?(exception)
13
+ NON_RETRYABLE_CHANNEL_ERRORS.any? { |klass| exception.is_a?(klass) }
14
+ end
15
+
16
+ # Checks if kwargs represent an explicit blank text-only call (no other content keys).
17
+ # Used to treat such calls as no-ops rather than errors.
18
+ # @param kwargs [Hash] The keyword arguments to check
19
+ # @return [Boolean] true if text is the only content key and is blank
20
+ def self.blank_text_only?(kwargs)
21
+ # Check for presence (not just key existence) since provided_data may include
22
+ # empty arrays from defaults
23
+ kwargs.key?(:text) &&
24
+ kwargs[:text].is_a?(String) &&
25
+ kwargs[:text].blank? &&
26
+ kwargs[:blocks].blank? &&
27
+ kwargs[:attachments].blank? &&
28
+ kwargs[:file].blank? &&
29
+ kwargs[:files].blank?
30
+ end
31
+
32
+ # Determines retry behavior for Slack API exceptions
33
+ # @param exception [Exception] The exception that occurred
34
+ # @return [Symbol, Integer, nil] :discard to skip retry, Integer (seconds) for custom delay, nil for default retry
35
+ def self.parse_retry_delay_from_exception(exception)
36
+ # Discard known-do-not-retry exceptions
37
+ return :discard if non_retryable_channel_error?(exception)
38
+
39
+ # Check for retry headers from Slack (e.g., rate limits)
40
+ if exception.respond_to?(:response_headers) && exception.response_headers.is_a?(Hash)
41
+ retry_after = exception.response_headers["Retry-After"] || exception.response_headers["retry-after"]
42
+ return add_jitter(retry_after.to_i) if retry_after.present?
43
+ end
44
+
45
+ # Default: let the backend use its default retry behavior
46
+ nil
47
+ end
48
+
49
+ # Adds random jitter to a delay to prevent thundering herd
50
+ # @param delay [Integer] The base delay in seconds
51
+ # @return [Integer] The delay with jitter added (0-30% extra)
52
+ def self.add_jitter(delay)
53
+ return delay if delay <= 0
54
+
55
+ jitter = (delay * rand * 0.3).ceil
56
+ delay + jitter
57
+ end
58
+
59
+ # Extracts the needed scope from a MissingScope exception.
60
+ # Tries multiple locations since slack-ruby-client's structure varies:
61
+ # - response_metadata["needed"] (documented but not always present)
62
+ # - response.body.needed (Slack::Messages::Message object)
63
+ # - HTTP header x-accepted-oauth-scopes
64
+ # @param exception [Slack::Web::Api::Errors::MissingScope] The exception
65
+ # @return [String, nil] The needed scope, or nil if not found
66
+ def self.extract_needed_scope(exception)
67
+ # Try response_metadata first (documented location)
68
+ exception.response_metadata&.dig("needed") ||
69
+ # Try response.body which is a Slack::Messages::Message
70
+ exception.response&.body&.try(:needed) ||
71
+ exception.response&.body&.try(:[], "needed") ||
72
+ # Try HTTP headers as fallback
73
+ exception.response&.env&.dig(:response_headers, "x-accepted-oauth-scopes")
74
+ end
75
+
76
+ # Builds a descriptive error message for MissingScope exceptions.
77
+ # @param exception [Slack::Web::Api::Errors::MissingScope] The exception
78
+ # @return [String] A user-friendly error message
79
+ def self.missing_scope_error_message(exception)
80
+ needed = extract_needed_scope(exception)
81
+ if needed.present?
82
+ format(ErrorMessages::MISSING_SCOPE, needed)
83
+ else
84
+ ErrorMessages::MISSING_SCOPE_UNKNOWN
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SlackSender
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+ require "active_support/core_ext/hash/keys"
5
+ require "active_support/concern"
6
+ require "slack-ruby-client"
7
+ begin
8
+ require "sidekiq"
9
+ rescue LoadError
10
+ # Sidekiq is optional for runtime, only needed for async operations
11
+ end
12
+ begin
13
+ require "active_job"
14
+ rescue LoadError
15
+ # ActiveJob is optional for runtime, only needed for async operations
16
+ end
17
+ require "axn"
18
+ require_relative "slack_sender/version"
19
+ require_relative "slack_sender/configuration"
20
+ require_relative "slack_sender/util"
21
+ require_relative "slack_sender/error_messages"
22
+
23
+ module SlackSender
24
+ class Error < StandardError; end
25
+
26
+ # Raised for invalid arguments that should not be retried
27
+ # (e.g., missing content, invalid blocks, incompatible options)
28
+ class InvalidArgumentsError < Error; end
29
+ end
30
+
31
+ require_relative "slack_sender/channel_normalizer"
32
+ require_relative "slack_sender/profile"
33
+ require_relative "slack_sender/profile_registry"
34
+ require_relative "slack_sender/delivery_axn"
35
+ require_relative "slack_sender/file_wrapper"
36
+ require_relative "slack_sender/multi_file_wrapper"
37
+ require_relative "slack_sender/file_uploader"
38
+ require_relative "slack_sender/strategy"
39
+
40
+ # Register the slack strategy with Axn (before loading Notifier which uses it)
41
+ Axn::Strategies.register(:slack, SlackSender::Strategy)
42
+
43
+ require_relative "slack_sender/notifier"
44
+
45
+ module SlackSender
46
+ class << self
47
+ def register(name = nil, **config)
48
+ ProfileRegistry.register(name.presence || :default, config)
49
+ end
50
+
51
+ def profile(name)
52
+ ProfileRegistry.find(name)
53
+ end
54
+
55
+ def [](name) = profile(name)
56
+
57
+ def default_profile
58
+ ProfileRegistry.find(:default)
59
+ rescue ProfileNotFound
60
+ raise Error, "No default profile set. Call SlackSender.register(...) first"
61
+ end
62
+
63
+ def client = default_profile.client
64
+ def call(**) = default_profile.call(**)
65
+ def call!(**) = default_profile.call!(**)
66
+ def group_link(key) = default_profile.group_link(key)
67
+ end
68
+ end
69
+
70
+ # Rails integration (if in Rails context)
71
+ require_relative "slack_sender/rails/engine" if defined?(Rails) && Rails.const_defined?(:Engine)
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: slack_sender
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kali Donovan
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-02-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: axn
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 0.1.0.pre.alpha.4.1
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: 0.2.0
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 0.1.0.pre.alpha.4.1
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.2.0
33
+ - !ruby/object:Gem::Dependency
34
+ name: slack-ruby-client
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ description: Slack messaging with background dispatch with automatic rate-limit retries.
48
+ email:
49
+ - kali@teamshares.com
50
+ executables: []
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - ".husky/pre-commit"
55
+ - ".lintstagedrc"
56
+ - CHANGELOG.md
57
+ - README.md
58
+ - Rakefile
59
+ - docs/axn_integration.md
60
+ - docs/configuration.md
61
+ - docs/troubleshooting.md
62
+ - docs/usage.md
63
+ - lib/slack_sender.rb
64
+ - lib/slack_sender/channel_normalizer.rb
65
+ - lib/slack_sender/configuration.rb
66
+ - lib/slack_sender/delivery_axn.rb
67
+ - lib/slack_sender/delivery_axn/async_configuration.rb
68
+ - lib/slack_sender/delivery_axn/error_message_parsing.rb
69
+ - lib/slack_sender/delivery_axn/exception_handlers.rb
70
+ - lib/slack_sender/delivery_axn/validation.rb
71
+ - lib/slack_sender/error_messages.rb
72
+ - lib/slack_sender/file_uploader.rb
73
+ - lib/slack_sender/file_wrapper.rb
74
+ - lib/slack_sender/multi_file_wrapper.rb
75
+ - lib/slack_sender/notifier.rb
76
+ - lib/slack_sender/notifier/notification_definition.rb
77
+ - lib/slack_sender/notifier/notification_dsl.rb
78
+ - lib/slack_sender/profile.rb
79
+ - lib/slack_sender/profile_registry.rb
80
+ - lib/slack_sender/rails/engine.rb
81
+ - lib/slack_sender/strategy.rb
82
+ - lib/slack_sender/util.rb
83
+ - lib/slack_sender/version.rb
84
+ homepage: https://github.com/teamshares/slack_sender
85
+ licenses:
86
+ - MIT
87
+ metadata:
88
+ homepage_uri: https://github.com/teamshares/slack_sender
89
+ source_code_uri: https://github.com/teamshares/slack_sender
90
+ changelog_uri: https://github.com/teamshares/slack_sender/blob/main/CHANGELOG.md
91
+ rubygems_mfa_required: 'true'
92
+ post_install_message:
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: 3.2.1
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubygems_version: 3.5.22
108
+ signing_key:
109
+ specification_version: 4
110
+ summary: Slack messages for people who don’t want to babysit Slack.
111
+ test_files: []