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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 320590e57f220cb0e85688e3987c1fdee1b9f1bc014a9907099ec277568a06f6
4
+ data.tar.gz: b478553cd49da506001a5223673cc9a2ca0d2adf79cba6d6fc4e5a317f33db89
5
+ SHA512:
6
+ metadata.gz: a5153bb970a492024ac5ddc16f1fab9e6debe046636f87330c4ab19ebade650702123e2edc7a7d3473e9e31a84f51ac6e0830cae0be2add5c18542996ae89b8d
7
+ data.tar.gz: 5c5737bfa9581c9766d0eb66b4d20367501006f2b40acff2f41aafabc3455be2db2c9174b1fa3feff31f5d70db032124b4928a7c2d1fb2140975f792cd8a614c
data/.husky/pre-commit ADDED
@@ -0,0 +1 @@
1
+ npx lint-staged
data/.lintstagedrc ADDED
@@ -0,0 +1 @@
1
+ {"*.rb":["bundle exec rubocop -A -c .rubocop.yml --force-exclusion"]}
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ ## [Unreleased]
2
+
3
+ * N/A
4
+
5
+ ## [0.1.0] - 2026-02-19
6
+
7
+ - Initial release
data/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # SlackSender
2
+
3
+ **Reliable Slack messaging for Ruby — with automatic retries, rate-limit handling, and sandbox safety.**
4
+
5
+ SlackSender handles the plumbing so you can focus on your application: background dispatch, retry logic, multi-workspace support, and development environment redirects are all built in.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem 'slack_sender'
13
+ ```
14
+
15
+ Then run:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ **Requirements:**
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)
24
+ - For async delivery: Sidekiq or ActiveJob (auto-detected)
25
+
26
+ ## Quick Start - Minimal
27
+
28
+ ```ruby
29
+ SlackSender.register(token: ENV['SLACK_BOT_TOKEN'])
30
+ ```
31
+
32
+ ```ruby
33
+ SlackSender.call!(channel: "some_channel_name", text: "Hi there")
34
+ ```
35
+
36
+ ## Quick Start - Realistic
37
+
38
+ ### 1. Register a Profile
39
+
40
+ ```ruby
41
+ SlackSender.register(
42
+ token: ENV['SLACK_BOT_TOKEN'],
43
+ channels: {
44
+ ops_alerts: 'C1111111111',
45
+ deployments: 'C2222222222',
46
+ },
47
+ sandbox: {
48
+ channel: { replace_with: 'C_DEV_CHANNEL' } # Redirects in non-production
49
+ }
50
+ )
51
+ ```
52
+
53
+ ### 2. Send Messages
54
+
55
+ ```ruby
56
+ # Async (recommended) — background job with automatic retries
57
+ SlackSender.call(channel: :ops_alerts, text: ":rotating_light: High error rate detected")
58
+
59
+ # Sync — when you need the thread timestamp
60
+ thread_ts = SlackSender.call!(channel: :deployments, text: ":rocket: Deploy started")
61
+ SlackSender.call(channel: :deployments, text: "Deploy complete!", thread_ts:)
62
+ ```
63
+
64
+ That's it. SlackSender handles rate limits, retries, and sandbox redirection (if configured, all messages sent from non-production environments will be delivered to your `replace_with` channel) automatically.
65
+
66
+ ## Documentation
67
+
68
+ | Guide | Description |
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 |
74
+
75
+ ## Features
76
+
77
+ - **Background dispatch** with automatic rate-limit retries via Sidekiq or ActiveJob
78
+ - **Multi-channel delivery** — broadcast to multiple channels efficiently
79
+ - **Sandbox mode** — redirect or suppress messages in non-production environments
80
+ - **File uploads** — sync and async, with automatic size handling
81
+ - **Multiple profiles** — manage multiple Slack workspaces
82
+ - **Axn integration** — `use :slack` strategy and dedicated `Notifier` base class
83
+
84
+ ## Development
85
+
86
+ ```bash
87
+ bin/setup # Install dependencies
88
+ bundle exec rspec # Run tests
89
+ ```
90
+
91
+ ## Contributing
92
+
93
+ Bug reports and pull requests are welcome on GitHub at https://github.com/teamshares/slack_sender.
94
+
95
+ ## License
96
+
97
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
9
+ RuboCop::RakeTask.new
10
+
11
+ task default: %i[spec rubocop]
12
+
13
+ # Require default to pass before release. This relies on the default gem release task
14
+ # (from bundler/gem_tasks) depending on "build"; default runs before build, so before push.
15
+ Rake::Task["build"].enhance([:default])
@@ -0,0 +1,168 @@
1
+ # Axn Integration
2
+
3
+ [← Back to README](../README.md)
4
+
5
+ SlackSender provides deep integration with [Axn](https://teamshares.github.io/axn/) for building Slack-enabled actions and dedicated notifier classes.
6
+
7
+ ## Slack Strategy for Axn Actions
8
+
9
+ Add Slack messaging capabilities to any Axn action using the `:slack` strategy:
10
+
11
+ ```ruby
12
+ class Deployments::Finish
13
+ include Axn
14
+ use :slack, channel: :deployments # Default channel for all slack() calls
15
+
16
+ expects :deployment, type: Deployment
17
+
18
+ on_success { slack ":rocket: Deploy finished for `#{deployment.service}`" }
19
+ on_failure { slack ":x: Deploy failed for `#{deployment.service}`", channel: :ops_alerts }
20
+
21
+ def call
22
+ # slack() is async (background job) - recommended for fire-and-forget
23
+ slack "Finalizing deploy for `#{deployment.service}`..."
24
+
25
+ # slack!() is sync - use when you need the thread_ts
26
+ thread_ts = slack! "Starting rollout..."
27
+ # ... rollout / status checks / persistence ...
28
+ slack "Rollout complete!", thread_ts: thread_ts
29
+ end
30
+ end
31
+ ```
32
+
33
+ ### Strategy Configuration
34
+
35
+ ```ruby
36
+ use :slack, channel: :general # Default channel for all slack() calls
37
+ use :slack, channel: :general, profile: :support # Use a specific SlackSender profile
38
+ use :slack, channels: [:alerts, :ops] # Default to multiple channels (async only)
39
+ use :slack # No default channel (must pass channel: each time)
40
+ ```
41
+
42
+ ### The `slack(...)` and `slack!(...)` Methods
43
+
44
+ The strategy adds two instance methods for sending Slack messages:
45
+
46
+ | Method | Delivery | Return Value | Use When |
47
+ |--------|----------|--------------|----------|
48
+ | `slack(...)` | Async (background job) | `true` or `false` | Default; enables auto-retry for rate limits |
49
+ | `slack!(...)` | Sync (immediate) | Thread timestamp or `false` | You need the `thread_ts` return value |
50
+
51
+ ```ruby
52
+ # Async delivery (recommended) - uses Sidekiq or ActiveJob
53
+ slack "Hello world"
54
+ slack "Hello", channel: :other_channel
55
+
56
+ # Sync delivery - immediate execution, returns thread_ts
57
+ thread_ts = slack! "Starting deployment..."
58
+ slack! "Deployment finished", thread_ts: thread_ts
59
+
60
+ # Full kwargs work with both methods
61
+ slack text: "Hello", channel: :ops_alerts, icon_emoji: "robot"
62
+ slack! channel: :ops_alerts, blocks: [{ type: "section", text: { type: "mrkdwn", text: "*Bold*" } }]
63
+ ```
64
+
65
+ **Note:** `slack(...)` requires an async backend to be configured (Sidekiq or ActiveJob). If no async backend is available, it raises `SlackSender::Error` with instructions to either use `slack!(...)` or configure an async backend.
66
+
67
+ ---
68
+
69
+ ## SlackSender::Notifier Base Class
70
+
71
+ For actions whose sole purpose is sending Slack notifications, inherit from `SlackSender::Notifier`. These are built on top of Axn (that's where the `expects` DSL comes from below), so you'll want to [familiarize yourself with that library](https://teamshares.github.io/axn/) before continuing:
72
+
73
+ ```ruby
74
+ # app/slack_notifiers/deployments/finished.rb
75
+ module SlackNotifiers
76
+ module Deployments
77
+ class Finished < SlackSender::Notifier
78
+ expects :deployment_id, type: Integer
79
+
80
+ # Post to the deployments channel for production releases
81
+ notify do
82
+ channel :deployments
83
+ only_if { production_release? }
84
+ text { ":rocket: *Deploy finished* for `#{deployment.service}` (#{deployment.environment})" }
85
+ end
86
+
87
+ # Optionally also post in the incident channel if this deploy is related to an incident
88
+ notify do
89
+ channel :incident_channel_id
90
+ only_if { incident_channel_id.present? }
91
+ text { ":rocket: *Deploy finished* for `#{deployment.service}` (#{deployment.environment})" }
92
+ end
93
+
94
+ private
95
+
96
+ def production_release? = deployment.environment.to_s == "production"
97
+
98
+ # Dynamic channel ID string (e.g., "C123...") sourced from your domain model
99
+ def incident_channel_id = deployment.incident_slack_channel_id
100
+
101
+ def deployment = @deployment ||= Deployment.find(deployment_id)
102
+ end
103
+ end
104
+ end
105
+
106
+ # Call it like any Axn
107
+ SlackNotifiers::Deployments::Finished.call(deployment_id: 123)
108
+ ```
109
+
110
+ ---
111
+
112
+ ## The `notify do ... end` DSL
113
+
114
+ The `notify` block groups all Slack message configuration together, keeping it visually separated from Axn declarations like `expects`:
115
+
116
+ ```ruby
117
+ notify do
118
+ channel :notifications # Single channel
119
+ text { "Hello!" } # Dynamic text (block)
120
+ end
121
+
122
+ notify do
123
+ channels :ops_alerts, :ic # Multiple channels (files uploaded once, shared to all)
124
+ only_if { priority == :high } # Conditional send
125
+ text :message_text # Text from method
126
+ attachments :build_attachments # Attachments from method
127
+ end
128
+ ```
129
+
130
+ ### DSL Options
131
+
132
+ | Option | Description |
133
+ |--------|-------------|
134
+ | `channel :sym` | Single channel (symbol resolved via profile, or method if defined) |
135
+ | `channels :a, :b` | Multiple channels |
136
+ | `text { ... }` | Text content (block evaluated in instance context) |
137
+ | `text :method` | Text from method |
138
+ | `text "static"` | Static text |
139
+ | `blocks { ... }` | Slack blocks |
140
+ | `attachments { ... }` | Slack attachments |
141
+ | `icon_emoji :emoji` | Custom emoji |
142
+ | `thread_ts :method` | Thread timestamp |
143
+ | `files { ... }` | File attachments |
144
+ | `only_if { ... }` | Condition (block) — only send if truthy |
145
+ | `only_if :method` | Condition (method) — only send if truthy |
146
+ | `profile :name` | Use a specific SlackSender profile |
147
+
148
+ ### Value Resolution
149
+
150
+ For each field, values are resolved in this order:
151
+ 1. **Block**: `text { "dynamic #{value}" }` — evaluated in instance context
152
+ 2. **Symbol**: `text :my_method` — calls method if it exists, otherwise treated as literal
153
+ 3. **Literal**: `text "static"` — used as-is
154
+
155
+ ### Required Fields
156
+
157
+ - At least one `channel` or `channels`
158
+ - At least one payload field (`text`, `blocks`, `attachments`, or `files`)
159
+
160
+ ---
161
+
162
+ ## Notifier Features
163
+
164
+ Since `SlackSender::Notifier` inherits from Axn, you get:
165
+ - `expects` / `exposes` for input/output contracts
166
+ - Hooks (`before`, `after`, `on_success`, `on_failure`)
167
+ - Automatic logging and error handling
168
+ - Async execution with `call_async`
@@ -0,0 +1,252 @@
1
+ # Configuration
2
+
3
+ [← Back to README](../README.md)
4
+
5
+ This guide covers global configuration, profile registration, and sandbox mode settings.
6
+
7
+ ## Global Configuration
8
+
9
+ Configure SlackSender behavior via `SlackSender.configure` (e.g. in a Rails initializer):
10
+
11
+ ```ruby
12
+ SlackSender.configure do |config|
13
+ # Set async backend (auto-detects Sidekiq or ActiveJob if available)
14
+ config.async_backend = :sidekiq # or :active_job
15
+
16
+ # Set sandbox mode (affects sandbox channel/user_group redirects)
17
+ # Defaults to true in non-production, false in production
18
+ config.sandbox_mode = !Rails.env.production?
19
+
20
+ # Set default sandbox behavior when sandbox_mode is true but profile
21
+ # doesn't specify a sandbox.mode or sandbox.channel.replace_with
22
+ # Options: :noop (default), :redirect, :passthrough
23
+ config.sandbox_default_behavior = :noop
24
+
25
+ # Enable/disable SlackSender globally (default: true)
26
+ config.enabled = ENV["DISABLE_SLACK"] != "1"
27
+
28
+ # Silence archived channel exceptions (default: false)
29
+ config.silence_archived_channel_exceptions = false
30
+
31
+ # Control autoloading namespace for app/slack_notifiers (default: true)
32
+ # When true: app/slack_notifiers/foo.rb -> SlackNotifiers::Foo
33
+ # When false: app/slack_notifiers/foo.rb -> Foo (standard Rails behavior)
34
+ config.use_slack_notifiers_namespace = true
35
+ end
36
+ ```
37
+
38
+ ### Global Options Reference
39
+
40
+ | Option | Type | Default | Description |
41
+ |--------|------|---------|-------------|
42
+ | `async_backend` | `Symbol` or `nil` | Auto-detected | Backend for async delivery. Supported: `:sidekiq`, `:active_job` |
43
+ | `sandbox_mode` | `Boolean` or `nil` | `!Rails.env.production?` if Rails available, else `true` | Whether app is in sandbox mode |
44
+ | `sandbox_default_behavior` | `Symbol` | `:noop` | Default behavior when in sandbox mode if profile doesn't specify. Options: `:noop`, `:redirect`, `:passthrough` |
45
+ | `enabled` | `Boolean` | `true` | Global enable/disable flag. When `false`, `call` and `call!` return `false` without sending |
46
+ | `silence_archived_channel_exceptions` | `Boolean` | `false` | If `true`, silently ignores `IsArchived` errors instead of reporting them |
47
+ | `max_async_file_upload_size` | `Integer` or `nil` | `26_214_400` (25 MB) | Max total file size for async uploads. Set to `nil` to disable |
48
+ | `use_slack_notifiers_namespace` | `Boolean` | `true` | When `true`, files in `app/slack_notifiers` are autoloaded under the `SlackNotifiers` namespace |
49
+
50
+ ---
51
+
52
+ ## Profile Registration
53
+
54
+ A **profile** represents a Slack workspace configuration. Register profiles with `SlackSender.register`:
55
+
56
+ ```ruby
57
+ SlackSender.register(
58
+ token: ENV['SLACK_BOT_TOKEN'],
59
+ default_channel: :ops_alerts,
60
+ channels: {
61
+ ops_alerts: 'C1111111111',
62
+ deployments: 'C2222222222',
63
+ reports: 'C3333333333',
64
+ },
65
+ user_groups: {
66
+ engineers: 'S1234567890',
67
+ },
68
+ sandbox: {
69
+ channel: {
70
+ replace_with: 'C1234567890',
71
+ message_prefix: ':construction: _This message would have been sent to %s in production_'
72
+ },
73
+ user_group: {
74
+ replace_with: 'S_DEV_GROUP'
75
+ }
76
+ }
77
+ )
78
+ ```
79
+
80
+ ### Profile Options Reference
81
+
82
+ | Option | Type | Default | Description |
83
+ |--------|------|---------|-------------|
84
+ | `token` | `String` or callable | Required | Slack Bot User OAuth Token. Can be a proc/lambda for dynamic fetching |
85
+ | `default_channel` | `Symbol`, `String`, or `nil` | `nil` | Default channel when none is specified in `call`/`call!` |
86
+ | `channels` | `Hash` | `{}` | Hash mapping symbol keys to channel IDs (e.g., `{ alerts: 'C123' }`) |
87
+ | `user_groups` | `Hash` | `{}` | Hash mapping symbol keys to user group IDs (e.g., `{ engineers: 'S123' }`) |
88
+ | `slack_client_config` | `Hash` | `{}` | Additional options passed to `Slack::Web::Client` constructor |
89
+ | `sandbox` | `Hash` | `{}` | Sandbox mode configuration (see below) |
90
+
91
+ ### Dynamic Token
92
+
93
+ Use a callable for the token to fetch it dynamically:
94
+
95
+ ```ruby
96
+ SlackSender.register(
97
+ token: -> { SecretsManager.get_slack_token },
98
+ channels: { ops_alerts: 'C123' }
99
+ )
100
+ ```
101
+
102
+ The token is memoized after first access.
103
+
104
+ ### Multiple Profiles
105
+
106
+ Register multiple profiles for different Slack workspaces:
107
+
108
+ ```ruby
109
+ # Internal engineering workspace (default profile)
110
+ SlackSender.register(
111
+ token: ENV['SLACK_BOT_TOKEN'],
112
+ channels: { ops_alerts: 'C123', deployments: 'C234' }
113
+ )
114
+
115
+ # Customer support workspace
116
+ SlackSender.register(:support,
117
+ token: ENV['SUPPORT_SLACK_TOKEN'],
118
+ channels: { support_tickets: 'C456' }
119
+ )
120
+
121
+ # Use specific profile
122
+ SlackSender.profile(:support).call(
123
+ channel: :support_tickets,
124
+ text: "New high-priority ticket received"
125
+ )
126
+
127
+ # Or use bracket notation
128
+ SlackSender[:support].call(channel: :support_tickets, text: "...")
129
+
130
+ # Or override default profile with profile parameter
131
+ SlackSender.call(profile: :support, channel: :support_tickets, text: "...")
132
+ ```
133
+
134
+ ---
135
+
136
+ ## Sandbox Mode
137
+
138
+ When `config.sandbox_mode?` is true (default in non-production), SlackSender applies sandbox behavior based on the profile's `sandbox` configuration.
139
+
140
+ ### Sandbox Options Reference
141
+
142
+ | Option | Type | Default | Description |
143
+ |--------|------|---------|-------------|
144
+ | `behavior` | `Symbol` or `nil` | Inferred | Explicit sandbox behavior: `:redirect`, `:noop`, or `:passthrough` |
145
+ | `channel.replace_with` | `String` or `nil` | `nil` | Channel ID to redirect all messages when behavior is `:redirect` |
146
+ | `channel.message_prefix` | `String` or `nil` | See below | Custom prefix for sandbox channel redirects. Use `%s` placeholder for channel name |
147
+ | `user_group.replace_with` | `String` or `nil` | `nil` | User group ID to replace all group mentions when in sandbox mode |
148
+
149
+ Default message prefix: `:construction: _This message would have been sent to %s in production_`
150
+
151
+ ### Behavior Resolution
152
+
153
+ When `config.sandbox_mode?` is true, the effective sandbox behavior is determined by:
154
+
155
+ 1. **Explicit `sandbox.behavior`** — if set, use it
156
+ 2. **Inferred from `sandbox.channel.replace_with`** — if present, behavior is `:redirect`
157
+ 3. **Global default** — `config.sandbox_default_behavior` (defaults to `:noop`)
158
+
159
+ | Behavior | Description |
160
+ |----------|-------------|
161
+ | `:redirect` | Redirect messages to `sandbox.channel.replace_with` (required). Adds message prefix. |
162
+ | `:noop` | Don't send anything. Logs what would have been sent. Returns `false`. |
163
+ | `:passthrough` | Send to real channel (explicit opt-out of sandbox safety). |
164
+
165
+ ### Mode: Redirect
166
+
167
+ Redirect all messages to a sandbox channel:
168
+
169
+ ```ruby
170
+ SlackSender.register(
171
+ token: ENV['SLACK_BOT_TOKEN'],
172
+ channels: { production_alerts: 'C9999999999' },
173
+ sandbox: {
174
+ behavior: :redirect, # Optional - inferred when channel.replace_with is set
175
+ channel: {
176
+ replace_with: 'C1234567890',
177
+ message_prefix: ':test_tube: Sandbox redirect from %s'
178
+ }
179
+ }
180
+ )
181
+
182
+ # In sandbox mode, this goes to C1234567890 with a prefix
183
+ SlackSender.call(channel: :production_alerts, text: "Critical alert")
184
+ ```
185
+
186
+ ### Mode: Noop (Default)
187
+
188
+ Don't send anything, just log what would have been sent:
189
+
190
+ ```ruby
191
+ SlackSender.register(
192
+ token: ENV['SLACK_BOT_TOKEN'],
193
+ channels: { alerts: 'C999' },
194
+ sandbox: { behavior: :noop }
195
+ )
196
+
197
+ # In sandbox mode, this logs the message but doesn't send to Slack
198
+ SlackSender.call(channel: :alerts, text: "Test message")
199
+ # => Logs: "[SANDBOX NOOP] Profile: default | Channel: <#C999> | Text: Test message"
200
+ # => Returns false
201
+ ```
202
+
203
+ ### Mode: Passthrough
204
+
205
+ Explicitly opt out of sandbox safety and send to real channels:
206
+
207
+ ```ruby
208
+ SlackSender.register(
209
+ token: ENV['SLACK_BOT_TOKEN'],
210
+ channels: { alerts: 'C999' },
211
+ sandbox: { behavior: :passthrough }
212
+ )
213
+
214
+ # In sandbox mode, this still sends to the real channel
215
+ SlackSender.call(channel: :alerts, text: "This goes to production!")
216
+ ```
217
+
218
+ ---
219
+
220
+ ## Required Slack Scopes
221
+
222
+ Your Slack app needs specific OAuth scopes depending on which features you use. Add these under **OAuth & Permissions** → **Bot Token Scopes** in your [Slack app settings](https://api.slack.com/apps).
223
+
224
+ **Minimum scopes for basic messaging:**
225
+ - `chat:write`
226
+
227
+ **Recommended scopes for full functionality:**
228
+
229
+ | Scope | Required For | Notes |
230
+ |-------|--------------|-------|
231
+ | `chat:write` | All messaging | Required for `chat.postMessage` |
232
+ | `chat:write.public` | Public channels | Post to public channels your bot hasn't been added to |
233
+ | `files:write` | File uploads | Required for `files.getUploadURLExternal` and `files.completeUploadExternal` |
234
+ | `files:read` | File metadata | Required if you need thread timestamps from file uploads |
235
+
236
+ After adding scopes, reinstall the app to your workspace to apply the changes.
237
+
238
+ ---
239
+
240
+ ## Exception Notifications
241
+
242
+ Exception notifications to error tracking services (e.g., Honeybadger) are handled via Axn's `on_exception` handler:
243
+
244
+ ```ruby
245
+ Axn.configure do |c|
246
+ c.on_exception = proc do |e, action:, context:|
247
+ Honeybadger.notify(e, context: { axn_context: context })
248
+ end
249
+ end
250
+ ```
251
+
252
+ See [Axn configuration documentation](https://teamshares.github.io/axn/reference/configuration#on_exception) for details.