rails_discord_notifier 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c884f2a318d8b5a1af5e5b3af00ee14b2e886f00ae368dc3ad4ab84e27c2c39e
4
- data.tar.gz: '047212981fcd047c7c80c206abf505410f49636d6f1488730de307352817b561'
3
+ metadata.gz: 1d11c790a2d82647c56a5c2e01a96e9be1d55dc89d5d7042c3b0f06baca195ff
4
+ data.tar.gz: 56f2cadcc53da57692a4e54ad203d5a818294f790bd3934fa514213df442b667
5
5
  SHA512:
6
- metadata.gz: 15c983ae8a8288a2b36635184fcc43f9622610195d78aca315fbe27c43d6c48bce1903d0a3566a5cac24bb005d487a41ef44db199fca21801d1256987cdcbfe2
7
- data.tar.gz: 4d9480a1e5db5f04eefdf014d7e6c1c5c917ecc6f3e42bf0a1dde5f073e122f4e8f305e503ffc56ee075a63067606ddf0dba2188eeb20574838d30eaa2de705b
6
+ metadata.gz: d07906322b8103001b7185d6594a55ea09e271fe1aca239cf69e1a93aa0e316a7c0440e4268b40be6476c97432c44861b8bc83bed4d51a8c2318e09bf23ff807
7
+ data.tar.gz: 6a411b50c842d47a2944c12fd89d1f1e27dab0dd0aa1bf8285e1bdb0a38e3c2651454f2310012a7f2a5d3b4fc8374bb62d4802d6f7d7d02ccc6856a8acad3145
data/CHANGELOG.md CHANGED
@@ -1,5 +1,95 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
1
8
  ## [Unreleased]
2
9
 
10
+ ## [0.2.0] - 2026-09-17
11
+
12
+ This release fixes three defects that could leak credentials or silently drop
13
+ error reports. Upgrading is strongly recommended.
14
+
15
+ ### Security
16
+
17
+ - **Nested parameters are now redacted.** Filtering previously only inspected
18
+ top-level keys, so `user[password]` was posted to Discord in cleartext.
19
+ Filtering is now recursive via `ActiveSupport::ParameterFilter` and always
20
+ applies a superset of the host application's `config.filter_parameters`.
21
+ - **Query strings are now redacted.** The `URL` field used the raw request URL,
22
+ so a secret in the query string (`?api_key=...`) was posted verbatim.
23
+ - **Exception messages are scrubbed.** `token=…`, `password="…"` and
24
+ `Api_Key: …` fragments in an exception or `cause` message are replaced with
25
+ `[FILTERED]`, so a database connection error no longer posts its password.
26
+ Best effort only - a message is free text, unlike structured parameters.
27
+ - **Webhook URLs are validated.** Only `https://` URLs on `discord.com` or
28
+ `discordapp.com` (or their subdomains) are accepted, which prevents a
29
+ misconfigured or hostile URL from receiving exception reports.
30
+
31
+ ### Fixed
32
+
33
+ - **Reports are no longer silently dropped.** Payloads are truncated to
34
+ Discord's documented limits (1024 per field, 256 title, 4096 description,
35
+ 6000 per embed). Oversized messages previously returned HTTP 400 and were
36
+ discarded, losing the error being reported.
37
+ - **Delivery failures are now visible.** The HTTP response was previously
38
+ discarded; non-2xx responses are logged, and 429s are logged with `Retry-After`.
39
+ - **Network timeouts are set** (2s open, 5s read, 5s write). Delivery previously
40
+ had no timeout at all and could hang a request thread indefinitely.
41
+ - `Rails.logger` is no longer assumed to exist; the logger is configurable and
42
+ falls back to `$stderr`.
43
+ - `require "time"` and `require "rack"` are now explicit, rather than relying on
44
+ another gem having loaded them.
45
+ - `rails_discord_notifier/middleware` can be required on its own.
46
+
47
+ ### Added
48
+
49
+ - **Background jobs, rake tasks and runners are now covered** via an
50
+ `ActiveSupport::ErrorReporter` subscriber (Rails 7.0+). Previously only
51
+ exceptions raised inside a web request were reported.
52
+ - **Asynchronous delivery** (`config.async`, on by default), so reporting no
53
+ longer adds a blocking HTTP round-trip to a failing request. In-flight
54
+ deliveries are waited for at process exit, so reports from rake tasks and
55
+ runners are not lost when the process ends (`Notifier#flush`).
56
+ - **Throttling** (`config.throttle_period`, 60s by default) collapses repeats of
57
+ the same error, so an error storm cannot flood the channel or trip Discord's
58
+ rate limit. The same exception object is never reported twice.
59
+ - **An ignore list** (`config.ignored_exceptions`) pre-populated with routing,
60
+ `RecordNotFound` and other client-caused noise.
61
+ - **`config.enabled`**, defaulting to off in development and test.
62
+ - **`rails_discord_notifier test`** posts a sample report to your webhook and
63
+ prints Discord's response. The executable previously did nothing.
64
+ - **Richer reports**: request ID, environment, release, hostname, the exception's
65
+ `cause`, and arbitrary extra fields via `config.context`.
66
+ - `RailsDiscordNotifier.notify` for reporting an exception by hand.
67
+ - `config.include_params`, `config.filter_parameters`, `config.backtrace_lines`,
68
+ `config.install_middleware`, `config.install_error_subscriber`.
69
+ - RBS signatures for the public API.
70
+
71
+ ### Changed
72
+
73
+ - **`configure` no longer raises** when `webhook_url` is missing. A missing
74
+ environment variable logs a warning and leaves the notifier disabled instead
75
+ of taking down application boot.
76
+ - Configuration moved to a `Configuration` object. The previous flat accessors
77
+ (`RailsDiscordNotifier.webhook_url = ...`) still work.
78
+ - Depends on `actionpack`, `activesupport`, `railties`, `rack` and `logger`
79
+ rather than the whole `rails` meta-gem. Minimum Rails is now 6.1.
80
+ - The timestamp uses Discord's native embed `timestamp` instead of a field.
81
+ - Backtraces default to 10 lines (was 5), trimmed to fit Discord's field limit.
82
+
83
+ ## [0.1.2] - 2025-06-10
84
+
85
+ - Add `frozen_string_literal` comments throughout
86
+ - Require MFA for RubyGems pushes
87
+ - Move development dependencies out of the gemspec and into the Gemfile
88
+
89
+ ## [0.1.1] - 2025-06-03
90
+
91
+ - Fix security and stability issues in the middleware
92
+
3
93
  ## [0.1.0] - 2025-05-28
