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.
- checksums.yaml +7 -0
- data/.husky/pre-commit +1 -0
- data/.lintstagedrc +1 -0
- data/CHANGELOG.md +7 -0
- data/README.md +97 -0
- data/Rakefile +15 -0
- data/docs/axn_integration.md +168 -0
- data/docs/configuration.md +252 -0
- data/docs/troubleshooting.md +160 -0
- data/docs/usage.md +382 -0
- data/lib/slack_sender/channel_normalizer.rb +72 -0
- data/lib/slack_sender/configuration.rb +121 -0
- data/lib/slack_sender/delivery_axn/async_configuration.rb +50 -0
- data/lib/slack_sender/delivery_axn/error_message_parsing.rb +75 -0
- data/lib/slack_sender/delivery_axn/exception_handlers.rb +36 -0
- data/lib/slack_sender/delivery_axn/validation.rb +37 -0
- data/lib/slack_sender/delivery_axn.rb +217 -0
- data/lib/slack_sender/error_messages.rb +45 -0
- data/lib/slack_sender/file_uploader.rb +64 -0
- data/lib/slack_sender/file_wrapper.rb +72 -0
- data/lib/slack_sender/multi_file_wrapper.rb +49 -0
- data/lib/slack_sender/notifier/notification_definition.rb +66 -0
- data/lib/slack_sender/notifier/notification_dsl.rb +59 -0
- data/lib/slack_sender/notifier.rb +75 -0
- data/lib/slack_sender/profile.rb +314 -0
- data/lib/slack_sender/profile_registry.rb +34 -0
- data/lib/slack_sender/rails/engine.rb +31 -0
- data/lib/slack_sender/strategy.rb +98 -0
- data/lib/slack_sender/util.rb +88 -0
- data/lib/slack_sender/version.rb +5 -0
- data/lib/slack_sender.rb +71 -0
- metadata +111 -0
|
@@ -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
|
data/lib/slack_sender.rb
ADDED
|
@@ -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: []
|