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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39c0803bd55c50a4d09940dc90870adcbc78170e5637492808914cb809d9617b
4
- data.tar.gz: 51cba5d01cd0528446a26fb23784c25227919b7a44b21ef05abb9d40f98d2f58
3
+ metadata.gz: fe43ce630c79111cd2c038186f60a7fbf7130ffe2907d6ab38e95d9e7cfbb1c8
4
+ data.tar.gz: 8618fa832234644c1060f4dde3b4549bef92a832af6bfff34f7b783b4804abbd
5
5
  SHA512:
6
- metadata.gz: '09db06666e34aa573d1651cb4d9dfdeaadbc967699f5bdc2fd0d23b344f8efe829afe97d3c175f3ba333894f7aec08a833cf4a6644c56bb06e37735e510d3d79'
7
- data.tar.gz: 86c01b75ebad0a5d4ee400f2feb6f03cc5c1e5ccf2bc2eeae1131cb5e8ef0c595d0f761ce78f396431bc103048ee47030bb38c7adf451c548e8a645bc0959b27
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
- Copy `examples/initializer.rb` to `config/initializers/event_trigger.rb`.
75
- A Railtie is shipped so the gem loads cleanly inside Rails.
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
- attr_accessor :enabled, :logger
68
- attr_reader :slack, :email, :webhook, :discord, :whatsapp
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 }.merge(@custom)
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
- # Guarantees:
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
- # - Handler blocks are likewise isolated: a failing handler is logged
14
- # and the rest still run.
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 list of provider instances that were executed.
42
+ # Returns the provider-wise result hash. Still runs handlers.
43
+ # Never raises.
21
44
  def dispatch(event, handlers = [])
22
- log("event triggered: #{event.name} payload=#{event.payload.inspect}")
23
- return [] unless @configuration.enabled?
24
-
25
- executed = []
26
- providers_for(event.name).each do |name, provider_config|
27
- klass = Registry.fetch(name)
28
- if klass.nil?
29
- log("provider ':#{name}' has no registered class; skipping")
30
- next
31
- end
32
- unless provider_config.enabled
33
- log("provider ':#{name}' skipped (disabled)")
34
- next
35
- end
36
- unless provider_config.subscribed_to?(event.name)
37
- log("provider ':#{name}' skipped (not subscribed to '#{event.name}')")
38
- next
39
- end
40
- begin
41
- klass.new(config: provider_config, global_config: @configuration).deliver(event)
42
- log("provider ':#{name}' executed for '#{event.name}'")
43
- executed << name
44
- rescue StandardError => e
45
- log("provider ':#{name}' failed for '#{event.name}': #{e.class}: #{e.message}")
46
- end
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
- Array(handlers).each do |handler|
50
- begin
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
- executed
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
- private
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
- def providers_for(_event_name)
63
- @configuration.providers
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&.info("[EventTrigger] #{message}")
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
@@ -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
- payload[key.to_sym] || payload[key.to_s]
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
- memo[k.to_sym] = v
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&.info("[EventTrigger][#{provider_name}] #{message}")
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 = ["app.error"]
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