4
94
 
5
95
  - Initial release
data/README.md CHANGED
@@ -1,94 +1,175 @@
1
1
  # RailsDiscordNotifier
2
2
 
3
- **Send Rails exceptions to Discord via an incoming webhook**
3
+ Send Rails exceptions to a Discord channel via an incoming webhook.
4
4
 
5
- RailsDiscordNotifier is a lightweight gem that captures unhandled exceptions in your Rails application and delivers detailed error reports directly to a Discord channel via a configurable webhook. You own the codebase and can customize the format, embed fields, and delivery settings to suit your needs.
6
-
7
- ## Features
8
-
9
- - Middleware-based interception of all uncaught exceptions
10
- - Rich embed messages with exception class, message, backtrace, request method & URL, and timestamp
11
- - Configurable via environment variables and initializer
12
- - Safe error handling: notification failures are logged but don’t interrupt your app
5
+ - Catches unhandled exceptions from **web requests** (Rack middleware) and from
6
+ **background jobs, rake tasks and runners** (`Rails.error` subscriber, Rails 7.0+)
7
+ - **Redacts secrets** from params and query strings, using your app's own
8
+ `config.filter_parameters` plus a built-in list
9
+ - **Never slows down or breaks your app**: delivery happens off the request
10
+ thread, with short timeouts, and every failure is logged rather than raised
11
+ - **Never floods your channel**: repeats of the same error are collapsed, and
12
+ routing/404-style noise is ignored by default
13
13
 
14
14
  ## Installation
15
15
 
16
- Add the gem directly from RubyGems.org by including it in your Gemfile:
17
-
18
16
  ```ruby
19
- gem "rails_discord_notifier", "~> 0.1.0"
17
+ gem "rails_discord_notifier", "~> 0.2"
20
18
  ```
21
19
 
22
- Then install:
23
-
24
20
  ```bash
25
21
  bundle install
22
+ rails generate rails_discord_notifier:install
26
23
  ```
27
24
 
28
- Or use Bundler:
25
+ Then set your webhook (Server Settings → Integrations → Webhooks → Copy URL):
29
26
 
30
27
  ```bash
31
- bundle add rails_discord_notifier
28
+ export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/…/…"
32
29
  ```
33
30
 
