event_trigger 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e2c3d6d3c6ba705815ec39d822e4a83d8d832a395b63eb85fd9b149e9cdc4a5e
4
+ data.tar.gz: 0e5339bc3d82788cdd942d0b7c69f979855379ac5f9c5b7ccde950617b46a9b8
5
+ SHA512:
6
+ metadata.gz: 399d0f668e2781a691c53bb6bc172ffbfc984656c50dc72e3eeda8831869209f1ac388c42faf6f5c3542bfa6aab9672c02d9c1a5730d7d85a0df4cdbe57f2930
7
+ data.tar.gz: 24811741cb39d9d792b138445754da696bdb4b514a4f194c4fbbbaed0b2b5854ab9c8a3285bc5ea40415a92182d3f1bf8269ef47d0c0d6d3e319cd65b7803ea6
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Kamlesh Parmar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # EventTrigger
2
+
3
+ Configurable event-based notification system for Ruby and Rails.
4
+
5
+ Configure notification providers **once**, then simply call:
6
+
7
+ ```ruby
8
+ EventTrigger.trigger("loan.activated", data: { loan_id: 123, customer_name: "Kamlesh" })
9
+ ```
10
+
11
+ The gem determines automatically which **enabled** providers are
12
+ configured for that event and executes only those providers.
13
+ Anything can be sent in `data:` -- passed through to providers/handlers.
14
+
15
+ ## Features
16
+
17
+ - Providers: **Slack**, **Email**, **Webhook**, plus custom providers
18
+ - Central `EventTrigger.configure` block (plain Ruby and Rails)
19
+ - Per-provider `enabled` flag + global `config.enabled` kill-switch
20
+ - Per-provider `events` allow-list (`nil`/empty = all events)
21
+ - Simple API: `trigger(name, data:)` + `on(name) { |event| }`
22
+ - Adapter architecture: every provider implements `#deliver(event)`
23
+ - Failure isolation: one provider failing never blocks others
24
+ - Optional logging: triggered / executed / skipped / failed
25
+ - No app-specific business logic -- just events + providers
26
+
27
+ ## Installation
28
+
29
+ ```ruby
30
+ gem "event_trigger"
31
+ ```
32
+
33
+ ```sh
34
+ bundle install
35
+ ```
36
+
37
+ Requires Ruby >= 2.6. No runtime dependencies (stdlib only).
38
+
39
+ ## Configuration
40
+
41
+ ```ruby
42
+ EventTrigger.configure do |config|
43
+ config.enabled = true
44
+ config.logger = Logger.new($stdout) # optional; nil = silent
45
+
46
+ config.slack.enabled = true
47
+ config.slack.webhook_url = ENV["SLACK_WEBHOOK_URL"]
48
+ config.slack.events = ["loan.activated", "loan.overdue", "payment.received"]
49
+
50
+ config.email.enabled = false
51
+ config.email.from = ENV["EVENT_TRIGGER_EMAIL_FROM"]
52
+ config.email.to = "ops@example.com"
53
+ config.email.events = ["loan.activated", "loan.foreclosed"]
54
+
55
+ config.webhook.enabled = true
56
+ config.webhook.url = ENV["EVENT_TRIGGER_WEBHOOK_URL"]
57
+ config.webhook.events = ["loan.activated"]
58
+ end
59
+ ```
60
+
61
+ Shortcuts `config.slack_webhook_url=` and `config.email_from=` delegate
62
+ to the per-provider configs.
63
+
64
+ `events` is an allow-list per provider. `nil` (default) or `[]` means
65
+ **ALL events** — the provider runs for every trigger (as long as it is
66
+ enabled):
67
+
68
+ ```ruby
69
+ config.slack.events = nil # or [] → all events
70
+ ```
71
+
72
+ ### Rails initializer
73
+
74
+ Copy `examples/initializer.rb` to `config/initializers/event_trigger.rb`.
75
+ A Railtie is shipped so the gem loads cleanly inside Rails.
76
+
77
+ ## Triggering events
78
+
79
+ ```ruby
80
+ EventTrigger.trigger("loan.activated", data: { loan_id: 123, customer_name: "Kamlesh" })
81
+ ```
82
+
83
+ Rules: `loan.activated` with Slack+Email enabled and subscribed runs both;
84
+ `loan.overdue` with only Slack subscribed runs Slack only; with
85
+ `config.enabled = false` nothing runs.
86
+
87
+ ## Handlers
88
+
89
+ ```ruby
90
+ EventTrigger.on("loan.activated") do |event|
91
+ puts "Loan #{event.payload[:loan_id]} activated"
92
+ end
93
+ ```
94
+
95
+ `event` exposes `#name`, `#payload`, `#timestamp`. Use `off(name)` to
96
+ remove handlers. Handler errors are logged, never raised.
97
+
98
+ ## Providers
99
+
100
+ Slack posts `{ text: }` to `hooks.slack.com` URLs (error payloads render as
101
+ error plus backtrace lines) and a rich embed (`username: RailsErrorNotifier`,
102
+ Backtrace/Context fields) to other webhook URLs. Email sends via Net::SMTP
103
+ in plain Ruby (or ActionMailer when configured with `mailer`). Webhook POSTs
104
+ `{ event:, payload:, timestamp: }` as JSON.
105
+
106
+ Custom provider:
107
+
108
+ ```ruby
109
+ class SmsProvider < EventTrigger::Provider
110
+ def deliver(event)
111
+ # use config + global_config
112
+ end
113
+ end
114
+ EventTrigger.register_provider(:sms, SmsProvider)
115
+ EventTrigger.configure { |c| c.for(:sms).enabled = true }
116
+ ```
117
+
118
+ Architecture: EventTrigger -> Event -> Enabled Providers (Slack/Email/Webhook).
119
+
120
+ ## Logging
121
+
122
+ Set `config.logger`. Logs: event triggered, provider executed, provider
123
+ skipped (disabled / not subscribed), provider failed, handler failed.
124
+
125
+ ## Public API
126
+
127
+ `configure`, `configuration`, `reset!`, `trigger(name, data:)`, `on`, `off`,
128
+ `register_provider`, `Provider#deliver`, `Registry`, `Dispatcher`.
129
+
130
+ ## Development
131
+
132
+ ```sh
133
+ bundle install
134
+ bundle exec rspec
135
+ ```
136
+
137
+ See `examples/plain_ruby.rb` for a runnable demo.
138
+
139
+ ## License
140
+
141
+ MIT -- see LICENSE.txt.
142
+
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
9
+
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module EventTrigger
6
+ # Per-provider configuration. Every provider (built-in or custom) gets
7
+ # an instance of this class with its own +enabled+, +events+ and
8
+ # provider-specific settings stored in +options+.
9
+ class ProviderConfig
10
+ attr_accessor :enabled
11
+ attr_writer :events
12
+
13
+ def initialize(enabled: false, events: nil)
14
+ @enabled = enabled
15
+ @events = events # nil => subscribed to ALL events
16
+ @options = {}
17
+ end
18
+
19
+ # List of event names this provider cares about, or nil for "all events".
20
+ # Always returns strings (or nil).
21
+ def events
22
+ return nil if @events.nil?
23
+
24
+ Array(@events).map(&:to_s)
25
+ end
26
+
27
+ # True when this provider should run for +event_name+.
28
+ # nil/empty events list means "all events".
29
+ def subscribed_to?(event_name)
30
+ list = events
31
+ return true if list.nil? || list.empty?
32
+
33
+ list.include?(event_name.to_s)
34
+ end
35
+
36
+ # Generic option bag for provider-specific settings
37
+ # (webhook_url, from, url, ...). Allows:
38
+ # config.slack.webhook_url = "..."
39
+ def method_missing(name, *args)
40
+ name_str = name.to_s
41
+ if name_str.end_with?("=")
42
+ @options[name_str.chomp("=").to_sym] = args.first
43
+ elsif @options.key?(name)
44
+ @options[name]
45
+ elsif args.empty?
46
+ nil
47
+ else
48
+ super
49
+ end
50
+ end
51
+
52
+ def respond_to_missing?(name, include_private = false)
53
+ true
54
+ end
55
+
56
+ def options
57
+ @options.dup
58
+ end
59
+
60
+ def to_h
61
+ { enabled: enabled, events: events }.merge(@options)
62
+ end
63
+ end
64
+
65
+ # Global configuration object yielded by EventTrigger.configure.
66
+ class Configuration
67
+ attr_accessor :enabled, :logger
68
+ attr_reader :slack, :email, :webhook
69
+
70
+ # Backwards-compatible top-level shortcuts required by the spec:
71
+ # config.slack_webhook_url = ENV["SLACK_WEBHOOK_URL"]
72
+ # config.email_from = ENV["EVENT_TRIGGER_EMAIL_FROM"]
73
+ # They delegate to the per-provider configs.
74
+ def initialize
75
+ @enabled = true
76
+ @logger = nil
77
+ @slack = ProviderConfig.new
78
+ @email = ProviderConfig.new
79
+ @webhook = ProviderConfig.new
80
+ @custom = {}
81
+ end
82
+
83
+ def slack_webhook_url
84
+ slack.webhook_url
85
+ end
86
+
87
+ def slack_webhook_url=(value)
88
+ slack.webhook_url = value
89
+ end
90
+
91
+ def email_from
92
+ email.from
93
+ end
94
+
95
+ def email_from=(value)
96
+ email.from = value
97
+ end
98
+
99
+ # Access (or lazily create) the config for any provider name,
100
+ # including future/custom providers:
101
+ # config.for(:sms).enabled = true
102
+ def for(name)
103
+ key = name.to_sym
104
+ case key
105
+ when :slack then slack
106
+ when :email then email
107
+ when :webhook then webhook
108
+ else
109
+ @custom[key] ||= ProviderConfig.new
110
+ end
111
+ end
112
+ alias provider for
113
+
114
+ # All provider configs keyed by name, including custom ones.
115
+ def providers
116
+ { slack: slack, email: email, webhook: webhook }.merge(@custom)
117
+ end
118
+
119
+ def enabled?
120
+ !!@enabled
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTrigger
4
+ # Default configuration applied when the gem is bundled into an app.
5
+ #
6
+ # Runs via the Railtie BEFORE the host app's initializers, so a
7
+ # user-provided config/initializers/event_trigger.rb can override
8
+ # anything set here. It also means the gem works out-of-the-box with
9
+ # zero configuration: this exact block is the default.
10
+ #
11
+ # EventTrigger.configure do |config|
12
+ # config.enabled = true
13
+ # config.slack.enabled = true
14
+ # config.slack.webhook_url = ENV["SLACK_WEBHOOK_URL"]
15
+ # config.slack.events = ["loan.activated", "loan.overdue", "payment.received"]
16
+ # config.email.enabled = false
17
+ # config.email.from = ENV["EVENT_TRIGGER_EMAIL_FROM"]
18
+ # ...
19
+ # end
20
+ def self.apply_defaults!(logger: nil)
21
+ configure do |config|
22
+ config.enabled = true
23
+ config.logger ||= logger
24
+
25
+ config.slack.enabled = true
26
+ config.slack.webhook_url ||= ENV["SLACK_WEBHOOK_URL"]
27
+ config.slack.events = ["loan.activated", "loan.overdue", "payment.received"]
28
+
29
+ config.email.enabled = false
30
+ config.email.from ||= ENV["EVENT_TRIGGER_EMAIL_FROM"]
31
+ config.email.to ||= ENV["EVENT_TRIGGER_EMAIL_TO"] || "ops@example.com"
32
+ config.email.events = ["loan.activated", "loan.foreclosed"]
33
+
34
+ config.webhook.enabled = false
35
+ config.webhook.url ||= ENV["EVENT_TRIGGER_WEBHOOK_URL"]
36
+ # Leave webhook subscribed to nothing until the app opts in.
37
+ config.webhook.events ||= []
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTrigger
4
+ # Fans an Event out to every enabled provider subscribed to that event,
5
+ # then runs any user-registered handlers (EventTrigger.on).
6
+ #
7
+ # Guarantees:
8
+ # - Global disable (config.enabled == false) => nothing runs.
9
+ # - Only enabled providers subscribed to the event run.
10
+ # - Unknown provider names are skipped (with a log line) instead of crashing.
11
+ # - One provider raising never prevents the others from running;
12
+ # 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
+ class Dispatcher
16
+ def initialize(configuration)
17
+ @configuration = configuration
18
+ end
19
+
20
+ # Returns the list of provider instances that were executed.
21
+ 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
47
+ end
48
+
49
+ Array(handlers).each do |handler|
50
+ begin
51
+ handler.call(event)
52
+ rescue StandardError => e
53
+ log("handler for '#{event.name}' failed: #{e.class}: #{e.message}")
54
+ end
55
+ end
56
+
57
+ executed
58
+ end
59
+
60
+ private
61
+
62
+ def providers_for(_event_name)
63
+ @configuration.providers
64
+ end
65
+
66
+ def log(message)
67
+ @configuration.logger&.info("[EventTrigger] #{message}")
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTrigger
4
+ # Value object representing a single triggered event.
5
+ # Carries the event +name+ ("loan.activated"), an arbitrary +payload+ hash
6
+ # (anything the caller passes via data:), and a timestamp.
7
+ class Event
8
+ attr_reader :name, :payload, :timestamp
9
+
10
+ def initialize(name:, payload: {})
11
+ @name = name.to_s
12
+ @payload = symbolize(payload || {})
13
+ @timestamp = Time.now
14
+ end
15
+
16
+ def [](key)
17
+ payload[key.to_sym] || payload[key.to_s]
18
+ end
19
+
20
+ def to_h
21
+ { name: name, payload: payload, timestamp: timestamp }
22
+ end
23
+
24
+ private
25
+
26
+ def symbolize(hash)
27
+ return {} unless hash.is_a?(Hash)
28
+
29
+ hash.each_with_object({}) do |(k, v), memo|
30
+ memo[k.to_sym] = v
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTrigger
4
+ # Common interface every notification provider must implement.
5
+ #
6
+ # To add a new provider, subclass this and implement #deliver(event):
7
+ #
8
+ # class SmsProvider < EventTrigger::Provider
9
+ # def deliver(event)
10
+ # # send SMS using config ...
11
+ # end
12
+ # end
13
+ # EventTrigger.register_provider(:sms, SmsProvider)
14
+ #
15
+ # Helpers available to subclasses:
16
+ # - #config -> ProviderConfig for this provider
17
+ # - #global_config -> full EventTrigger::Configuration
18
+ # - #log(message) -> write to configured logger, if any
19
+ class Provider
20
+ attr_reader :config, :global_config
21
+
22
+ def initialize(config:, global_config:)
23
+ @config = config
24
+ @global_config = global_config
25
+ end
26
+
27
+ # Deliver the notification for +event+. Must be implemented by subclasses.
28
+ def deliver(_event)
29
+ raise NotImplementedError, "#{self.class} must implement #deliver(event)"
30
+ end
31
+
32
+ # Symbolic name derived from class name, e.g. SlackProvider -> :slack.
33
+ # Custom providers can override .provider_name.
34
+ def self.provider_name
35
+ name.to_s.split("::").last.to_s.sub(/Provider\z/, "").downcase.to_sym
36
+ end
37
+
38
+ def provider_name
39
+ self.class.provider_name
40
+ end
41
+
42
+ protected
43
+
44
+ def log(message)
45
+ logger = global_config.logger
46
+ logger&.info("[EventTrigger][#{provider_name}] #{message}")
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../provider"
4
+
5
+ module EventTrigger
6
+ module Providers
7
+ # Sends event notifications via email.
8
+ #
9
+ # Works in two modes:
10
+ # 1. Rails mode -- if ActionMailer is loaded and
11
+ # config.email.mailer is set (a mailer class) or
12
+ # config.email.delivery_method == :action_mailer, the event is
13
+ # delivered through ActionMailer.
14
+ # 2. Plain-Ruby mode -- otherwise delivered via stdlib Net::SMTP using
15
+ # config.email.smtp_settings (+ from/to addresses).
16
+ #
17
+ # Config:
18
+ # config.email.enabled = true
19
+ # config.email.from = "no-reply@example.com"
20
+ # config.email.to = "ops@example.com" # default recipient
21
+ # config.email.subject_prefix = "[EventTrigger]" # optional
22
+ # config.email.events = ["loan.activated"]
23
+ #
24
+ # Per-trigger overrides: EventTrigger.trigger("x", data: { email_to:, email_subject:, ... })
25
+ class EmailProvider < Provider
26
+ def self.provider_name
27
+ :email
28
+ end
29
+
30
+ def deliver(event)
31
+ from = config.from || global_config.email_from
32
+ to = event.payload[:email_to] || event.payload["email_to"] || config.to
33
+ raise Error, "Email `from` is not configured" if from.nil? || from.to_s.strip.empty?
34
+ raise Error, "Email `to` is not configured" if to.nil? || to.to_s.strip.empty?
35
+
36
+ subject = event.payload[:email_subject] || event.payload["email_subject"] ||
37
+ build_subject(event)
38
+ body = build_body(event)
39
+
40
+ if action_mailer_delivery?
41
+ deliver_via_action_mailer(from, to, subject, body, event)
42
+ else
43
+ deliver_via_smtp(from, to, subject, body)
44
+ end
45
+ log("delivered event '#{event.name}' to #{Array(to).join(', ')}")
46
+ true
47
+ end
48
+
49
+ private
50
+
51
+ def action_mailer_delivery?
52
+ defined?(ActionMailer::Base) && (config.mailer || config.delivery_method == :action_mailer)
53
+ end
54
+
55
+ def deliver_via_action_mailer(from, to, subject, body, _event)
56
+ mailer = config.mailer
57
+ if mailer
58
+ mailer.event_notification(from, to, subject, body).deliver_now
59
+ else
60
+ Class.new(ActionMailer::Base) do
61
+ def event_notification(from, to, subject, body)
62
+ mail(from: from, to: to, subject: subject, body: body)
63
+ end
64
+ end.event_notification(from, to, subject, body).deliver_now
65
+ end
66
+ end
67
+
68
+ def deliver_via_smtp(from, to, subject, body)
69
+ require "net/smtp"
70
+ settings = smtp_settings
71
+ message = <<~MSG
72
+ From: #{from}
73
+ To: #{Array(to).join(", ")}
74
+ Subject: #{subject}
75
+ MIME-Version: 1.0
76
+ Content-Type: text/plain; charset=UTF-8
77
+
78
+ #{body}
79
+ MSG
80
+ Net::SMTP.start(
81
+ settings[:address] || "localhost",
82
+ settings[:port] || 25,
83
+ settings[:domain] || "localhost",
84
+ settings[:user_name],
85
+ settings[:password],
86
+ settings[:authentication] || :plain
87
+ ) do |smtp|
88
+ smtp.send_message(message, from, Array(to))
89
+ end
90
+ end
91
+
92
+ def smtp_settings
93
+ config.smtp_settings || {}
94
+ end
95
+
96
+ def build_subject(event)
97
+ prefix = config.subject_prefix || "[EventTrigger]"
98
+ custom = event.payload[:subject] || event.payload["subject"]
99
+ return custom.to_s if custom
100
+
101
+ "#{prefix} #{event.name}"
102
+ end
103
+
104
+ def build_body(event)
105
+ lines = ["Event: #{event.name}", "Time: #{event.timestamp}", ""]
106
+ event.payload.each do |k, v|
107
+ next if [:email_to, :email_subject, "email_to", "email_subject"].include?(k)
108
+
109
+ lines << "#{k}: #{v}"
110
+ end
111
+ lines.join("\n")
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "time"
7
+ require_relative "../provider"
8
+
9
+ module EventTrigger
10
+ module Providers
11
+ # Sends event notifications to Slack (or any Slack-compatible /
12
+ # Discord-compatible incoming-webhook URL).
13
+ #
14
+ # Config:
15
+ # config.slack.enabled = true
16
+ # config.slack.webhook_url = ENV["SLACK_WEBHOOK_URL"]
17
+ # config.slack.events = ["loan.activated"]
18
+ #
19
+ # Payload behaviour:
20
+ # - Slack incoming webhooks (hooks.slack.com) receive { text: ... }.
21
+ # - All other URLs (e.g. Discord-style webhooks) receive a rich embed
22
+ # payload with error/backtrace/context fields, truncated to stay
23
+ # within message limits.
24
+ class SlackProvider < Provider
25
+ MAX_TEXT = 1500
26
+
27
+ def self.provider_name
28
+ :slack
29
+ end
30
+
31
+ def deliver(event)
32
+ url = config.webhook_url || config.url
33
+ raise Error, "Slack webhook_url is not configured" if url.nil? || url.to_s.strip.empty?
34
+
35
+ body = build_payload(url.to_s, event)
36
+ post_json(url.to_s, body)
37
+ log("delivered event '#{event.name}'")
38
+ true
39
+ end
40
+
41
+ private
42
+
43
+ def build_payload(url, event)
44
+ payload = event.payload
45
+ if url.include?("hooks.slack.com")
46
+ { text: slack_text(event, payload) }
47
+ else
48
+ {
49
+ username: "RailsErrorNotifier",
50
+ embeds: [
51
+ {
52
+ title: "🚨 Error Occurred",
53
+ description: embed_description(event, payload),
54
+ color: 0xFF0000,
55
+ fields: [
56
+ { name: "Event", value: "```\n#{truncate(event.name)}\n```", inline: false },
57
+ { name: "Backtrace", value: "```\n#{backtrace_text(payload)}\n```", inline: false },
58
+ { name: "Context", value: "```\n#{context_text(payload)}\n```", inline: false }
59
+ ],
60
+ timestamp: Time.now.utc.iso8601
61
+ }
62
+ ]
63
+ }
64
+ end
65
+ end
66
+
67
+ # Slack text format: human summary; falls back to error/backtrace keys
68
+ # when present, otherwise dumps the generic payload.
69
+ def slack_text(event, payload)
70
+ if payload[:error] || payload["error"]
71
+ err = payload[:error] || payload["error"]
72
+ bt = payload[:backtrace] || payload["backtrace"]
73
+ lines = [err.to_s]
74
+ lines += Array(bt) if bt
75
+ lines.join("\n")
76
+ else
77
+ summary = payload.map { |k, v| "#{k}: #{v}" }.join(", ")
78
+ summary = event.payload.inspect if summary.empty?
79
+ "[#{event.name}] #{summary}"
80
+ end
81
+ end
82
+
83
+ def embed_description(event, payload)
84
+ err = payload[:error] || payload["error"] || event.name
85
+ truncate(err.to_s)
86
+ end
87
+
88
+ def backtrace_text(payload)
89
+ bt = payload[:backtrace] || payload["backtrace"] || ["No backtrace"]
90
+ truncate_for_discord(Array(bt).first(10).join("\n"))
91
+ end
92
+
93
+ def context_text(payload)
94
+ ctx = payload[:context] || payload["context"] || payload
95
+ truncate_for_discord(ctx.inspect)
96
+ end
97
+
98
+ def truncate(str, limit = MAX_TEXT)
99
+ s = str.to_s
100
+ s.length > limit ? "#{s[0, limit]}…" : s
101
+ end
102
+
103
+ def truncate_for_discord(str, limit = 1000)
104
+ truncate(str, limit)
105
+ end
106
+
107
+ def post_json(url, body)
108
+ uri = URI.parse(url)
109
+ http = Net::HTTP.new(uri.host, uri.port)
110
+ http.use_ssl = uri.scheme == "https"
111
+ http.open_timeout = 5
112
+ http.read_timeout = 10
113
+ request = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
114
+ request.body = JSON.generate(body)
115
+ response = http.request(request)
116
+ unless response.is_a?(Net::HTTPSuccess)
117
+ raise Error, "Slack delivery failed (HTTP #{response.code}): #{response.body}"
118
+ end
119
+ response
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require_relative "../provider"
7
+
8
+ module EventTrigger
9
+ module Providers
10
+ # POSTs the event as JSON to a configured HTTP endpoint.
11
+ #
12
+ # Config:
13
+ # config.webhook.enabled = true
14
+ # config.webhook.url = "https://example.com/hooks/events"
15
+ # config.webhook.headers = { "Authorization" => "Bearer ..." }
16
+ # config.webhook.events = ["loan.activated"]
17
+ class WebhookProvider < Provider
18
+ def self.provider_name
19
+ :webhook
20
+ end
21
+
22
+ def deliver(event)
23
+ url = config.url
24
+ raise Error, "Webhook url is not configured" if url.nil? || url.to_s.strip.empty?
25
+
26
+ post_json(url.to_s, event_to_json(event), headers)
27
+ log("delivered event '#{event.name}' to #{url}")
28
+ true
29
+ end
30
+
31
+ private
32
+
33
+ def headers
34
+ { "Content-Type" => "application/json" }.merge(config.headers || {})
35
+ end
36
+
37
+ def event_to_json(event)
38
+ {
39
+ event: event.name,
40
+ payload: stringify(event.payload),
41
+ timestamp: event.timestamp.to_s
42
+ }
43
+ end
44
+
45
+ def stringify(hash)
46
+ hash.each_with_object({}) { |(k, v), m| m[k.to_s] = v }
47
+ end
48
+
49
+ def post_json(url, body, headers)
50
+ uri = URI.parse(url)
51
+ http = Net::HTTP.new(uri.host, uri.port)
52
+ http.use_ssl = uri.scheme == "https"
53
+ http.open_timeout = 5
54
+ http.read_timeout = 10
55
+ request = Net::HTTP::Post.new(uri.request_uri, headers)
56
+ request.body = JSON.generate(body)
57
+ response = http.request(request)
58
+ unless response.is_a?(Net::HTTPSuccess)
59
+ raise Error, "Webhook delivery failed (HTTP #{response.code}): #{response.body}"
60
+ end
61
+ response
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTrigger
4
+ # Rails integration:
5
+ # 1. Applies safe defaults as soon as the gem is bundled (so the app
6
+ # works with zero configuration).
7
+ # 2. Auto-creates config/initializers/event_trigger.rb in the host app
8
+ # when missing, so the user always has an editable copy of the
9
+ # default block (no manual `rails generate` step needed). Never
10
+ # overwrites an existing file.
11
+ # 3. Hooks Rails.logger in automatically (unless the app already set one).
12
+ #
13
+ # Order: Railtie initializer runs BEFORE the host app's initializers, so
14
+ # anything in config/initializers/event_trigger.rb wins over these defaults.
15
+ if defined?(Rails::Railtie)
16
+ class Railtie < Rails::Railtie
17
+ generators do
18
+ require "generators/event_trigger/install_generator" if defined?(Rails::Generators)
19
+ rescue LoadError
20
+ nil
21
+ end
22
+
23
+ initializer "event_trigger.configure_rails_defaults", before: :load_environment_config do
24
+ require "event_trigger" unless defined?(EventTrigger::Dispatcher)
25
+ EventTrigger.apply_defaults!
26
+ if defined?(Rails.logger) && Rails.logger && EventTrigger.configuration.logger.nil?
27
+ EventTrigger.configuration.logger = Rails.logger
28
+ end
29
+ end
30
+
31
+ # Runs after app initializers are loaded: if the app has no
32
+ # initializer of its own, write the default one so it exists on
33
+ # disk for the user to edit. Runs in every env except test (to
34
+ # keep the test suite hermetic).
35
+ initializer "event_trigger.ensure_initializer_present", after: :load_environment do
36
+ next unless defined?(Rails.root) && Rails.root
37
+ next if Rails.env.test? rescue nil
38
+
39
+ begin
40
+ dest = Rails.root.join("config/initializers/event_trigger.rb")
41
+ next if File.exist?(dest)
42
+
43
+ src_candidates = [
44
+ File.expand_path("../../generators/event_trigger/templates/event_trigger.rb", __dir__),
45
+ File.expand_path("../../../generators/event_trigger/templates/event_trigger.rb", __FILE__)
46
+ ]
47
+ src = src_candidates.find { |p| File.exist?(p) }
48
+ next unless src
49
+
50
+ FileUtils.mkdir_p(File.dirname(dest))
51
+ FileUtils.cp(src, dest)
52
+ Rails.logger&.info("[EventTrigger] created #{dest} with defaults (edit to customize)")
53
+ rescue StandardError
54
+ nil # never break boot because of the initializer copy
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTrigger
4
+ # Maps symbolic provider names (:slack, :email, :webhook, custom...)
5
+ # to provider classes. Custom providers register via
6
+ # EventTrigger.register_provider(:name, Klass).
7
+ module Registry
8
+ @providers = {}
9
+
10
+ class << self
11
+ def register(name, klass)
12
+ @providers[name.to_sym] = klass
13
+ end
14
+
15
+ def fetch(name)
16
+ @providers[name.to_sym]
17
+ end
18
+
19
+ def all
20
+ @providers.dup
21
+ end
22
+
23
+ def registered?(name)
24
+ @providers.key?(name.to_sym)
25
+ end
26
+
27
+ # Restore the three built-in providers (used by reset!).
28
+ def reset!
29
+ @providers = {}
30
+ require_relative "providers/slack_provider"
31
+ require_relative "providers/email_provider"
32
+ require_relative "providers/webhook_provider"
33
+ register(:slack, Providers::SlackProvider)
34
+ register(:email, Providers::EmailProvider)
35
+ register(:webhook, Providers::WebhookProvider)
36
+ end
37
+ end
38
+ end
39
+
40
+ Registry.reset!
41
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTrigger
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "event_trigger/version"
4
+ require_relative "event_trigger/configuration"
5
+ require_relative "event_trigger/event"
6
+ require_relative "event_trigger/registry"
7
+ require_relative "event_trigger/provider"
8
+ require_relative "event_trigger/providers/slack_provider"
9
+ require_relative "event_trigger/providers/email_provider"
10
+ require_relative "event_trigger/providers/webhook_provider"
11
+ require_relative "event_trigger/dispatcher"
12
+ require_relative "event_trigger/defaults"
13
+ require_relative "event_trigger/railtie" if defined?(Rails::Railtie)
14
+
15
+ module EventTrigger
16
+ class Error < StandardError; end
17
+
18
+ class << self
19
+ # Central configuration block:
20
+ #
21
+ # EventTrigger.configure do |config|
22
+ # config.enabled = true
23
+ # config.slack.enabled = true
24
+ # config.slack.webhook_url = ENV["SLACK_WEBHOOK_URL"]
25
+ # end
26
+ def configure
27
+ yield configuration
28
+ end
29
+
30
+ # Access (and memoize) the global configuration object.
31
+ def configuration
32
+ @configuration ||= Configuration.new
33
+ end
34
+
35
+ # Reset configuration + handlers + provider registry to defaults.
36
+ # Primarily useful for tests.
37
+ def reset!
38
+ @configuration = Configuration.new
39
+ @handlers = Hash.new { |h, k| h[k] = [] }
40
+ Registry.reset!
41
+ end
42
+
43
+ # Register a handler block for a specific event name:
44
+ #
45
+ # EventTrigger.on("loan.activated") do |event|
46
+ # # custom notification logic
47
+ # end
48
+ #
49
+ # Returns the registered block.
50
+ def on(event_name, &block)
51
+ raise ArgumentError, "EventTrigger.on requires a block" unless block_given?
52
+
53
+ key = normalize_event_name(event_name)
54
+ handlers[key] << block
55
+ block
56
+ end
57
+
58
+ # Remove all handlers for an event (or all events when no name given).
59
+ def off(event_name = nil)
60
+ if event_name.nil?
61
+ @handlers = Hash.new { |h, k| h[k] = [] }
62
+ else
63
+ handlers.delete(normalize_event_name(event_name))
64
+ end
65
+ end
66
+
67
+ # Registered handlers hash: { "event.name" => [proc, ...] }
68
+ def handlers
69
+ @handlers ||= Hash.new { |h, k| h[k] = [] }
70
+ end
71
+
72
+ # Register a custom provider class under a symbolic name:
73
+ #
74
+ # class SmsProvider < EventTrigger::Provider; end
75
+ # EventTrigger.register_provider(:sms, SmsProvider)
76
+ def register_provider(name, klass)
77
+ Registry.register(name, klass)
78
+ end
79
+
80
+ # The main public API:
81
+ #
82
+ # EventTrigger.trigger("loan.activated", data: { loan_id: 123 })
83
+ #
84
+ # Builds an EventTrigger::Event and hands it to the Dispatcher,
85
+ # which fans out to every enabled provider configured for that event.
86
+ # Returns the Event object.
87
+ def trigger(event_name, data: {})
88
+ event = Event.new(name: normalize_event_name(event_name), payload: data || {})
89
+ Dispatcher.new(configuration).dispatch(event, handlers_for(event.name))
90
+ event
91
+ end
92
+
93
+ # Handlers registered for a given event name (string).
94
+ def handlers_for(event_name)
95
+ handlers[normalize_event_name(event_name)].dup
96
+ end
97
+
98
+ # Normalize event names to strings ("loan.activated").
99
+ def normalize_event_name(name)
100
+ name.to_s
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module EventTrigger
6
+ module Generators
7
+ # Copies the default initializer into the host Rails app:
8
+ #
9
+ # rails generate event_trigger:install
10
+ #
11
+ # Pass --force to overwrite an existing initializer. The Railtie also
12
+ # auto-creates this file when missing, so in practice you rarely need
13
+ # to run the generator by hand.
14
+ class InstallGenerator < Rails::Generators::Base
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ desc "Copy EventTrigger initializer to config/initializers/event_trigger.rb"
18
+
19
+ def copy_initializer
20
+ copy_file "event_trigger.rb",
21
+ "config/initializers/event_trigger.rb"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Default EventTrigger configuration for your Rails app.
4
+ # Auto-created by the gem on first boot (and by `rails generate event_trigger:install`).
5
+ # Adjust values for your environment.
6
+
7
+ EventTrigger.configure do |config|
8
+ config.enabled = true
9
+ # Optional logging (defaults to Rails.logger via the gem Railtie).
10
+ # Set to nil to silence, or Logger.new($stdout) for plain Ruby.
11
+ # config.logger = Rails.logger
12
+
13
+ config.slack.enabled = true
14
+ config.slack.webhook_url = ENV["SLACK_WEBHOOK_URL"]
15
+ # Allow-list of events for this provider. nil (or []) = ALL events.
16
+ # Replace the examples below with your own event names.
17
+ config.slack.events = ["loan.activated", "loan.overdue", "payment.received"]
18
+
19
+ config.email.enabled = false
20
+ config.email.from = ENV["EVENT_TRIGGER_EMAIL_FROM"]
21
+ config.email.to = ENV["EVENT_TRIGGER_EMAIL_TO"]
22
+ # nil (or []) = ALL events.
23
+ config.email.events = ["loan.activated", "loan.foreclosed"]
24
+
25
+ config.webhook.enabled = false
26
+ config.webhook.url = ENV["EVENT_TRIGGER_WEBHOOK_URL"]
27
+ # nil (or []) = ALL events.
28
+ config.webhook.events = ["loan.activated"]
29
+ end
@@ -0,0 +1,4 @@
1
+ module EventTrigger
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: event_trigger
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kamlesh Parmar
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-09-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '13.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '13.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: net-smtp
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0.3'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0.3'
55
+ description: 'EventTrigger provides a simple event system with pluggable notification
56
+ providers (Slack, Email, Webhook, custom). Configure providers once, then call EventTrigger.trigger("event.name",
57
+ data: {...}) and the gem dispatches to the enabled providers configured for that
58
+ event. Works in plain Ruby and Ruby on Rails.'
59
+ email:
60
+ - malviyak00@gmail.com
61
+ executables: []
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - LICENSE.txt
66
+ - README.md
67
+ - Rakefile
68
+ - lib/event_trigger.rb
69
+ - lib/event_trigger/configuration.rb
70
+ - lib/event_trigger/defaults.rb
71
+ - lib/event_trigger/dispatcher.rb
72
+ - lib/event_trigger/event.rb
73
+ - lib/event_trigger/provider.rb
74
+ - lib/event_trigger/providers/email_provider.rb
75
+ - lib/event_trigger/providers/slack_provider.rb
76
+ - lib/event_trigger/providers/webhook_provider.rb
77
+ - lib/event_trigger/railtie.rb
78
+ - lib/event_trigger/registry.rb
79
+ - lib/event_trigger/version.rb
80
+ - lib/generators/event_trigger/install_generator.rb
81
+ - lib/generators/event_trigger/templates/event_trigger.rb
82
+ - sig/event_trigger.rbs
83
+ homepage: https://github.com/mrmalvi/event_trigger
84
+ licenses:
85
+ - MIT
86
+ metadata:
87
+ homepage_uri: https://github.com/mrmalvi/event_trigger
88
+ source_code_uri: https://github.com/mrmalvi/event_trigger
89
+ changelog_uri: https://github.com/mrmalvi/event_trigger/blob/main/CHANGELOG.md
90
+ post_install_message:
91
+ rdoc_options: []
92
+ require_paths:
93
+ - lib
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: 2.6.0
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ requirements: []
105
+ rubygems_version: 3.4.19
106
+ signing_key:
107
+ specification_version: 4
108
+ summary: Configurable event-based notification system for Ruby and Rails.
109
+ test_files: []