notify-engine 0.1.1 → 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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +77 -7
- data/lib/notify/adapters/email.rb +134 -24
- data/lib/notify/adapters/telegram.rb +53 -0
- data/lib/notify/config.rb +4 -1
- data/lib/notify/delivery_result.rb +12 -0
- data/lib/notify/engine.rb +1 -0
- data/lib/notify/errors.rb +14 -0
- data/lib/notify/mailer.rb +6 -3
- data/lib/notify/strict_delivery.rb +111 -0
- data/lib/notify/strict_mailer.rb +56 -0
- data/lib/notify/telegram_client.rb +110 -0
- data/lib/notify/telegram_renderer.rb +14 -0
- data/lib/notify/test_helper.rb +1 -1
- data/lib/notify/version.rb +1 -1
- data/lib/notify.rb +14 -0
- metadata +29 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 248f96a79272360f485c3a963fbdb2fd3ae3b10008c5c394029bcb437d4b015d
|
|
4
|
+
data.tar.gz: 7da8f5f1d380cbfb9d1f1c0721df7bf86432bbc416df16bcd34d6c72b3e6a5ee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 97948a149835c21f73c3b36d868549d171d5dfc03574e8ac5d60807eda278005433cd04630fd2f670c4041322254200d951296afe350f39176b8d679675f577f
|
|
7
|
+
data.tar.gz: 80d26a8d7f756b92f2f49575b0845c9e114076a37a4b47d0966c8c74c9c37d3a9216f0165a04266ce46f3254bdc87224dfa5a732168b4c413eb10ebb96955590
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.0] - 2026-10-23
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Explicit single-channel `Notify.deliver` with required recipients, native JSON-safe locals, typed results and sanitized failure dispositions, preserving existing `Notify.message` behavior.
|
|
13
|
+
- `Notify.prepare` renders and validates without provider I/O, returning a process-local callable so callers can persist a pre-send checkpoint before delivery.
|
|
14
|
+
- Telegram adapter with named bots, plain-text templates, explicit chat destinations, bounded verified HTTPS requests, provider rate-delay metadata and safe access/permission errors.
|
|
15
|
+
- Per-message email Message-ID and SMTP timeout options; synchronous, queued, captured and suppressed outcomes remain distinct.
|
|
16
|
+
- Strict email delivery telemetry filters recipient and message fields. Capture mode retains explicit targets without claiming provider acceptance.
|
|
17
|
+
- Strict mail guards validate destinations and transport after interceptors; failures known to precede transport expose `attempted: false`. SMTP cleanup preserves the original transmission outcome.
|
|
18
|
+
|
|
8
19
|
## [0.1.1] - 2026-05-29
|
|
9
20
|
|
|
10
21
|
### Fixed
|
|
@@ -24,5 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
24
35
|
- Structured error handling: `Notify::UnknownMessage`, `Notify::MissingRecipients`, `Notify::MissingSubject`, and `Notify::DeliveryError` with aggregated `.adapter_errors` — per-adapter error isolation ensures one failure does not block other adapters
|
|
25
36
|
- Pluggable adapter system via `Notify.register_adapter(:name, klass)` and `Notify::Adapters::Base` contract (`#deliver`, `.template_extensions`) for custom delivery channels
|
|
26
37
|
|
|
38
|
+
[0.2.0]: https://github.com/lstpsche/notify-engine-gem/releases/tag/v0.2.0
|
|
27
39
|
[0.1.1]: https://github.com/lstpsche/notify-engine-gem/releases/tag/v0.1.1
|
|
28
40
|
[0.1.0]: https://github.com/lstpsche/notify-engine-gem/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Convention-over-configuration multi-channel notification dispatch for Rails.
|
|
4
4
|
|
|
5
|
-
-
|
|
5
|
+
- Conventional dispatch: `Notify.message(:name, **payload)`; explicit delivery outcomes: `Notify.deliver(:name, via:, to:, ...)`
|
|
6
6
|
- Filesystem-driven adapter routing — presence of a template determines which channels fire
|
|
7
7
|
- Optional payload class compute layer for recipient resolution, subject building, and data transformation
|
|
8
|
-
- Pluggable adapters (email built-in, custom adapters via `Notify.register_adapter`)
|
|
8
|
+
- Pluggable adapters (email and Telegram built-in, custom adapters via `Notify.register_adapter`)
|
|
9
9
|
- Introspection: `Notify.messages` and `Notify.adapters`
|
|
10
10
|
|
|
11
11
|
## Installation
|
|
@@ -80,7 +80,7 @@ app/notify_templates/
|
|
|
80
80
|
│ ├── export_report.html.erb
|
|
81
81
|
│ └── _disabled_message.html.erb # _ prefix = excluded from registry
|
|
82
82
|
├── telegram/
|
|
83
|
-
│ └── stuck_pg_jobs.
|
|
83
|
+
│ └── stuck_pg_jobs.text.erb
|
|
84
84
|
├── stuck_pg_jobs.rb # optional PayloadClass
|
|
85
85
|
└── export_report.rb # optional PayloadClass
|
|
86
86
|
```
|
|
@@ -123,7 +123,7 @@ Notify.configure do |config|
|
|
|
123
123
|
subject_prefix: -> { "[#{Rails.env.upcase}]:" } # default: nil (String or Proc)
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
# Telegram adapter (
|
|
126
|
+
# Telegram adapter (disabled until explicitly configured)
|
|
127
127
|
config.adapters[:telegram] = {
|
|
128
128
|
enabled: false,
|
|
129
129
|
bots: []
|
|
@@ -473,11 +473,10 @@ When multiple adapters handle a message, one adapter failing does not block the
|
|
|
473
473
|
|
|
474
474
|
If a payload class raises during `initialize` or any resolution method, the error is caught and logged. The dispatcher falls back to raw payload mode for that adapter.
|
|
475
475
|
|
|
476
|
-
## Out of Scope
|
|
476
|
+
## Out of Scope
|
|
477
477
|
|
|
478
|
-
The following are
|
|
478
|
+
The following are outside the gem's scope:
|
|
479
479
|
|
|
480
|
-
- Telegram Bot API delivery (architecture supports it, implementation deferred)
|
|
481
480
|
- Delivery tracking / persistence
|
|
482
481
|
- User preferences / opt-out
|
|
483
482
|
- Template previews (à la `ActionMailer::Preview`)
|
|
@@ -512,3 +511,74 @@ gem build notify-engine.gemspec
|
|
|
512
511
|
## License
|
|
513
512
|
|
|
514
513
|
MIT — see [LICENSE](LICENSE).
|
|
514
|
+
|
|
515
|
+
## Explicit delivery outcomes
|
|
516
|
+
|
|
517
|
+
Use `Notify.deliver` when the caller must identify one exact destination and know what happened. It does not consult payload-class recipients, default recipients, CC/BCC overrides or per-message subject defaults. Email still uses the configured sender, layout, helpers and subject prefix.
|
|
518
|
+
|
|
519
|
+
```ruby
|
|
520
|
+
result = Notify.deliver(:alert,
|
|
521
|
+
via: :email,
|
|
522
|
+
to: "operator@example.com",
|
|
523
|
+
subject: "Document needs attention",
|
|
524
|
+
locals: { body: "Review the document in the application." },
|
|
525
|
+
delivery_method: :deliver_now,
|
|
526
|
+
message_id: "stable-document-notice@example.com",
|
|
527
|
+
delivery_options: { open_timeout: 5, read_timeout: 15 })
|
|
528
|
+
|
|
529
|
+
result.adapter # :email
|
|
530
|
+
result.status # :accepted, :enqueued or :captured
|
|
531
|
+
result.provider_id # optional String
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
`accepted` means the provider accepted the write. `enqueued` means only that ActionMailer accepted an asynchronous job. `captured` means test capture or a local test/file transport. Suppressed delivery and missing/disabled adapters are explicit failures. Acceptance does not prove inbox placement, human visibility or reading.
|
|
535
|
+
|
|
536
|
+
Failures raise `Notify::DeliveryFailure` with a safe `code`, `disposition` (`permanent`, `retryable` or `uncertain`), and optional `http_status`, `provider_error_code`, and `retry_after`. `attempted == false` explicitly identifies a failure known to precede transport execution; nil leaves that unspecified. No automatic retries are performed. A timeout or lost response after transmission can be uncertain and must not be blindly retried. Strict errors emit `notify.adapter.error` once; transport invocations emit `notify.adapter.deliver` without recipient/content fields. Callers own persistence, retry policy, rate coordination and reconciliation.
|
|
537
|
+
|
|
538
|
+
Strict email checks its recipient, envelope destination and transport again after Mail interceptors run. Interceptors may suppress delivery, but redirecting it, adding recipients or substituting an unsupported transport fails before provider I/O. SMTP session cleanup cannot replace the original transmission failure or undo known acceptance.
|
|
539
|
+
|
|
540
|
+
For a durable sender, prepare before recording the send boundary:
|
|
541
|
+
|
|
542
|
+
```ruby
|
|
543
|
+
prepared = Notify.prepare(:alert, via: :email, to: "operator@example.com",
|
|
544
|
+
subject: "Document needs attention", locals: { body: "Review required." })
|
|
545
|
+
# Persist the application's pre-send checkpoint here.
|
|
546
|
+
result = prepared.call
|
|
547
|
+
# Persist acceptance outside the provider-error rescue boundary.
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Preparation renders/validates without opening a provider connection. The callable is process-local: do not serialize or enqueue it. Calling it again performs another invocation; idempotency belongs to the caller. Use native JSON-safe locals so `deliver_later` remains serializable. Custom legacy adapters need not implement the strict API.
|
|
551
|
+
|
|
552
|
+
## Telegram
|
|
553
|
+
|
|
554
|
+
Configure named bot entries. Tokens can be callables and are evaluated when preparing a message:
|
|
555
|
+
|
|
556
|
+
```ruby
|
|
557
|
+
Notify.configure do |config|
|
|
558
|
+
config.adapters[:telegram] = {
|
|
559
|
+
enabled: true,
|
|
560
|
+
bots: [{ name: :alerts, token: -> { ENV.fetch("ALERT_BOT_TOKEN") }, enabled: true }],
|
|
561
|
+
open_timeout: 5, read_timeout: 15, write_timeout: 15
|
|
562
|
+
}
|
|
563
|
+
end
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
Create `app/notify_templates/telegram/alert.text.erb`, using ordinary template locals:
|
|
567
|
+
|
|
568
|
+
```erb
|
|
569
|
+
<%= text -%>
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
```ruby
|
|
573
|
+
Notify.deliver(:alert, via: :telegram,
|
|
574
|
+
to: { bot: :alerts, chat_id: "@alerts_channel" },
|
|
575
|
+
locals: { text: "Document needs attention." })
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
Telegram is synchronous and uses plain text, with no parse mode or link previews. Messages must fit the Bot API's 4096-character limit; the adapter rejects oversized input instead of splitting it. Bot and chat must be explicit for strict sends. Public channel/supergroup handles and numeric chat IDs are supported; personal usernames are not direct-message destinations. Channel owners must add the bot and grant posting rights. Personal recipients must first start/message the bot and then be addressed by numeric chat ID.
|
|
579
|
+
|
|
580
|
+
Legacy `Notify.message(:alert, tg_bots: [:alerts], text: "...")` can select configured bot names when each has an explicit default `chat_id` in its bot entry. A disabled bot is skipped only in legacy dispatch; unknown names are errors. Strict sends never fall back to a bot's default chat.
|
|
581
|
+
|
|
582
|
+
Telegram failures expose stable safe reasons including unavailable chat, missing membership/posting rights, blocking, invalid credentials and rate limits. Unknown provider descriptions remain generic rejections. A migrated chat ID requires configuration correction and is never silently substituted. Raw provider descriptions and credential-bearing exceptions are not retained. If the application instruments Net::HTTP, redact bot-token URL segments in its breadcrumb/transaction exporters too; the Telegram API puts the token in the request path.
|
|
583
|
+
|
|
584
|
+
`Notify.test_mode!` captures strict targets/locals in `Notify.deliveries` and returns `captured`. Disable that mode explicitly when testing actual transport behavior with a fake provider. No bot polling, inbound webhook, user-account login, database ledger or background retry engine is included.
|
|
@@ -1,29 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "action_view"
|
|
4
|
+
require "net/smtp"
|
|
4
5
|
|
|
5
6
|
module Notify
|
|
6
7
|
module Adapters
|
|
7
8
|
class Email < Base
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
# Net::SMTP#start may raise while closing a session after DATA was already accepted.
|
|
10
|
+
# Keep that acceptance instead of interpreting a QUIT failure as a retryable rejection.
|
|
11
|
+
module SMTPAcceptance
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def start_smtp_session
|
|
15
|
+
accepted = false
|
|
16
|
+
connected = false
|
|
17
|
+
response = nil
|
|
18
|
+
transmission_failure = nil
|
|
19
|
+
super do |smtp|
|
|
20
|
+
connected = true
|
|
21
|
+
begin
|
|
22
|
+
response = yield smtp
|
|
23
|
+
rescue StandardError => error
|
|
24
|
+
transmission_failure = Email.delivery_failure(error)
|
|
25
|
+
raise transmission_failure, cause: nil
|
|
26
|
+
end
|
|
27
|
+
accepted = true
|
|
28
|
+
response
|
|
29
|
+
end
|
|
30
|
+
rescue StandardError => error
|
|
31
|
+
return response if accepted
|
|
14
32
|
|
|
15
|
-
|
|
33
|
+
failure = transmission_failure || Email.delivery_failure(error)
|
|
34
|
+
if connected && failure.code == "connection_failed"
|
|
35
|
+
failure = DeliveryFailure.new("smtp_outcome_unknown", disposition: :uncertain)
|
|
36
|
+
end
|
|
37
|
+
raise failure, cause: nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
16
40
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
to: Array(to),
|
|
21
|
-
subject: subject,
|
|
22
|
-
from: from,
|
|
23
|
-
cc: cc,
|
|
24
|
-
bcc: bcc,
|
|
25
|
-
template_path: template_path.to_s
|
|
26
|
-
)
|
|
41
|
+
def deliver(message_name:, to:, subject:, locals:, template_path:, options: {})
|
|
42
|
+
message = build_message(message_name, to, subject, locals, template_path, options)
|
|
43
|
+
delivery_method = options[:delivery_method] || email_config[:delivery_method] || :deliver_later
|
|
27
44
|
|
|
28
45
|
case delivery_method.to_sym
|
|
29
46
|
when :deliver_now
|
|
@@ -33,6 +50,51 @@ module Notify
|
|
|
33
50
|
end
|
|
34
51
|
end
|
|
35
52
|
|
|
53
|
+
def deliver_strict(message_name:, to:, subject:, locals:, template_path:, options: {})
|
|
54
|
+
prepare_strict(message_name: message_name, to: to, subject: subject, locals: locals,
|
|
55
|
+
template_path: template_path, options: options).call
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def prepare_strict(message_name:, to:, subject:, locals:, template_path:, options: {})
|
|
59
|
+
options = options.merge(strict: true)
|
|
60
|
+
message = build_message(message_name, to, subject, locals, template_path, options)
|
|
61
|
+
if options[:delivery_method].to_sym == :deliver_later
|
|
62
|
+
return lambda do
|
|
63
|
+
enqueue_prepared(message)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
mail = message.message
|
|
68
|
+
self.class.validate_transport!(mail)
|
|
69
|
+
-> { send_prepared(message, mail) }
|
|
70
|
+
rescue DeliveryFailure => error
|
|
71
|
+
raise error, cause: nil
|
|
72
|
+
rescue StandardError
|
|
73
|
+
raise DeliveryFailure.new("render_failed"), cause: nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def send_prepared(message, mail)
|
|
77
|
+
message.deliver_now
|
|
78
|
+
raise DeliveryFailure, "delivery_suppressed" unless mail.perform_deliveries
|
|
79
|
+
unless mail.raise_delivery_errors
|
|
80
|
+
raise DeliveryFailure.new("delivery_errors_disabled", disposition: :uncertain)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
status = mail.delivery_method.is_a?(Mail::TestMailer) || mail.delivery_method.is_a?(Mail::FileDelivery) ? :captured : :accepted
|
|
84
|
+
DeliveryResult.new(adapter: :email, status: status, provider_id: mail.message_id)
|
|
85
|
+
rescue DeliveryFailure => error
|
|
86
|
+
raise error, cause: nil
|
|
87
|
+
rescue StandardError => error
|
|
88
|
+
raise self.class.delivery_failure(error), cause: nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def enqueue_prepared(message)
|
|
92
|
+
message.deliver_later
|
|
93
|
+
DeliveryResult.new(adapter: :email, status: :enqueued)
|
|
94
|
+
rescue StandardError
|
|
95
|
+
raise DeliveryFailure.new("enqueue_outcome_unknown", disposition: :uncertain), cause: nil
|
|
96
|
+
end
|
|
97
|
+
|
|
36
98
|
def self.template_extensions
|
|
37
99
|
formats = begin
|
|
38
100
|
ActionView::Template::Types.symbols
|
|
@@ -52,8 +114,56 @@ module Notify
|
|
|
52
114
|
extensions.uniq
|
|
53
115
|
end
|
|
54
116
|
|
|
117
|
+
def self.delivery_failure(error)
|
|
118
|
+
if error.is_a?(Net::SMTPServerBusy)
|
|
119
|
+
DeliveryFailure.new("smtp_temporary_rejection", disposition: :retryable)
|
|
120
|
+
elsif error.is_a?(Net::SMTPFatalError) || error.is_a?(Net::SMTPAuthenticationError) || error.is_a?(Net::SMTPSyntaxError)
|
|
121
|
+
DeliveryFailure.new("smtp_rejected")
|
|
122
|
+
elsif [SocketError, Net::OpenTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH].any? { |klass| error.is_a?(klass) }
|
|
123
|
+
DeliveryFailure.new("connection_failed", disposition: :retryable)
|
|
124
|
+
else
|
|
125
|
+
DeliveryFailure.new("smtp_outcome_unknown", disposition: :uncertain)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.validate_transport!(mail)
|
|
130
|
+
raise DeliveryFailure.new("delivery_suppressed", attempted: false) unless mail.perform_deliveries
|
|
131
|
+
raise DeliveryFailure.new("delivery_errors_disabled", attempted: false) unless mail.raise_delivery_errors
|
|
132
|
+
unless [Mail::SMTP, Mail::Sendmail, Mail::TestMailer, Mail::FileDelivery].any? { |klass| mail.delivery_method.is_a?(klass) }
|
|
133
|
+
raise DeliveryFailure.new("unsupported_email_transport", attempted: false)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
55
137
|
private
|
|
56
138
|
|
|
139
|
+
def build_message(message_name, to, subject, locals, template_path, options)
|
|
140
|
+
subject = apply_subject_prefix(subject)
|
|
141
|
+
from = options[:from]
|
|
142
|
+
cc = options[:cc]
|
|
143
|
+
bcc = options[:bcc]
|
|
144
|
+
mailer = options[:strict] ? Notify::StrictMailer : Notify::Mailer
|
|
145
|
+
configure_mailer!(mailer)
|
|
146
|
+
|
|
147
|
+
extra = {}
|
|
148
|
+
extra[:message_id] = options[:message_id] if options[:message_id]
|
|
149
|
+
extra[:delivery_options] = options[:delivery_options] if options[:delivery_options]&.any?
|
|
150
|
+
attributes = {
|
|
151
|
+
locals: locals,
|
|
152
|
+
to: Array(to),
|
|
153
|
+
subject: subject,
|
|
154
|
+
from: from,
|
|
155
|
+
cc: cc,
|
|
156
|
+
bcc: bcc,
|
|
157
|
+
template_path: template_path.to_s,
|
|
158
|
+
**extra
|
|
159
|
+
}
|
|
160
|
+
if options[:strict]
|
|
161
|
+
mailer.with(attributes).dispatch_strict(message_name)
|
|
162
|
+
else
|
|
163
|
+
mailer.dispatch(message_name, **attributes)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
57
167
|
def email_config
|
|
58
168
|
Notify.config.adapters[:email] || {}
|
|
59
169
|
end
|
|
@@ -68,20 +178,20 @@ module Notify
|
|
|
68
178
|
"#{prefix_str} #{subject}"
|
|
69
179
|
end
|
|
70
180
|
|
|
71
|
-
def configure_mailer!
|
|
72
|
-
configure_layout!
|
|
73
|
-
configure_helpers!
|
|
181
|
+
def configure_mailer!(mailer = Notify::Mailer)
|
|
182
|
+
configure_layout!(mailer)
|
|
183
|
+
configure_helpers!(mailer)
|
|
74
184
|
end
|
|
75
185
|
|
|
76
|
-
def configure_layout!
|
|
77
|
-
|
|
186
|
+
def configure_layout!(mailer = Notify::Mailer)
|
|
187
|
+
mailer.layout(email_config[:layout] || false)
|
|
78
188
|
end
|
|
79
189
|
|
|
80
|
-
def configure_helpers!
|
|
190
|
+
def configure_helpers!(mailer = Notify::Mailer)
|
|
81
191
|
helpers = email_config[:helpers]
|
|
82
192
|
return unless helpers.is_a?(Array) && helpers.any?
|
|
83
193
|
|
|
84
|
-
helpers.each { |helper_module|
|
|
194
|
+
helpers.each { |helper_module| mailer.helper(helper_module) }
|
|
85
195
|
end
|
|
86
196
|
end
|
|
87
197
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Notify
|
|
4
|
+
module Adapters
|
|
5
|
+
class Telegram < Base
|
|
6
|
+
def deliver(message_name:, to:, subject:, locals:, template_path:, options: {})
|
|
7
|
+
Array(to).each do |name|
|
|
8
|
+
bot = bot_config(name)
|
|
9
|
+
next unless bot.fetch(:enabled, true)
|
|
10
|
+
|
|
11
|
+
deliver_strict(message_name: message_name, to: { bot: name, chat_id: bot[:chat_id] },
|
|
12
|
+
subject: subject, locals: locals, template_path: template_path, options: options)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def deliver_strict(message_name:, to:, subject:, locals:, template_path:, options: {})
|
|
17
|
+
prepare_strict(message_name: message_name, to: to, subject: subject, locals: locals,
|
|
18
|
+
template_path: template_path, options: options).call
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def prepare_strict(message_name:, to:, subject:, locals:, template_path:, options: {})
|
|
22
|
+
bot = bot_config(to.fetch(:bot))
|
|
23
|
+
raise DeliveryFailure, "bot_disabled" unless bot.fetch(:enabled, true)
|
|
24
|
+
|
|
25
|
+
text = TelegramRenderer.new.render(message_name: message_name, locals: locals, template_path: template_path)
|
|
26
|
+
token = bot[:token]
|
|
27
|
+
token = token.call if token.respond_to?(:call)
|
|
28
|
+
TelegramClient.validate!(token: token, chat_id: to.fetch(:chat_id), text: text)
|
|
29
|
+
chat_id = to.fetch(:chat_id).dup
|
|
30
|
+
timeouts = TelegramClient.timeouts
|
|
31
|
+
-> { TelegramClient.new.send_message(token: token, chat_id: chat_id, text: text, timeouts: timeouts) }
|
|
32
|
+
rescue DeliveryFailure => error
|
|
33
|
+
raise error, cause: nil
|
|
34
|
+
rescue StandardError
|
|
35
|
+
raise DeliveryFailure.new("invalid_bot_configuration"), cause: nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.template_extensions
|
|
39
|
+
ActionView::Template::Handlers.extensions.map { |handler| :"text.#{handler}" }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def bot_config(name)
|
|
45
|
+
bots = Notify.config.adapters.fetch(:telegram).fetch(:bots)
|
|
46
|
+
bot = bots.find { |item| item.fetch(:name).to_s == name.to_s }
|
|
47
|
+
raise DeliveryFailure, "unknown_bot" unless bot
|
|
48
|
+
|
|
49
|
+
bot
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/notify/config.rb
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Notify
|
|
4
|
+
DeliveryResult = Struct.new(:adapter, :status, :provider_id, keyword_init: true) do
|
|
5
|
+
def initialize(adapter:, status:, provider_id: nil)
|
|
6
|
+
raise ArgumentError, "Invalid delivery status" unless %i[accepted enqueued captured].include?(status.to_sym)
|
|
7
|
+
|
|
8
|
+
super(adapter: adapter.to_sym, status: status.to_sym, provider_id: provider_id&.to_s)
|
|
9
|
+
freeze
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
data/lib/notify/engine.rb
CHANGED
data/lib/notify/errors.rb
CHANGED
|
@@ -6,6 +6,20 @@ module Notify
|
|
|
6
6
|
class MissingRecipients < Error; end
|
|
7
7
|
class MissingSubject < Error; end
|
|
8
8
|
|
|
9
|
+
class DeliveryFailure < Error
|
|
10
|
+
attr_reader :code, :disposition, :http_status, :provider_error_code, :retry_after, :attempted
|
|
11
|
+
|
|
12
|
+
def initialize(code, disposition: :permanent, http_status: nil, provider_error_code: nil, retry_after: nil, attempted: nil)
|
|
13
|
+
@code = code.to_s
|
|
14
|
+
@disposition = disposition.to_sym
|
|
15
|
+
@http_status = http_status
|
|
16
|
+
@provider_error_code = provider_error_code
|
|
17
|
+
@retry_after = retry_after
|
|
18
|
+
@attempted = attempted
|
|
19
|
+
super(@code)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
9
23
|
class DeliveryError < Error
|
|
10
24
|
attr_reader :adapter_errors
|
|
11
25
|
|
data/lib/notify/mailer.rb
CHANGED
|
@@ -4,7 +4,8 @@ require "action_mailer"
|
|
|
4
4
|
|
|
5
5
|
module Notify
|
|
6
6
|
class Mailer < ActionMailer::Base
|
|
7
|
-
def dispatch(message_name, locals:, to:, subject:, from: nil, cc: nil, bcc: nil, template_path: nil
|
|
7
|
+
def dispatch(message_name, locals:, to:, subject:, from: nil, cc: nil, bcc: nil, template_path: nil,
|
|
8
|
+
message_id: nil, delivery_options: nil, strict: false)
|
|
8
9
|
prepend_view_path(template_path.to_s) if template_path
|
|
9
10
|
|
|
10
11
|
bridge_locals(locals)
|
|
@@ -16,8 +17,10 @@ module Notify
|
|
|
16
17
|
template_path: ""
|
|
17
18
|
}
|
|
18
19
|
mail_options[:from] = from if from
|
|
19
|
-
mail_options[:cc] = cc if cc
|
|
20
|
-
mail_options[:bcc] = bcc if bcc
|
|
20
|
+
mail_options[:cc] = cc if strict || cc
|
|
21
|
+
mail_options[:bcc] = bcc if strict || bcc
|
|
22
|
+
mail_options[:message_id] = message_id if message_id
|
|
23
|
+
mail_options[:delivery_method_options] = delivery_options if delivery_options && !delivery_options.empty?
|
|
21
24
|
|
|
22
25
|
mail(mail_options)
|
|
23
26
|
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Notify
|
|
7
|
+
# A single, explicit transport invocation. Legacy payload/default resolution is intentionally separate.
|
|
8
|
+
class StrictDelivery
|
|
9
|
+
def call(name, via:, to:, subject: nil, locals: {}, delivery_method: :deliver_now, message_id: nil, delivery_options: {})
|
|
10
|
+
prepare(name, via: via, to: to, subject: subject, locals: locals, delivery_method: delivery_method,
|
|
11
|
+
message_id: message_id, delivery_options: delivery_options).call
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Prepare/render without provider I/O, allowing a caller to persist its pre-send checkpoint afterward.
|
|
15
|
+
def prepare(name, via:, to:, subject: nil, locals: {}, delivery_method: :deliver_now, message_id: nil, delivery_options: {})
|
|
16
|
+
fail_with("unknown_message") unless name.is_a?(String) || name.is_a?(Symbol)
|
|
17
|
+
fail_with("adapter_unavailable") unless via.is_a?(String) || via.is_a?(Symbol)
|
|
18
|
+
name = name.to_sym
|
|
19
|
+
adapter = via.to_sym
|
|
20
|
+
validate!(name, adapter, to, subject, locals, delivery_method, delivery_options)
|
|
21
|
+
fail_with("invalid_message_id") if message_id && adapter != :email
|
|
22
|
+
if message_id && !(message_id.is_a?(String) && message_id.length <= 255 && message_id.match?(/\A[^<>\s@]+@[^<>\s@]+\z/))
|
|
23
|
+
fail_with("invalid_message_id")
|
|
24
|
+
end
|
|
25
|
+
if Notify.test_mode?
|
|
26
|
+
return lambda do
|
|
27
|
+
Notify.deliveries << { name: name, adapters: [adapter], strict: true,
|
|
28
|
+
payload: { locals: locals }, resolved: { subject: subject, recipients: { adapter => [to] }, locals: locals } }
|
|
29
|
+
DeliveryResult.new(adapter: adapter, status: :captured)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
config = Notify.config.adapters.fetch(adapter)
|
|
34
|
+
from = config[:from]
|
|
35
|
+
options = { from: from.respond_to?(:call) ? from.call : from,
|
|
36
|
+
delivery_method: delivery_method, message_id: message_id, delivery_options: delivery_options }
|
|
37
|
+
prepared = Notify.registered_adapters.fetch(adapter).new.prepare_strict(
|
|
38
|
+
message_name: name, to: to, subject: subject, locals: locals,
|
|
39
|
+
template_path: Notify.registry.templates_path.join(adapter.to_s), options: options
|
|
40
|
+
)
|
|
41
|
+
-> { execute(name, adapter, prepared) }
|
|
42
|
+
rescue DeliveryFailure => error
|
|
43
|
+
ActiveSupport::Notifications.instrument("notify.adapter.error", message_name: name, adapter: adapter, error: error)
|
|
44
|
+
raise error, cause: nil
|
|
45
|
+
rescue StandardError => original
|
|
46
|
+
error = DeliveryFailure.new("preparation_failed")
|
|
47
|
+
error.set_backtrace(original.backtrace)
|
|
48
|
+
ActiveSupport::Notifications.instrument("notify.adapter.error", message_name: name, adapter: adapter, error: error)
|
|
49
|
+
raise error, cause: nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def execute(name, adapter, prepared)
|
|
55
|
+
result = ActiveSupport::Notifications.instrument("notify.adapter.deliver", message_name: name, adapter: adapter) do
|
|
56
|
+
prepared.call
|
|
57
|
+
end
|
|
58
|
+
unless result.is_a?(DeliveryResult) && result.adapter == adapter
|
|
59
|
+
raise DeliveryFailure.new("invalid_delivery_result", disposition: :uncertain)
|
|
60
|
+
end
|
|
61
|
+
result
|
|
62
|
+
rescue DeliveryFailure => error
|
|
63
|
+
ActiveSupport::Notifications.instrument("notify.adapter.error", message_name: name, adapter: adapter, error: error)
|
|
64
|
+
raise error, cause: nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate!(name, adapter, to, subject, locals, delivery_method, delivery_options)
|
|
68
|
+
fail_with("unknown_message") unless Notify.registry&.adapters_for(name)&.include?(adapter)
|
|
69
|
+
klass = Notify.registered_adapters[adapter]
|
|
70
|
+
fail_with("adapter_unavailable") unless klass&.method_defined?(:prepare_strict)
|
|
71
|
+
fail_with("adapter_disabled") unless Notify.config.adapters.fetch(adapter, {}).fetch(:enabled, true)
|
|
72
|
+
fail_with("invalid_locals") unless locals.is_a?(Hash) && json_value?(locals)
|
|
73
|
+
fail_with("invalid_delivery_method") unless delivery_method.respond_to?(:to_sym) &&
|
|
74
|
+
%i[deliver_now deliver_later].include?(delivery_method.to_sym)
|
|
75
|
+
fail_with("invalid_delivery_options") unless delivery_options.is_a?(Hash) && delivery_options.all? do |key, value|
|
|
76
|
+
%i[open_timeout read_timeout].include?(key) && value.is_a?(Numeric) && value.finite? &&
|
|
77
|
+
value.respond_to?(:positive?) && value.positive?
|
|
78
|
+
end
|
|
79
|
+
if adapter == :email
|
|
80
|
+
fail_with("invalid_recipient") unless to.is_a?(String) && to.length <= 254 &&
|
|
81
|
+
to.match?(URI::MailTo::EMAIL_REGEXP) && !to.match?(/[\r\n,;]/)
|
|
82
|
+
fail_with("missing_subject") unless subject.is_a?(String) && !subject.strip.empty? && !subject.match?(/[\r\n]/)
|
|
83
|
+
elsif adapter == :telegram
|
|
84
|
+
fail_with("invalid_delivery_options") unless delivery_method.to_sym == :deliver_now
|
|
85
|
+
fail_with("invalid_recipient") unless to.is_a?(Hash) && to.size == 2 && to.key?(:bot) && to.key?(:chat_id) &&
|
|
86
|
+
to[:bot].respond_to?(:to_sym) && TelegramClient.valid_chat_id?(to[:chat_id])
|
|
87
|
+
bot = Notify.config.adapters.fetch(:telegram).fetch(:bots).find { |item| item[:name].to_s == to[:bot].to_s }
|
|
88
|
+
fail_with("unknown_bot") unless bot
|
|
89
|
+
fail_with("bot_disabled") unless bot.fetch(:enabled, true)
|
|
90
|
+
fail_with("invalid_delivery_options") unless delivery_options.empty?
|
|
91
|
+
else
|
|
92
|
+
fail_with("unsupported_strict_adapter")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def json_value?(value, depth = 0)
|
|
97
|
+
return false if depth > 20
|
|
98
|
+
case value
|
|
99
|
+
when Hash then value.all? { |key, item| (key.is_a?(String) || key.is_a?(Symbol)) && json_value?(item, depth + 1) }
|
|
100
|
+
when Array then value.all? { |item| json_value?(item, depth + 1) }
|
|
101
|
+
when String, Integer, true, false, nil then true
|
|
102
|
+
when Float then value.finite?
|
|
103
|
+
else false
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def fail_with(code)
|
|
108
|
+
raise DeliveryFailure, code
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Notify
|
|
4
|
+
class StrictMailDeliveryJob < ActionMailer::MailDeliveryJob
|
|
5
|
+
self.log_arguments = false
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# Reuses the ordinary mailer while keeping strict destinations/content out of mail telemetry.
|
|
9
|
+
class StrictMailer < Mailer
|
|
10
|
+
self.delivery_job = StrictMailDeliveryJob
|
|
11
|
+
|
|
12
|
+
# Parameterized delivery keeps process.action_mailer args limited to the message name.
|
|
13
|
+
def dispatch_strict(message_name)
|
|
14
|
+
attributes = params.to_h.symbolize_keys
|
|
15
|
+
@_strict_recipient = attributes.fetch(:to).fetch(0).dup.freeze
|
|
16
|
+
message = dispatch(message_name, **attributes.merge(strict: true))
|
|
17
|
+
message.delivery_handler = self
|
|
18
|
+
message
|
|
19
|
+
rescue DeliveryFailure => error
|
|
20
|
+
raise error, cause: nil
|
|
21
|
+
rescue StandardError
|
|
22
|
+
raise DeliveryFailure.new("render_failed"), cause: nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Mail invokes its handler after interceptors and before the transport.
|
|
26
|
+
def deliver_mail(mail, &block)
|
|
27
|
+
Adapters::Email.validate_transport!(mail)
|
|
28
|
+
unless mail.destinations == [@_strict_recipient] && Array(mail.smtp_envelope_to) == [@_strict_recipient]
|
|
29
|
+
raise DeliveryFailure.new("recipient_changed", attempted: false), cause: nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
mail.delivery_method.extend(Adapters::Email::SMTPAcceptance) if mail.delivery_method.is_a?(Mail::SMTP)
|
|
33
|
+
self.class.deliver_mail(mail, &block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
def deliver_mail(mail)
|
|
38
|
+
super do
|
|
39
|
+
yield
|
|
40
|
+
rescue DeliveryFailure => error
|
|
41
|
+
raise error, cause: nil
|
|
42
|
+
rescue StandardError => error
|
|
43
|
+
# Sanitize before ActiveSupport adds the exception to its event and logs it.
|
|
44
|
+
raise Adapters::Email.delivery_failure(error), cause: nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def set_payload_for_mail(payload, mail)
|
|
51
|
+
super
|
|
52
|
+
%i[mail subject to from cc bcc].each { |key| payload[key] = "[FILTERED]" if payload.key?(key) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Notify
|
|
7
|
+
class TelegramClient
|
|
8
|
+
MAX_RESPONSE_BYTES = 65_536
|
|
9
|
+
REASONS = {
|
|
10
|
+
"Forbidden: bot is not a member of the channel chat" => "bot_not_member",
|
|
11
|
+
"Forbidden: bot was kicked from the channel chat" => "bot_not_member",
|
|
12
|
+
"Forbidden: bot was kicked from the supergroup chat" => "bot_not_member",
|
|
13
|
+
"Forbidden: bot was blocked by the user" => "bot_blocked",
|
|
14
|
+
"Bad Request: chat not found" => "chat_unavailable",
|
|
15
|
+
"Bad Request: not enough rights to send text messages to the chat" => "posting_forbidden",
|
|
16
|
+
"Bad Request: not enough rights to send text messages to the channel" => "posting_forbidden",
|
|
17
|
+
"Bad Request: CHAT_WRITE_FORBIDDEN" => "posting_forbidden"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def self.valid_chat_id?(value)
|
|
21
|
+
value.is_a?(String) && value.length <= 64 && value.match?(/\A(?:-?[1-9]\d*|@[a-zA-Z][a-zA-Z0-9_]{4,31})\z/)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.validate!(token:, chat_id:, text:)
|
|
25
|
+
unless token.is_a?(String) && token.length <= 256 && token.match?(/\A\d+:[A-Za-z0-9_-]+\z/)
|
|
26
|
+
raise DeliveryFailure, "invalid_bot_token"
|
|
27
|
+
end
|
|
28
|
+
raise DeliveryFailure, "invalid_recipient" unless valid_chat_id?(chat_id)
|
|
29
|
+
unless text.is_a?(String) && text.valid_encoding? && text.length.between?(1, 4096)
|
|
30
|
+
raise DeliveryFailure, "invalid_message_length"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.timeouts
|
|
35
|
+
config = Notify.config.adapters.fetch(:telegram)
|
|
36
|
+
{ open_timeout: config.fetch(:open_timeout, 5), read_timeout: config.fetch(:read_timeout, 15),
|
|
37
|
+
write_timeout: config.fetch(:write_timeout, 15) }.tap do |options|
|
|
38
|
+
unless options.values.all? { |value| (value.is_a?(Integer) || value.is_a?(Float)) && value.finite? && value.positive? }
|
|
39
|
+
raise DeliveryFailure, "invalid_delivery_options"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def send_message(token:, chat_id:, text:, timeouts: self.class.timeouts)
|
|
45
|
+
transmitting = false
|
|
46
|
+
self.class.validate!(token: token, chat_id: chat_id, text: text)
|
|
47
|
+
http = Net::HTTP.new("api.telegram.org", 443)
|
|
48
|
+
http.use_ssl = true
|
|
49
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
50
|
+
http.open_timeout = timeouts.fetch(:open_timeout)
|
|
51
|
+
http.read_timeout = timeouts.fetch(:read_timeout)
|
|
52
|
+
http.write_timeout = timeouts.fetch(:write_timeout)
|
|
53
|
+
http.max_retries = 0
|
|
54
|
+
request = Net::HTTP::Post.new("/bot#{token}/sendMessage", "Content-Type" => "application/json")
|
|
55
|
+
request.body = JSON.generate(chat_id: chat_id, text: text, link_preview_options: { is_disabled: true })
|
|
56
|
+
status = nil
|
|
57
|
+
body = +""
|
|
58
|
+
http.start do |connection|
|
|
59
|
+
transmitting = true
|
|
60
|
+
connection.request(request) do |response|
|
|
61
|
+
status = response.code.to_i
|
|
62
|
+
response.read_body do |chunk|
|
|
63
|
+
raise DeliveryFailure.new("telegram_outcome_unknown", disposition: :uncertain) if body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
|
|
64
|
+
body << chunk
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
result(status, body)
|
|
69
|
+
rescue DeliveryFailure => error
|
|
70
|
+
raise error, cause: nil
|
|
71
|
+
rescue StandardError
|
|
72
|
+
code = transmitting ? "telegram_outcome_unknown" : "connection_failed"
|
|
73
|
+
raise DeliveryFailure.new(code, disposition: transmitting ? :uncertain : :retryable), cause: nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def result(status, body)
|
|
79
|
+
data = JSON.parse(body)
|
|
80
|
+
unless data.is_a?(Hash)
|
|
81
|
+
raise DeliveryFailure.new("telegram_outcome_unknown", disposition: :uncertain, http_status: status)
|
|
82
|
+
end
|
|
83
|
+
if status.between?(200, 299) && data["ok"] == true && data["result"].is_a?(Hash) &&
|
|
84
|
+
data["result"]["message_id"].is_a?(Integer) && data["result"]["message_id"].positive?
|
|
85
|
+
return DeliveryResult.new(adapter: :telegram, status: :accepted, provider_id: data["result"]["message_id"])
|
|
86
|
+
end
|
|
87
|
+
if data["ok"] == false && data["error_code"].is_a?(Integer) && data["error_code"].between?(400, 599)
|
|
88
|
+
provider_code = data["error_code"]
|
|
89
|
+
parameters = data["parameters"].is_a?(Hash) ? data["parameters"] : {}
|
|
90
|
+
retry_after = parameters["retry_after"]
|
|
91
|
+
retry_after = nil unless retry_after.is_a?(Integer) && retry_after.positive?
|
|
92
|
+
code = if parameters["migrate_to_chat_id"].is_a?(Integer)
|
|
93
|
+
"chat_migrated"
|
|
94
|
+
elsif provider_code == 401
|
|
95
|
+
"invalid_bot_token"
|
|
96
|
+
elsif provider_code == 429
|
|
97
|
+
"rate_limited"
|
|
98
|
+
else
|
|
99
|
+
REASONS.fetch(data["description"], "telegram_rejected")
|
|
100
|
+
end
|
|
101
|
+
disposition = provider_code == 429 || provider_code >= 500 ? :retryable : :permanent
|
|
102
|
+
raise DeliveryFailure.new(code, disposition: disposition, http_status: status,
|
|
103
|
+
provider_error_code: provider_code, retry_after: retry_after)
|
|
104
|
+
end
|
|
105
|
+
raise DeliveryFailure.new("telegram_outcome_unknown", disposition: :uncertain, http_status: status)
|
|
106
|
+
rescue JSON::ParserError
|
|
107
|
+
raise DeliveryFailure.new("telegram_outcome_unknown", disposition: :uncertain, http_status: status), cause: nil
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_view"
|
|
4
|
+
|
|
5
|
+
module Notify
|
|
6
|
+
class TelegramRenderer
|
|
7
|
+
def render(message_name:, locals:, template_path:)
|
|
8
|
+
view = ActionView::Base.with_empty_template_cache.with_view_paths([template_path.to_s], locals.stringify_keys)
|
|
9
|
+
view.render(template: message_name.to_s, formats: [:text], locals: locals.symbolize_keys)
|
|
10
|
+
rescue StandardError
|
|
11
|
+
raise DeliveryFailure.new("render_failed"), cause: nil
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/notify/test_helper.rb
CHANGED
data/lib/notify/version.rb
CHANGED
data/lib/notify.rb
CHANGED
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "notify/version"
|
|
4
4
|
require_relative "notify/errors"
|
|
5
|
+
require_relative "notify/delivery_result"
|
|
5
6
|
require_relative "notify/config"
|
|
6
7
|
require_relative "notify/payload_class"
|
|
7
8
|
require_relative "notify/registry"
|
|
8
9
|
require_relative "notify/adapters/base"
|
|
9
10
|
require_relative "notify/mailer"
|
|
11
|
+
require_relative "notify/strict_mailer"
|
|
10
12
|
require_relative "notify/adapters/email"
|
|
13
|
+
require_relative "notify/telegram_client"
|
|
14
|
+
require_relative "notify/telegram_renderer"
|
|
15
|
+
require_relative "notify/adapters/telegram"
|
|
11
16
|
require_relative "notify/dispatcher"
|
|
17
|
+
require_relative "notify/strict_delivery"
|
|
12
18
|
require_relative "notify/engine"
|
|
13
19
|
require_relative "notify/test_helper"
|
|
14
20
|
|
|
@@ -84,6 +90,14 @@ module Notify
|
|
|
84
90
|
end
|
|
85
91
|
end
|
|
86
92
|
|
|
93
|
+
def deliver(name, **options)
|
|
94
|
+
StrictDelivery.new.call(name, **options)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def prepare(name, **options)
|
|
98
|
+
StrictDelivery.new.prepare(name, **options)
|
|
99
|
+
end
|
|
100
|
+
|
|
87
101
|
private
|
|
88
102
|
|
|
89
103
|
def adapter_classes
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: notify-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nikita Shkoda
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: actionmailer
|
|
@@ -150,6 +149,26 @@ dependencies:
|
|
|
150
149
|
- - "<"
|
|
151
150
|
- !ruby/object:Gem::Version
|
|
152
151
|
version: '9.0'
|
|
152
|
+
- !ruby/object:Gem::Dependency
|
|
153
|
+
name: webmock
|
|
154
|
+
requirement: !ruby/object:Gem::Requirement
|
|
155
|
+
requirements:
|
|
156
|
+
- - ">="
|
|
157
|
+
- !ruby/object:Gem::Version
|
|
158
|
+
version: '3.23'
|
|
159
|
+
- - "<"
|
|
160
|
+
- !ruby/object:Gem::Version
|
|
161
|
+
version: '4.0'
|
|
162
|
+
type: :development
|
|
163
|
+
prerelease: false
|
|
164
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
165
|
+
requirements:
|
|
166
|
+
- - ">="
|
|
167
|
+
- !ruby/object:Gem::Version
|
|
168
|
+
version: '3.23'
|
|
169
|
+
- - "<"
|
|
170
|
+
- !ruby/object:Gem::Version
|
|
171
|
+
version: '4.0'
|
|
153
172
|
description: Rails engine gem for filesystem-driven notification routing, adapter
|
|
154
173
|
delivery, and optional payload computation.
|
|
155
174
|
email:
|
|
@@ -166,13 +185,19 @@ files:
|
|
|
166
185
|
- lib/notify.rb
|
|
167
186
|
- lib/notify/adapters/base.rb
|
|
168
187
|
- lib/notify/adapters/email.rb
|
|
188
|
+
- lib/notify/adapters/telegram.rb
|
|
169
189
|
- lib/notify/config.rb
|
|
190
|
+
- lib/notify/delivery_result.rb
|
|
170
191
|
- lib/notify/dispatcher.rb
|
|
171
192
|
- lib/notify/engine.rb
|
|
172
193
|
- lib/notify/errors.rb
|
|
173
194
|
- lib/notify/mailer.rb
|
|
174
195
|
- lib/notify/payload_class.rb
|
|
175
196
|
- lib/notify/registry.rb
|
|
197
|
+
- lib/notify/strict_delivery.rb
|
|
198
|
+
- lib/notify/strict_mailer.rb
|
|
199
|
+
- lib/notify/telegram_client.rb
|
|
200
|
+
- lib/notify/telegram_renderer.rb
|
|
176
201
|
- lib/notify/test_helper.rb
|
|
177
202
|
- lib/notify/version.rb
|
|
178
203
|
homepage: https://github.com/lstpsche/notify-engine-gem
|
|
@@ -183,7 +208,6 @@ metadata:
|
|
|
183
208
|
source_code_uri: https://github.com/lstpsche/notify-engine-gem
|
|
184
209
|
changelog_uri: https://github.com/lstpsche/notify-engine-gem/blob/main/CHANGELOG.md
|
|
185
210
|
bug_tracker_uri: https://github.com/lstpsche/notify-engine-gem/issues
|
|
186
|
-
post_install_message:
|
|
187
211
|
rdoc_options: []
|
|
188
212
|
require_paths:
|
|
189
213
|
- lib
|
|
@@ -198,8 +222,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
198
222
|
- !ruby/object:Gem::Version
|
|
199
223
|
version: '0'
|
|
200
224
|
requirements: []
|
|
201
|
-
rubygems_version:
|
|
202
|
-
signing_key:
|
|
225
|
+
rubygems_version: 4.0.7
|
|
203
226
|
specification_version: 4
|
|
204
227
|
summary: Convention-over-configuration multi-channel notifications for Rails.
|
|
205
228
|
test_files: []
|