34
- ## Usage
35
-
36
- Generate the default initializer in your Rails app:
31
+ ## Verify it works
37
32
 
38
33
  ```bash
39
- rails generate rails_discord_notifier:install
34
+ bundle exec rails_discord_notifier test
40
35
  ```
41
36
 
42
- In `config/initializers/rails_discord_notifier.rb`, configure your webhook and options:
37
+ This posts a real sample report to your webhook and prints what Discord said,
38
+ so you find out now rather than during your next incident.
39
+
40
+ ## What a report looks like
41
+
42
+ An embed titled with the failing request, followed by the details you need to
43
+ find the error:
44
+
45
+ ```
46
+ Exception in POST /orders
47
+ ActiveRecord::RecordInvalid: Validation failed: Email can't be blank
48
+ Caused by ArgumentError: missing email
49
+
50
+ URL https://shop.example.com/orders?utm_source=newsletter
51
+ Controller orders Action create
52
+ Environment production Release a1b2c3d
53
+ Request ID 8f3c… Host web-01
54
+ Params { "order": { "email": "", "card_token": "[FILTERED]" } }
55
+ Backtrace `app/controllers/orders_controller.rb:42:in 'create'` …
56
+ ```
57
+
58
+ ## Configuration
59
+
60
+ Everything is optional except `webhook_url`.
61
+
62
+ | Option | Default | Description |
63
+ |---|---|---|
64
+ | `webhook_url` | `nil` | **Required.** Must be `https://` on `discord.com` or `discordapp.com`. Anything else leaves the notifier disabled. |
65
+ | `enabled` | `false` in development and test, `true` elsewhere | Master switch. |
66
+ | `username` | `"Error Bot"` | Bot display name in Discord. |
67
+ | `avatar_url` | `nil` | Bot avatar image. |
68
+ | `async` | `true` | Deliver on a separate thread so a failing request is not slowed down. |
69
+ | `throttle_period` | `60` | Seconds to collapse repeats of the same error. `0` disables. |
70
+ | `ignored_exceptions` | routing/404-style noise (see below) | Exception class names, matched including subclasses. |
71
+ | `include_params` | `true` | Include filtered request parameters. |
72
+ | `filter_parameters` | `[]` | Extra redaction matchers, unioned with your app's `config.filter_parameters` and the gem's built-in list. |
73
+ | `backtrace_lines` | `10` | Backtrace lines to send, trimmed to fit Discord's field limit. |
74
+ | `open_timeout` / `read_timeout` / `write_timeout` | `2` / `5` / `5` | Seconds. |
75
+ | `environment` | `Rails.env`, else `RAILS_ENV`/`RACK_ENV` | Shown as a field, and decides the `enabled` default. |
76
+ | `release` | `nil` | Shown as a field; handy for matching an error to a deploy. |
77
+ | `context` | `nil` | Callable receiving the Rack env (`nil` outside a request), returning a hash of extra fields. |
78
+ | `logger` | `Rails.logger`, else `$stderr` | Where delivery problems are logged. |
79
+ | `install_middleware` | `true` | Install the Rack middleware. |
80
+ | `install_error_subscriber` | `true` | Subscribe to `Rails.error` (Rails 7.0+). |
43
81
 
44
82
  ```ruby
45
83
  RailsDiscordNotifier.configure do |config|
46
- # Discord webhook URL (required)
47
- config.webhook_url = ENV.fetch("DISCORD_WEBHOOK_URL")
84
+ config.webhook_url = ENV.fetch("DISCORD_WEBHOOK_URL", nil)
85
+ config.release = ENV.fetch("GIT_COMMIT_SHA", nil)
48
86
 
49
- # Bot username (optional, defaults to ENV["ERROR_BOT_NAME"] or "Error Bot")
50
- config.username = ENV.fetch("ERROR_BOT_NAME", "Error Bot")
87
+ config.ignored_exceptions += %w[MyApp::HarmlessError]
51
88
 
52
- # Bot avatar URL (optional)
53
- # config.avatar_url = ENV.fetch("ERROR_BOT_AVATAR_URL", nil)
89
+ config.context = ->(env) { { "User" => env&.dig("warden")&.user&.id } }
54
90
  end
55
91
  ```
56
92
 
