event_trigger 0.1.1 → 0.1.2
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/README.md +71 -13
- data/lib/event_trigger/configuration.rb +54 -3
- data/lib/event_trigger/defaults.rb +23 -0
- data/lib/event_trigger/dispatcher.rb +178 -38
- data/lib/event_trigger/event.rb +24 -2
- data/lib/event_trigger/provider.rb +59 -2
- data/lib/event_trigger/providers/discord_provider.rb +4 -3
- data/lib/event_trigger/providers/email_provider.rb +39 -12
- data/lib/event_trigger/providers/http_poster.rb +50 -5
- data/lib/event_trigger/providers/slack_provider.rb +57 -27
- data/lib/event_trigger/providers/sms_provider.rb +83 -0
- data/lib/event_trigger/providers/teams_provider.rb +67 -0
- data/lib/event_trigger/providers/telegram_provider.rb +87 -0
- data/lib/event_trigger/providers/twilio_client.rb +45 -0
- data/lib/event_trigger/providers/webhook_provider.rb +23 -21
- data/lib/event_trigger/providers/whatsapp_provider.rb +15 -11
- data/lib/event_trigger/railtie.rb +29 -10
- data/lib/event_trigger/registry.rb +16 -2
- data/lib/event_trigger/threading.rb +18 -0
- data/lib/event_trigger/version.rb +1 -1
- data/lib/event_trigger.rb +75 -13
- data/lib/generators/event_trigger/templates/event_trigger.rb +44 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fe43ce630c79111cd2c038186f60a7fbf7130ffe2907d6ab38e95d9e7cfbb1c8
|
|
4
|
+
data.tar.gz: 8618fa832234644c1060f4dde3b4549bef92a832af6bfff34f7b783b4804abbd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 015e5be5caa07396a767ba1702f87f547eceaea17c6c27eebc3be3e76d54f20fa7502301a47f93840f4bca17b41fc32b6c2f34dd2ac19f9c2b68a44860ab53aa
|
|
7
|
+
data.tar.gz: 4820ccdc4149f082b6f6647086503c0d17f5dabec9274107354a189247bf3c16e38f23b85f68bd6155fe19278f2d4fbc7cadddb86473b204f87a21f6a0dad026
|
data/README.md
CHANGED
|
@@ -71,8 +71,61 @@ config.slack.events = nil # or [] → all events
|
|
|
71
71
|
|
|
72
72
|
### Rails initializer
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
Generate the default config (recommended):
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
rails generate event_trigger:install
|
|
78
|
+
# overwrite an existing one:
|
|
79
|
+
rails generate event_trigger:install --force
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This copies `config/initializers/event_trigger.rb` into your app:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
EventTrigger.configure do |config|
|
|
86
|
+
config.enabled = true
|
|
87
|
+
|
|
88
|
+
config.slack.enabled = true
|
|
89
|
+
config.slack.webhook_url = ENV["SLACK_WEBHOOK_URL"]
|
|
90
|
+
# NEW: Slack Web API mode (additive) — set BOTH to use chat.postMessage:
|
|
91
|
+
# config.slack.token = ENV["SLACK_BOT_TOKEN"]
|
|
92
|
+
# config.slack.channel = ENV["SLACK_CHANNEL"]
|
|
93
|
+
config.slack.events = nil # or your event names
|
|
94
|
+
|
|
95
|
+
config.email.enabled = false
|
|
96
|
+
# Rails apps: uncomment for ActionMailer delivery:
|
|
97
|
+
# config.email.delivery_method = :action_mailer
|
|
98
|
+
config.email.from = ENV["EVENT_TRIGGER_EMAIL_FROM"]
|
|
99
|
+
# NEW: spec-style SMTP hash also works (string/symbol keys):
|
|
100
|
+
# config.email.smtp = {
|
|
101
|
+
# address: ENV["SMTP_ADDRESS"], port: ENV["SMTP_PORT"],
|
|
102
|
+
# username: ENV["SMTP_USERNAME"], password: ENV["SMTP_PASSWORD"]
|
|
103
|
+
# }
|
|
104
|
+
config.email.events = nil
|
|
105
|
+
|
|
106
|
+
config.webhook.enabled = false
|
|
107
|
+
# NEW: config.webhook[:method] = :post # :post/:put/:patch/:delete/:get
|
|
108
|
+
config.discord.enabled = false
|
|
109
|
+
config.whatsapp.enabled = false
|
|
110
|
+
# NEW providers (all disabled by default):
|
|
111
|
+
# config.telegram.enabled = true
|
|
112
|
+
# config.telegram.bot_token = ENV["TELEGRAM_BOT_TOKEN"]
|
|
113
|
+
# config.telegram.chat_id = ENV["TELEGRAM_CHAT_ID"]
|
|
114
|
+
# config.teams.enabled = true
|
|
115
|
+
# config.teams.access_token = ENV["TEAMS_ACCESS_TOKEN"]
|
|
116
|
+
# config.teams.team_id = ENV["TEAMS_TEAM_ID"]
|
|
117
|
+
# config.teams.channel_id = ENV["TEAMS_CHANNEL_ID"]
|
|
118
|
+
# config.sms.enabled = true # provider :twilio (Twilio Messaging API)
|
|
119
|
+
# config.sms.account_sid = ENV["TWILIO_ACCOUNT_SID"]
|
|
120
|
+
# config.sms.auth_token = ENV["TWILIO_AUTH_TOKEN"]
|
|
121
|
+
# config.sms.from = ENV["TWILIO_FROM"]
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
No generator run? The gem's Railtie auto-applies these defaults on
|
|
126
|
+
boot and auto-creates the initializer file when missing (never
|
|
127
|
+
overwrites). Manual alternative: copy `examples/initializer.rb` to
|
|
128
|
+
`config/initializers/event_trigger.rb`.
|
|
76
129
|
|
|
77
130
|
## Triggering events
|
|
78
131
|
|
|
@@ -84,6 +137,22 @@ Rules: `loan.activated` with Slack+Email enabled and subscribed runs both;
|
|
|
84
137
|
`loan.overdue` with only Slack subscribed runs Slack only; with
|
|
85
138
|
`config.enabled = false` nothing runs.
|
|
86
139
|
|
|
140
|
+
`trigger` still returns the `Event` (unchanged). NEW additive result:
|
|
141
|
+
the provider-wise map is exposed without changing the return value:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
event = EventTrigger.trigger("loan.activated", data: { loan_id: 123 })
|
|
145
|
+
EventTrigger.last_result
|
|
146
|
+
# => { event: "loan.activated", status: "success",
|
|
147
|
+
# providers: { slack: "sent", email: "sent", discord: "skipped (disabled)",
|
|
148
|
+
# telegram: "skipped (disabled)", teams: "skipped (disabled)",
|
|
149
|
+
# sms: "skipped (disabled)", webhook: "sent" } }
|
|
150
|
+
event.last_result # same hash
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Statuses: `"sent"` | `"failed"` | `"skipped ..."` (reason included).
|
|
154
|
+
One provider failing never stops the others (isolation preserved).
|
|
155
|
+
|
|
87
156
|
## Handlers
|
|
88
157
|
|
|
89
158
|
```ruby
|
|
@@ -146,17 +215,6 @@ config.whatsapp.events = ["app.error"]
|
|
|
146
215
|
Per-trigger override: `data: { whatsapp_to: "whatsapp:+919..." }`.
|
|
147
216
|
Numbers without the `whatsapp:` scheme are normalized automatically.
|
|
148
217
|
|
|
149
|
-
## Crash safety
|
|
150
|
-
|
|
151
|
-
Every new provider follows the same contract as the old ones:
|
|
152
|
-
- Missing config / missing `twilio-ruby` gem / network errors raise
|
|
153
|
-
`EventTrigger::Error` inside `#deliver` only.
|
|
154
|
-
- `EventTrigger::Dispatcher` rescues per provider, logs
|
|
155
|
-
`provider ':x' failed ...`, and keeps running the rest — **the host
|
|
156
|
-
app never crashes because a notification failed**.
|
|
157
|
-
- HTTP providers (Slack/Webhook/Discord) try stdlib `Net::HTTP`
|
|
158
|
-
first and fall back to `Faraday` when installed.
|
|
159
|
-
|
|
160
218
|
## Logging
|
|
161
219
|
|
|
162
220
|
Set `config.logger`. Logs: event triggered, provider executed, provider
|
|
@@ -36,6 +36,18 @@ module EventTrigger
|
|
|
36
36
|
# Generic option bag for provider-specific settings
|
|
37
37
|
# (webhook_url, from, url, ...). Allows:
|
|
38
38
|
# config.slack.webhook_url = "..."
|
|
39
|
+
#
|
|
40
|
+
# NOTE: `config[:key]` reads raw options without hitting Ruby's own
|
|
41
|
+
# methods (e.g. `config.method` would call Object#method, so use
|
|
42
|
+
# `config[:method]` for the webhook HTTP verb).
|
|
43
|
+
def [](key)
|
|
44
|
+
@options[safe_key(key)]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def []=(key, value)
|
|
48
|
+
@options[safe_key(key)] = value
|
|
49
|
+
end
|
|
50
|
+
|
|
39
51
|
def method_missing(name, *args)
|
|
40
52
|
name_str = name.to_s
|
|
41
53
|
if name_str.end_with?("=")
|
|
@@ -60,12 +72,29 @@ module EventTrigger
|
|
|
60
72
|
def to_h
|
|
61
73
|
{ enabled: enabled, events: events }.merge(@options)
|
|
62
74
|
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Never let a non-symbolizable key raise (e.g. Integer keys).
|
|
79
|
+
def safe_key(key)
|
|
80
|
+
key.respond_to?(:to_sym) ? key.to_sym : key.to_s.to_sym
|
|
81
|
+
rescue StandardError
|
|
82
|
+
key.to_s
|
|
83
|
+
end
|
|
63
84
|
end
|
|
64
85
|
|
|
65
86
|
# Global configuration object yielded by EventTrigger.configure.
|
|
66
87
|
class Configuration
|
|
67
|
-
|
|
68
|
-
|
|
88
|
+
# Hard safety net: no provider call may hang a host app forever.
|
|
89
|
+
# 0 (or nil) disables the timeout. Per-provider override:
|
|
90
|
+
# config.slack.delivery_timeout = 5
|
|
91
|
+
DEFAULT_DELIVERY_TIMEOUT = 15
|
|
92
|
+
# Handlers are NOT time-limited by default (0 = disabled) because a
|
|
93
|
+
# killed handler could leave partial work behind. Set to enable.
|
|
94
|
+
DEFAULT_HANDLER_TIMEOUT = 0
|
|
95
|
+
|
|
96
|
+
attr_accessor :enabled, :logger, :delivery_timeout, :handler_timeout
|
|
97
|
+
attr_reader :slack, :email, :webhook, :discord, :whatsapp, :telegram, :teams, :sms
|
|
69
98
|
|
|
70
99
|
# Backwards-compatible top-level shortcuts required by the spec:
|
|
71
100
|
# config.slack_webhook_url = ENV["SLACK_WEBHOOK_URL"]
|
|
@@ -74,14 +103,30 @@ module EventTrigger
|
|
|
74
103
|
def initialize
|
|
75
104
|
@enabled = true
|
|
76
105
|
@logger = nil
|
|
106
|
+
@delivery_timeout = DEFAULT_DELIVERY_TIMEOUT
|
|
107
|
+
@handler_timeout = DEFAULT_HANDLER_TIMEOUT
|
|
77
108
|
@slack = ProviderConfig.new
|
|
78
109
|
@email = ProviderConfig.new
|
|
79
110
|
@webhook = ProviderConfig.new
|
|
80
111
|
@discord = ProviderConfig.new
|
|
81
112
|
@whatsapp = ProviderConfig.new
|
|
113
|
+
@telegram = ProviderConfig.new
|
|
114
|
+
@teams = ProviderConfig.new
|
|
115
|
+
@sms = ProviderConfig.new
|
|
82
116
|
@custom = {}
|
|
83
117
|
end
|
|
84
118
|
|
|
119
|
+
# Effective timeout for one provider (per-provider wins, then global).
|
|
120
|
+
def timeout_for(provider_config)
|
|
121
|
+
per_provider = begin
|
|
122
|
+
provider_config[:delivery_timeout]
|
|
123
|
+
rescue StandardError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
value = per_provider.nil? ? @delivery_timeout : per_provider
|
|
127
|
+
value.nil? ? DEFAULT_DELIVERY_TIMEOUT : value.to_f
|
|
128
|
+
end
|
|
129
|
+
|
|
85
130
|
def slack_webhook_url
|
|
86
131
|
slack.webhook_url
|
|
87
132
|
end
|
|
@@ -117,6 +162,9 @@ module EventTrigger
|
|
|
117
162
|
when :webhook then webhook
|
|
118
163
|
when :discord then discord
|
|
119
164
|
when :whatsapp then whatsapp
|
|
165
|
+
when :telegram then telegram
|
|
166
|
+
when :teams then teams
|
|
167
|
+
when :sms then sms
|
|
120
168
|
else
|
|
121
169
|
@custom[key] ||= ProviderConfig.new
|
|
122
170
|
end
|
|
@@ -124,9 +172,12 @@ module EventTrigger
|
|
|
124
172
|
alias provider for
|
|
125
173
|
|
|
126
174
|
# All provider configs keyed by name, including custom ones.
|
|
175
|
+
# Order here is the dispatch order (existing providers first so
|
|
176
|
+
# old result hashes keep their key order for backwards compat).
|
|
127
177
|
def providers
|
|
128
178
|
{ slack: slack, email: email, webhook: webhook,
|
|
129
|
-
discord: discord, whatsapp: whatsapp
|
|
179
|
+
discord: discord, whatsapp: whatsapp,
|
|
180
|
+
telegram: telegram, teams: teams, sms: sms }.merge(@custom)
|
|
130
181
|
end
|
|
131
182
|
|
|
132
183
|
def enabled?
|
|
@@ -17,6 +17,7 @@ module EventTrigger
|
|
|
17
17
|
# config.email.from = ENV["EVENT_TRIGGER_EMAIL_FROM"]
|
|
18
18
|
# ...
|
|
19
19
|
# end
|
|
20
|
+
# Never raises: called from the Railtie at boot time.
|
|
20
21
|
def self.apply_defaults!(logger: nil)
|
|
21
22
|
configure do |config|
|
|
22
23
|
config.enabled = true
|
|
@@ -46,6 +47,28 @@ module EventTrigger
|
|
|
46
47
|
config.whatsapp.twilio_from ||= ENV["TWILIO_WHATSAPP_FROM"]
|
|
47
48
|
config.whatsapp.twilio_to ||= ENV["TWILIO_WHATSAPP_TO"]
|
|
48
49
|
config.whatsapp.events ||= []
|
|
50
|
+
|
|
51
|
+
# NEW providers default to disabled + unsubscribed; enabling them
|
|
52
|
+
# is purely opt-in so existing apps see no behaviour change.
|
|
53
|
+
config.telegram.enabled = false
|
|
54
|
+
config.telegram.bot_token ||= ENV["TELEGRAM_BOT_TOKEN"]
|
|
55
|
+
config.telegram.chat_id ||= ENV["TELEGRAM_CHAT_ID"]
|
|
56
|
+
config.telegram.events ||= []
|
|
57
|
+
|
|
58
|
+
config.teams.enabled = false
|
|
59
|
+
config.teams.access_token ||= ENV["TEAMS_ACCESS_TOKEN"]
|
|
60
|
+
config.teams.team_id ||= ENV["TEAMS_TEAM_ID"]
|
|
61
|
+
config.teams.channel_id ||= ENV["TEAMS_CHANNEL_ID"]
|
|
62
|
+
config.teams.events ||= []
|
|
63
|
+
|
|
64
|
+
config.sms.enabled = false
|
|
65
|
+
config.sms.provider ||= :twilio
|
|
66
|
+
config.sms.account_sid ||= ENV["TWILIO_ACCOUNT_SID"]
|
|
67
|
+
config.sms.auth_token ||= ENV["TWILIO_AUTH_TOKEN"]
|
|
68
|
+
config.sms.from ||= ENV["TWILIO_FROM"]
|
|
69
|
+
config.sms.events ||= []
|
|
49
70
|
end
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
50
73
|
end
|
|
51
74
|
end
|
|
@@ -1,70 +1,210 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
3
5
|
module EventTrigger
|
|
4
6
|
# Fans an Event out to every enabled provider subscribed to that event,
|
|
5
7
|
# then runs any user-registered handlers (EventTrigger.on).
|
|
6
8
|
#
|
|
7
|
-
#
|
|
9
|
+
# Safety contract (never harms the host app or system):
|
|
8
10
|
# - Global disable (config.enabled == false) => nothing runs.
|
|
9
11
|
# - Only enabled providers subscribed to the event run.
|
|
10
12
|
# - Unknown provider names are skipped (with a log line) instead of crashing.
|
|
11
13
|
# - One provider raising never prevents the others from running;
|
|
12
14
|
# failures are logged and swallowed so all providers always run.
|
|
13
|
-
# -
|
|
14
|
-
#
|
|
15
|
+
# - Every provider call is wrapped in a hard timeout
|
|
16
|
+
# (config.delivery_timeout, default 15s; per-provider override via
|
|
17
|
+
# config.<provider>.delivery_timeout) so a hanging API can never block
|
|
18
|
+
# a web request forever. 0 disables the timeout.
|
|
19
|
+
# - Handler blocks are isolated: a failing handler is logged and the rest
|
|
20
|
+
# still run. Optional handler timeout via config.handler_timeout (0 = off).
|
|
21
|
+
# - Logging failures (closed IO, disk full, broken custom logger) are
|
|
22
|
+
# swallowed — a logger must never break dispatch.
|
|
23
|
+
# - #dispatch itself never raises: any internal error is captured.
|
|
24
|
+
#
|
|
25
|
+
# Result shape (additive):
|
|
26
|
+
# { event:, status:, providers: { slack: "sent", email: "failed", ... } }
|
|
27
|
+
# statuses: "sent" | "failed" | "skipped ..." (reason included).
|
|
15
28
|
class Dispatcher
|
|
29
|
+
SKIP_DISABLED = "skipped (disabled)"
|
|
30
|
+
SKIP_UNSUBSCRIBED_PREFIX = "skipped (not subscribed"
|
|
31
|
+
SKIP_UNREGISTERED = "skipped (unregistered)"
|
|
32
|
+
STATUS_SENT = "sent"
|
|
33
|
+
STATUS_FAILED = "failed"
|
|
34
|
+
STATUS_ERROR = "error"
|
|
35
|
+
UNKNOWN = "<unknown>"
|
|
36
|
+
SECRET_KEYS = %w[token secret password passwd api_key apikey auth authorization bearer webhook sid].freeze
|
|
37
|
+
|
|
16
38
|
def initialize(configuration)
|
|
17
39
|
@configuration = configuration
|
|
18
40
|
end
|
|
19
41
|
|
|
20
|
-
# Returns the
|
|
42
|
+
# Returns the provider-wise result hash. Still runs handlers.
|
|
43
|
+
# Never raises.
|
|
21
44
|
def dispatch(event, handlers = [])
|
|
22
|
-
log("event triggered: #{event
|
|
23
|
-
return
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
log("event triggered: #{event_name(event)} payload=#{redact_payload(payload_of(event))}")
|
|
46
|
+
return build_result(event, {}, disabled_globally: true) unless globally_enabled?
|
|
47
|
+
|
|
48
|
+
statuses = {}
|
|
49
|
+
providers_snapshot.each { |name, cfg| statuses[name] = run_provider(name, cfg, event) }
|
|
50
|
+
run_handlers(handlers, event)
|
|
51
|
+
build_result(event, statuses)
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
# Absolute last line of defence: a bug inside the dispatcher itself
|
|
54
|
+
# must never surface in the host app.
|
|
55
|
+
log("dispatcher error for '#{event_name(event)}': #{e.class}: #{redact(e.message)}")
|
|
56
|
+
build_result(event, {}, internal_error: "#{e.class}: #{redact(e.message)}")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Snapshot so concurrent configuration changes can't break iteration.
|
|
62
|
+
def providers_snapshot
|
|
63
|
+
@configuration.providers.to_a
|
|
64
|
+
rescue StandardError
|
|
65
|
+
[]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def globally_enabled?
|
|
69
|
+
@configuration.enabled?
|
|
70
|
+
rescue StandardError
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def run_provider(name, provider_config, event)
|
|
75
|
+
klass = Registry.fetch(name)
|
|
76
|
+
if klass.nil?
|
|
77
|
+
log("provider ':#{name}' has no registered class; skipping")
|
|
78
|
+
return SKIP_UNREGISTERED
|
|
79
|
+
end
|
|
80
|
+
unless provider_config.enabled
|
|
81
|
+
log("provider ':#{name}' skipped (disabled)")
|
|
82
|
+
return SKIP_DISABLED
|
|
83
|
+
end
|
|
84
|
+
unless provider_config.subscribed_to?(event_name(event))
|
|
85
|
+
log("provider ':#{name}' skipped (not subscribed to '#{event_name(event)}')")
|
|
86
|
+
return "#{SKIP_UNSUBSCRIBED_PREFIX} to '#{event_name(event)}')"
|
|
47
87
|
end
|
|
48
88
|
|
|
49
|
-
|
|
50
|
-
|
|
89
|
+
deliver_with_timeout(klass, name, provider_config, event)
|
|
90
|
+
log("provider ':#{name}' executed for '#{event_name(event)}'")
|
|
91
|
+
STATUS_SENT
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
log("provider ':#{name}' failed for '#{event_name(event)}': #{e.class}: #{redact(e.message)}")
|
|
94
|
+
STATUS_FAILED
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Hard timeout guard around the provider call (covers slow DNS, hung
|
|
98
|
+
# sockets, Twilio/SMTP stalls...). Timeout::Error is a StandardError,
|
|
99
|
+
# so it is reported as "failed" and the loop continues.
|
|
100
|
+
def deliver_with_timeout(klass, name, provider_config, event)
|
|
101
|
+
timeout = timeout_for(provider_config)
|
|
102
|
+
instance = klass.new(config: provider_config, global_config: @configuration)
|
|
103
|
+
return instance.deliver(event) if timeout.nil? || timeout <= 0
|
|
104
|
+
|
|
105
|
+
Timeout.timeout(timeout, Error, "provider ':#{name}' timed out after #{timeout}s") do
|
|
106
|
+
instance.deliver(event)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def timeout_for(provider_config)
|
|
111
|
+
@configuration.timeout_for(provider_config)
|
|
112
|
+
rescue StandardError
|
|
113
|
+
Configuration::DEFAULT_DELIVERY_TIMEOUT
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def run_handlers(handlers, event)
|
|
117
|
+
Array(handlers).each { |handler| run_handler(handler, event) }
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
log("handler iteration failed: #{e.class}: #{redact(e.message)}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def run_handler(handler, event)
|
|
123
|
+
return unless handler.respond_to?(:call)
|
|
124
|
+
|
|
125
|
+
timeout = handler_timeout
|
|
126
|
+
if timeout.nil? || timeout <= 0
|
|
127
|
+
handler.call(event)
|
|
128
|
+
else
|
|
129
|
+
Timeout.timeout(timeout, Error, "handler for '#{event_name(event)}' timed out after #{timeout}s") do
|
|
51
130
|
handler.call(event)
|
|
52
|
-
rescue StandardError => e
|
|
53
|
-
log("handler for '#{event.name}' failed: #{e.class}: #{e.message}")
|
|
54
131
|
end
|
|
55
132
|
end
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
log("handler for '#{event_name(event)}' failed: #{e.class}: #{redact(e.message)}")
|
|
135
|
+
end
|
|
56
136
|
|
|
57
|
-
|
|
137
|
+
def handler_timeout
|
|
138
|
+
value = @configuration.handler_timeout
|
|
139
|
+
value.nil? ? 0 : value.to_f
|
|
140
|
+
rescue StandardError
|
|
141
|
+
0
|
|
58
142
|
end
|
|
59
143
|
|
|
60
|
-
|
|
144
|
+
def build_result(event, statuses, disabled_globally: false, internal_error: nil)
|
|
145
|
+
if disabled_globally
|
|
146
|
+
statuses = providers_snapshot.map { |k, _| [k, SKIP_DISABLED] }.to_h
|
|
147
|
+
end
|
|
148
|
+
# Overall status mirrors the documented API shape:
|
|
149
|
+
# "success" unless the dispatcher itself blew up.
|
|
150
|
+
result = { event: event_name(event), status: "success", providers: statuses }
|
|
151
|
+
return result unless internal_error
|
|
152
|
+
|
|
153
|
+
result.merge(status: STATUS_ERROR, error: internal_error)
|
|
154
|
+
end
|
|
61
155
|
|
|
62
|
-
|
|
63
|
-
|
|
156
|
+
# Safe accessor: an Event-like object whose #name raises must not
|
|
157
|
+
# break the dispatcher.
|
|
158
|
+
def event_name(event)
|
|
159
|
+
event.respond_to?(:name) ? event.name.to_s : UNKNOWN
|
|
160
|
+
rescue StandardError
|
|
161
|
+
UNKNOWN
|
|
64
162
|
end
|
|
65
163
|
|
|
164
|
+
def payload_of(event)
|
|
165
|
+
event.payload
|
|
166
|
+
rescue StandardError
|
|
167
|
+
{}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Logging must never break dispatch (closed IO, disk full, broken
|
|
171
|
+
# custom logger, ...).
|
|
66
172
|
def log(message)
|
|
67
|
-
@configuration.logger
|
|
173
|
+
logger = @configuration.logger
|
|
174
|
+
return unless logger.respond_to?(:info)
|
|
175
|
+
|
|
176
|
+
logger.info("[EventTrigger] #{message}")
|
|
177
|
+
rescue StandardError
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Never log credential-looking fragments (tokens, passwords, ...).
|
|
182
|
+
def redact(message)
|
|
183
|
+
str = message.to_s.dup
|
|
184
|
+
SECRET_KEYS.each do |key|
|
|
185
|
+
str.gsub!(/#{key}[^,\s\]}]*[\w\-\.\/~+=\/]+/i, "#{key}=[FILTERED]")
|
|
186
|
+
end
|
|
187
|
+
str
|
|
188
|
+
rescue StandardError
|
|
189
|
+
"[unprintable]"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Payload logging is capped: a huge payload must not bloat logs/memory.
|
|
193
|
+
def redact_payload(payload, limit = 2000)
|
|
194
|
+
return "{}" unless payload.is_a?(Hash)
|
|
195
|
+
|
|
196
|
+
redacted = payload.each_with_object({}) { |(k, v), memo| memo[k] = secret_key?(k) ? "[FILTERED]" : v }
|
|
197
|
+
text = redacted.inspect
|
|
198
|
+
text.length > limit ? "#{text[0, limit]}…(truncated)" : text
|
|
199
|
+
rescue StandardError
|
|
200
|
+
"{}"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def secret_key?(key)
|
|
204
|
+
k = key.to_s.downcase
|
|
205
|
+
SECRET_KEYS.any? { |s| k.include?(s) }
|
|
206
|
+
rescue StandardError
|
|
207
|
+
false
|
|
68
208
|
end
|
|
69
209
|
end
|
|
70
210
|
end
|
data/lib/event_trigger/event.rb
CHANGED
|
@@ -6,15 +6,27 @@ module EventTrigger
|
|
|
6
6
|
# (anything the caller passes via data:), and a timestamp.
|
|
7
7
|
class Event
|
|
8
8
|
attr_reader :name, :payload, :timestamp
|
|
9
|
+
# Provider-wise result hash for this event's dispatch
|
|
10
|
+
# ({ event:, status:, providers: }). Set by EventTrigger.trigger.
|
|
11
|
+
attr_accessor :last_result
|
|
9
12
|
|
|
10
13
|
def initialize(name:, payload: {})
|
|
11
14
|
@name = name.to_s
|
|
12
15
|
@payload = symbolize(payload || {})
|
|
13
16
|
@timestamp = Time.now
|
|
17
|
+
@last_result = nil
|
|
14
18
|
end
|
|
15
19
|
|
|
20
|
+
# Safe lookup: works with symbols, strings and non-symbolizable keys.
|
|
16
21
|
def [](key)
|
|
17
|
-
|
|
22
|
+
sym = begin
|
|
23
|
+
key.to_sym
|
|
24
|
+
rescue StandardError
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
return payload[sym] if sym && payload.key?(sym)
|
|
28
|
+
|
|
29
|
+
payload[key.to_s]
|
|
18
30
|
end
|
|
19
31
|
|
|
20
32
|
def to_h
|
|
@@ -23,12 +35,22 @@ module EventTrigger
|
|
|
23
35
|
|
|
24
36
|
private
|
|
25
37
|
|
|
38
|
+
# Defensive: non-Hash payloads become {}, and odd keys (Integer,
|
|
39
|
+
# objects without #to_sym) fall back to strings instead of raising.
|
|
40
|
+
# This guarantees Event.new can never crash the caller.
|
|
26
41
|
def symbolize(hash)
|
|
27
42
|
return {} unless hash.is_a?(Hash)
|
|
28
43
|
|
|
29
44
|
hash.each_with_object({}) do |(k, v), memo|
|
|
30
|
-
|
|
45
|
+
key = begin
|
|
46
|
+
k.respond_to?(:to_sym) ? k.to_sym : k.to_s
|
|
47
|
+
rescue StandardError
|
|
48
|
+
k.to_s
|
|
49
|
+
end
|
|
50
|
+
memo[key] = v
|
|
31
51
|
end
|
|
52
|
+
rescue StandardError
|
|
53
|
+
{}
|
|
32
54
|
end
|
|
33
55
|
end
|
|
34
56
|
end
|
|
@@ -12,11 +12,18 @@ module EventTrigger
|
|
|
12
12
|
# end
|
|
13
13
|
# EventTrigger.register_provider(:sms, SmsProvider)
|
|
14
14
|
#
|
|
15
|
+
# Normalized alias: #send(event_name, data) builds an Event and
|
|
16
|
+
# delegates to #deliver. Built-in providers implement it; custom
|
|
17
|
+
# providers get a default that does the same.
|
|
18
|
+
#
|
|
15
19
|
# Helpers available to subclasses:
|
|
16
20
|
# - #config -> ProviderConfig for this provider
|
|
17
21
|
# - #global_config -> full EventTrigger::Configuration
|
|
18
|
-
# - #log(message) -> write to configured logger, if any
|
|
22
|
+
# - #log(message) -> write to configured logger, if any (never logs credentials)
|
|
19
23
|
class Provider
|
|
24
|
+
# Keys that must never appear in logs (case-insensitive substring match).
|
|
25
|
+
SECRET_KEYS = %w[token secret password passwd api_key apikey auth authorization bearer webhook].freeze
|
|
26
|
+
|
|
20
27
|
attr_reader :config, :global_config
|
|
21
28
|
|
|
22
29
|
def initialize(config:, global_config:)
|
|
@@ -29,6 +36,41 @@ module EventTrigger
|
|
|
29
36
|
raise NotImplementedError, "#{self.class} must implement #deliver(event)"
|
|
30
37
|
end
|
|
31
38
|
|
|
39
|
+
# Normalized interface: provider.send(event_name, data)
|
|
40
|
+
#
|
|
41
|
+
# NOTE: this intentionally shadows Ruby's Object#send for the
|
|
42
|
+
# notification signature. Calls that are clearly internal Ruby sends
|
|
43
|
+
# are forwarded to Object#send so frameworks/reflection keep working:
|
|
44
|
+
# provider.send(:deliver, event) -> Object#send
|
|
45
|
+
# provider.send(:some_helper, 1, 2) -> Object#send
|
|
46
|
+
# provider.send("loan.activated", {}) -> notification (deliver)
|
|
47
|
+
# provider.send("loan.activated") -> notification (empty payload)
|
|
48
|
+
# Ambiguous single-symbol form (provider.send(:foo)) is treated as
|
|
49
|
+
# Object#send; pass a String event name (or a data Hash) to notify.
|
|
50
|
+
def send(event_name, *args, &block)
|
|
51
|
+
notification =
|
|
52
|
+
if args.size == 1
|
|
53
|
+
args.first.nil? || args.first.is_a?(Hash)
|
|
54
|
+
elsif args.empty?
|
|
55
|
+
!event_name.is_a?(Symbol)
|
|
56
|
+
else
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
return __send__(event_name, *args, &block) unless notification
|
|
61
|
+
|
|
62
|
+
deliver(Event.new(name: event_name.to_s, payload: args.first || {}))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Normalized result wrapper: { provider:, status:, error: }.
|
|
66
|
+
# Never raises — failures are captured, never stop other providers.
|
|
67
|
+
def safe_send(event_name, data = {})
|
|
68
|
+
send(event_name, data)
|
|
69
|
+
{ provider: provider_name, status: "sent", error: nil }
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
{ provider: provider_name, status: "failed", error: "#{e.class}: #{e.message}" }
|
|
72
|
+
end
|
|
73
|
+
|
|
32
74
|
# Symbolic name derived from class name, e.g. SlackProvider -> :slack.
|
|
33
75
|
# Custom providers can override .provider_name.
|
|
34
76
|
def self.provider_name
|
|
@@ -43,7 +85,22 @@ module EventTrigger
|
|
|
43
85
|
|
|
44
86
|
def log(message)
|
|
45
87
|
logger = global_config.logger
|
|
46
|
-
logger
|
|
88
|
+
return unless logger.respond_to?(:info)
|
|
89
|
+
|
|
90
|
+
logger.info("[EventTrigger][#{provider_name}] #{redact(message)}")
|
|
91
|
+
rescue StandardError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Strip credential-looking fragments before they reach the log.
|
|
96
|
+
def redact(message)
|
|
97
|
+
str = message.to_s
|
|
98
|
+
SECRET_KEYS.each do |key|
|
|
99
|
+
str = str.gsub(/#{key}[^,\s\]}]*[\w\-\.\/~\+=\/]+/i, "#{key}=[FILTERED]")
|
|
100
|
+
end
|
|
101
|
+
str
|
|
102
|
+
rescue StandardError
|
|
103
|
+
"[unprintable]"
|
|
47
104
|
end
|
|
48
105
|
end
|
|
49
106
|
end
|
|
@@ -13,7 +13,7 @@ module EventTrigger
|
|
|
13
13
|
# config.discord.enabled = true
|
|
14
14
|
# config.discord.webhook_url = ENV["DISCORD_WEBHOOK_URL"]
|
|
15
15
|
# config.discord.username = "RailsErrorNotifier" # optional
|
|
16
|
-
# config.discord.events = ["
|
|
16
|
+
# config.discord.events = ["loan.activated", "loan.overdue"]
|
|
17
17
|
#
|
|
18
18
|
# Crash-safe: failures raise EventTrigger::Error, which the
|
|
19
19
|
# Dispatcher logs — the host app never crashes because of Discord.
|
|
@@ -30,11 +30,12 @@ module EventTrigger
|
|
|
30
30
|
url = config.webhook_url || config.url
|
|
31
31
|
raise Error, "Discord webhook_url is not configured" if url.nil? || url.to_s.strip.empty?
|
|
32
32
|
|
|
33
|
-
post_json(url.to_s, build_payload(event), service: "Discord")
|
|
33
|
+
post_json(url.to_s, build_payload(event), { "Content-Type" => "application/json" }, service: "Discord")
|
|
34
34
|
log("delivered event '#{event.name}'")
|
|
35
35
|
true
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
|
|
38
39
|
private
|
|
39
40
|
|
|
40
41
|
def build_payload(event)
|
|
@@ -63,7 +64,7 @@ module EventTrigger
|
|
|
63
64
|
truncate(err.to_s, 1500)
|
|
64
65
|
end
|
|
65
66
|
|
|
66
|
-
def truncate(str, limit)
|
|
67
|
+
def truncate(str, limit = 1500)
|
|
67
68
|
s = str.to_s
|
|
68
69
|
s.length > limit ? "#{s[0, limit]}…" : s
|
|
69
70
|
end
|