57
- Ensure the middleware is loaded (added automatically by the Railtie).
93
+ A missing or malformed `webhook_url` logs a warning and disables the notifier.
94
+ It will never take down application boot.
95
+
96
+ ### Ignored by default
97
+
98
+ `ActionController::RoutingError`, `ActiveRecord::RecordNotFound`,
99
+ `ActionController::InvalidAuthenticityToken`, `ActionController::UnknownFormat`,
100
+ `ActionController::BadRequest`, `Rack::QueryParser::ParameterTypeError` and
101
+ similar client-caused errors. See `Configuration::DEFAULT_IGNORED_EXCEPTIONS`
102
+ for the full list. Replace it wholesale with `config.ignored_exceptions = [...]`
103
+ or extend it with `+=`.
104
+
105
+ ## Secret redaction
106
+
107
+ Parameters and query strings are filtered recursively with
108
+ `ActiveSupport::ParameterFilter`, using the union of:
58
109
 
59
- Any uncaught exception in your controllers or middleware stack will now send an embed to Discord before re-raising.
110
+ 1. your application's `config.filter_parameters`,
111
+ 2. anything you add via `config.filter_parameters`, and
112
+ 3. the gem's built-in list (`passw`, `secret`, `token`, `_key`, `crypt`, `salt`,
113
+ `certificate`, `otp`, `ssn`, `signature`, `authorization`, `cookie`,
114
+ `session`, `credential`).
60
115
 
61
- ## Configuration Options
116
+ Because it is a union, enabling this gem can never redact less than your
117
+ application already does. To send no parameters at all, set
118
+ `config.include_params = false`.
62
119
 
63
- | Option | Type | Default | Description |
64
- |---------------|--------|--------------------------------------|------------------------------------|
65
- | `webhook_url` | String | **Required** | Discord incoming webhook URL |
66
- | `username` | String | fetched from ENV `ERROR_BOT_NAME` (defaults to `"Error Bot"`) | Bot display name |
67
- | `avatar_url` | String | fetched from ENV `ERROR_BOT_AVATAR_URL` (defaults to `nil`) | URL of the bot avatar image |
120
+ Exception messages are free text rather than structured parameters, so they get
121
+ a best-effort scrub instead: `token=abc`, `password="x"` and `Api_Key: abc` style
122
+ fragments are replaced with `[FILTERED]`. This catches the usual culprits (a
123
+ database connection error carrying a password, an HTTP error carrying a URL with
124
+ a token) but cannot be exhaustive the way parameter filtering is.
125
+
126
+ Request bodies, headers and cookies are never sent.
127
+
128
+ ## Reporting an exception yourself
129
+
130
+ ```ruby
131
+ rescue SomeError => e
132
+ RailsDiscordNotifier.notify(e, context: { "Tenant" => tenant.id })
133
+ end
134
+ ```
135
+
136
+ ## How it gets your exceptions
137
+
138
+ | Source | Mechanism | Requires |
139
+ |---|---|---|
140
+ | Controllers, routing, Rack apps | `RailsDiscordNotifier::Middleware` | any supported Rails |
141
+ | Active Job, rake tasks, runners, `Rails.error.handle/record` | `RailsDiscordNotifier::ErrorSubscriber` | Rails 7.0+ |
142
+ | Anything else | `RailsDiscordNotifier.notify` | — |
143
+
144
+ The middleware observes and re-raises, so it never changes how your application
145
+ handles errors. When both entry points see the same exception, it is reported once.
146
+
147
+ Asynchronous deliveries still in flight are waited for at process exit, so a
148
+ report from a rake task or `rails runner` is not lost when the process ends. Call
149
+ `RailsDiscordNotifier.notifier.flush` to wait for them explicitly.
150
+
151
+ ## Compatibility
152
+
153
+ Ruby 3.1+, Rails 6.1+. Every supported combination is exercised in CI.
68
154
 
69
155
  ## Development
70
156
 
71
- 1. Checkout the repo
72
- 2. Run `bin/setup` to install dependencies
73
- 3. Run `bundle exec rspec` to execute the test suite
74
- 4. Build and install locally:
75
- ```bash
76
- gem build rails_discord_notifier.gemspec
77
- gem install ./rails_discord_notifier-#{RailsDiscordNotifier::VERSION}.gem
78
- ```
79
- 5. To release (public gem) update `lib/rails_discord_notifier/version.rb` and run:
80
- ```bash
81
- bundle exec rake release
82
- ```
157
+ ```bash
158
+ bin/setup # install dependencies
159
+ bundle exec rspec # run the test suite
160
+ bundle exec rubocop # lint
161
+ bundle exec appraisal install && bundle exec appraisal rspec # all Rails versions
162
+ ```
83
163
 
84
- ## Contributing
164
+ To release: update `lib/rails_discord_notifier/version.rb` and `CHANGELOG.md`,
165
+ then `bundle exec rake release`.
85
166
 
86
- Bug reports and pull requests are welcome on GitHub:
87
- [https://github.com/gettinDrikkieWithIt/rails_discord_notifier](https://github.com/gettinDrikkieWithIt/rails_discord_notifier)
167
+ ## Contributing
88
168
 
89
- Please follow the project’s Code of Conduct.
169
+ Bug reports and pull requests are welcome at
170
+ <https://github.com/gettinDrikkieWithIt/rails_discord_notifier>.
171
+ Please follow the [Code of Conduct](CODE_OF_CONDUCT.md).
90
172
 
91
173
  ## License
92
174
 
93
- This gem is available as open source under the terms of the MIT License.
94
- See [LICENSE.txt](LICENSE.txt) for details.
175
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -1,3 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require "rails_discord_notifier"
5
+
6
+ exit RailsDiscordNotifier::CLI.new(ARGV).run
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "rails/generators"
2
4
 
3
5
  module RailsDiscordNotifier
@@ -1,5 +1,48 @@
1
+ # frozen_string_literal: true
2
+
1
3
  RailsDiscordNotifier.configure do |config|
2
- config.webhook_url = ENV.fetch("DISCORD_WEBHOOK_URL")
3
- config.username = ENV.fetch("ERROR_BOT_NAME", "Error Bot")
4
- # config.avatar_url = "https://example.com/avatar.png"
4
+ # Required. Must be an https://discord.com/api/webhooks/... URL.
5
+ # If it is missing or malformed the notifier stays disabled and logs a warning
6
+ # rather than breaking boot.
7
+ config.webhook_url = ENV.fetch("DISCORD_WEBHOOK_URL", nil)
8
+
9
+ # Appearance of the bot in Discord.
10
+ config.username = ENV.fetch("ERROR_BOT_NAME", "Error Bot")
11
+ # config.avatar_url = ENV.fetch("ERROR_BOT_AVATAR_URL", nil)
12
+
13
+ # Reporting is off in development and test by default.
14
+ # config.enabled = Rails.env.production?
15
+
16
+ # Deliver off the request thread. Set to false only if you need delivery to be
17
+ # synchronous (for example inside a test).
18
+ # config.async = true
19
+
20
+ # Collapse repeats of the same error for this many seconds. 0 disables it.
21
+ # config.throttle_period = 60
22
+
23
+ # Network timeouts, in seconds. Kept short so a slow Discord never slows you down.
24
+ # config.open_timeout = 2
25
+ # config.read_timeout = 5
26
+ # config.write_timeout = 5
27
+
28
+ # Client-caused noise that is ignored by default. Append your own:
29
+ # config.ignored_exceptions += %w[MyApp::HarmlessError]
30
+
31
+ # Request parameters are included, filtered with your app's
32
+ # config.filter_parameters plus the gem's own list. Turn them off entirely with:
33
+ # config.include_params = false
34
+ # config.filter_parameters += %w[national_id]
35
+
36
+ # Extra fields on every report. Receives the Rack env (nil outside a request).
37
+ # config.context = lambda do |env|
38
+ # { "User" => env&.dig("warden")&.user&.id }
39
+ # end
40
+
41
+ # Shown as a field, handy for matching an error to a deploy.
42
+ # config.release = ENV.fetch("GIT_COMMIT_SHA", nil)
43
+
44
+ # Entry points. Middleware catches web requests; the Rails.error subscriber
45
+ # catches jobs, rake tasks and runners (Rails 7.0+).
46
+ # config.install_middleware = true
47
+ # config.install_error_subscriber = true
5
48
  end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsDiscordNotifier
4
+ # `rails_discord_notifier test` posts a real message to your webhook and prints
5
+ # what Discord said. It answers the first question every user of this gem has:
6
+ # "is my webhook actually wired up?"
7
+ class CLI
8
+ USAGE = <<~TEXT
9
+ Usage: rails_discord_notifier <command> [options]
10
+
11
+ Commands:
12
+ test Send a sample exception report to your Discord webhook
13
+ version Print the gem version
14
+ help Show this message
15
+
16
+ Options:
17
+ --webhook URL Webhook to use (defaults to $DISCORD_WEBHOOK_URL)
18
+ TEXT
19
+
20
+ def initialize(argv, out: $stdout, env: ENV)
21
+ @argv = Array(argv)
22
+ @out = out
23
+ @env = env
24
+ end
25
+
26
+ # Returns a process exit status.
27
+ def run
28
+ case @argv.first
29
+ when "test" then run_test
30
+ when "version" then print_version
31
+ when "help", nil then usage(0)
32
+ else usage(1)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def run_test
39
+ config = test_config
40
+ unless config.valid_webhook_url?
41
+ @out.puts(webhook_help)
42
+ return 1
43
+ end
44
+
45
+ @out.puts("Posting a sample exception report to Discord…")
46
+ deliver(config)
47
+ end
48
+
49
+ def deliver(config)
50
+ if Client.new(config).post(sample_payload(config))
51
+ @out.puts("Sent. Check your Discord channel.")
52
+ 0
53
+ else
54
+ @out.puts("Discord did not accept the message. See the logged response above.")
55
+ 1
56
+ end
57
+ end
58
+
59
+ def sample_payload(config)
60
+ exception = begin
61
+ raise "RailsDiscordNotifier test message - everything is wired up correctly"
62
+ rescue StandardError => e
63
+ e
64
+ end
65
+ Payload.new(exception, config: config).to_h
66
+ end
67
+
68
+ def test_config
69
+ Configuration.new.tap do |config|
70
+ config.webhook_url = webhook_url
71
+ config.enabled = true
72
+ config.async = false
73
+ config.logger = Logger.new(@out)
74
+ end
75
+ end
76
+
77
+ def webhook_url
78
+ index = @argv.index("--webhook")
79
+ return @argv[index + 1] if index && @argv[index + 1]
80
+
81
+ @env["DISCORD_WEBHOOK_URL"]
82
+ end
83
+
84
+ def webhook_help
85
+ if webhook_url.to_s.strip.empty?
86
+ "No webhook configured. Set DISCORD_WEBHOOK_URL or pass --webhook URL."
87
+ else
88
+ "Invalid webhook: expected an https://discord.com/api/webhooks/... URL, got #{webhook_url}"
89
+ end
90
+ end
91
+
92
+ def print_version
93
+ @out.puts(VERSION)
94
+ 0
95
+ end
96
+
97
+ def usage(status)
98
+ @out.puts(USAGE)
99
+ status
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module RailsDiscordNotifier
8
+ # Posts a prepared payload to the Discord webhook.
9
+ #
10
+ # Never raises: a notifier that breaks the request it is reporting on is worse
11
+ # than no notifier. Every failure is logged and swallowed.
12
+ class Client
13
+ SUCCESS = (200..299)
14
+
15
+ def initialize(config)
16
+ @config = config
17
+ end
18
+
19
+ # Returns true when Discord accepted the message.
20
+ def post(payload)
21
+ uri = target_uri
22
+ return false if uri.nil?
23
+
24
+ accepted?(http(uri).request(build_request(uri, payload)))
25
+ rescue StandardError => e
26
+ config.logger.error("[RailsDiscordNotifier] delivery failed: #{e.class}: #{e.message}")
27
+ false
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :config
33
+
34
+ def target_uri
35
+ return nil unless config.valid_webhook_url?
36
+
37
+ URI.parse(config.webhook_url.to_s.strip)
38
+ rescue URI::InvalidURIError
39
+ nil
40
+ end
41
+
42
+ def http(uri)
43
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
44
+ http.use_ssl = uri.scheme == "https"
45
+ http.open_timeout = config.open_timeout
46
+ http.read_timeout = config.read_timeout
47
+ http.write_timeout = config.write_timeout
48
+ end
49
+ end
50
+
51
+ def build_request(uri, payload)
52
+ Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json").tap do |request|
53
+ request.body = JSON.generate(payload)
54
+ end
55
+ end
56
+
57
+ # Logs whatever Discord said, and answers whether the message got through.
58
+ def accepted?(response)
59
+ return true if SUCCESS.cover?(response.code.to_i)
60
+
61
+ if response.code.to_i == 429
62
+ config.logger.warn(
63
+ "[RailsDiscordNotifier] rate limited by Discord, retry after #{response["Retry-After"] || "unknown"}s"
64
+ )
65
+ else
66
+ config.logger.error(
67
+ "[RailsDiscordNotifier] Discord rejected the message: HTTP #{response.code} #{response.body}"
68
+ )
69
+ end
70
+ false
71
+ end
72
+ end
73
+ end