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
|
@@ -46,6 +46,7 @@ module EventTrigger
|
|
|
46
46
|
true
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
|
|
49
50
|
private
|
|
50
51
|
|
|
51
52
|
def action_mailer_delivery?
|
|
@@ -68,7 +69,24 @@ module EventTrigger
|
|
|
68
69
|
def deliver_via_smtp(from, to, subject, body)
|
|
69
70
|
require "net/smtp"
|
|
70
71
|
settings = smtp_settings
|
|
71
|
-
message =
|
|
72
|
+
message = build_message(from, to, subject, body)
|
|
73
|
+
smtp = Net::SMTP.new(settings[:address] || "localhost", settings[:port] || 25)
|
|
74
|
+
apply_smtp_timeouts(smtp, settings)
|
|
75
|
+
if settings[:enable_starttls_auto] && smtp.respond_to?(:enable_starttls_auto)
|
|
76
|
+
smtp.enable_starttls_auto
|
|
77
|
+
end
|
|
78
|
+
smtp.start(
|
|
79
|
+
settings[:domain] || "localhost",
|
|
80
|
+
settings[:user_name] || settings[:username],
|
|
81
|
+
settings[:password],
|
|
82
|
+
settings[:authentication] || :plain
|
|
83
|
+
) do
|
|
84
|
+
smtp.send_message(message, from, Array(to))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_message(from, to, subject, body)
|
|
89
|
+
<<~MSG
|
|
72
90
|
From: #{from}
|
|
73
91
|
To: #{Array(to).join(", ")}
|
|
74
92
|
Subject: #{subject}
|
|
@@ -77,20 +95,29 @@ module EventTrigger
|
|
|
77
95
|
|
|
78
96
|
#{body}
|
|
79
97
|
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
98
|
end
|
|
91
99
|
|
|
100
|
+
# Guard against a stalled/unreachable SMTP server blocking the app
|
|
101
|
+
# forever (configurable via config.email.smtp timeouts).
|
|
102
|
+
def apply_smtp_timeouts(smtp, settings)
|
|
103
|
+
smtp.open_timeout = settings[:open_timeout] || 5 if smtp.respond_to?(:open_timeout=)
|
|
104
|
+
smtp.read_timeout = settings[:read_timeout] || 10 if smtp.respond_to?(:read_timeout=)
|
|
105
|
+
smtp.write_timeout = settings[:write_timeout] || 10 if smtp.respond_to?(:write_timeout=)
|
|
106
|
+
rescue StandardError
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# NEW additive alias: spec uses config.email.smtp = {...}.
|
|
111
|
+
# Existing config.email.smtp_settings keeps working; smtp wins
|
|
112
|
+
# when both are set. Accepts string or symbol keys.
|
|
92
113
|
def smtp_settings
|
|
93
|
-
config.smtp_settings || {}
|
|
114
|
+
raw = config.smtp || config.smtp_settings || {}
|
|
115
|
+
raw = {} unless raw.is_a?(Hash)
|
|
116
|
+
raw.each_with_object({}) do |(k, v), memo|
|
|
117
|
+
key = k.to_sym
|
|
118
|
+
key = :user_name if key == :username # Net::SMTP expects :user_name
|
|
119
|
+
memo[key] = v
|
|
120
|
+
end
|
|
94
121
|
end
|
|
95
122
|
|
|
96
123
|
def build_subject(event)
|
|
@@ -12,6 +12,9 @@ module EventTrigger
|
|
|
12
12
|
# request itself raises (DNS, timeout, connection error), it falls
|
|
13
13
|
# back to Faraday when available. HTTP error statuses raise
|
|
14
14
|
# EventTrigger::Error directly (no retry — same result expected).
|
|
15
|
+
#
|
|
16
|
+
# NOTE: faraday is loaded lazily (only on fallback) so apps that
|
|
17
|
+
# never hit the network path pay no load cost.
|
|
15
18
|
module HttpPoster
|
|
16
19
|
private
|
|
17
20
|
|
|
@@ -26,31 +29,73 @@ module EventTrigger
|
|
|
26
29
|
end
|
|
27
30
|
end
|
|
28
31
|
|
|
32
|
+
def request_json(method, url, body, headers = { "Content-Type" => "application/json" }, service: "HTTP")
|
|
33
|
+
payload = JSON.generate(body)
|
|
34
|
+
begin
|
|
35
|
+
net_http_request(method, url, payload, headers, service)
|
|
36
|
+
rescue EventTrigger::Error
|
|
37
|
+
raise
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
# Only POST has a Faraday fallback today; other verbs re-raise.
|
|
40
|
+
raise e unless method.to_s.downcase == "post"
|
|
41
|
+
|
|
42
|
+
faraday_post(url, payload, headers, service, e)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
29
46
|
def net_http_post(url, payload, headers, service)
|
|
47
|
+
net_http_request(:post, url, payload, headers, service)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def net_http_request(method, url, payload, headers, service)
|
|
30
51
|
uri = URI.parse(url.to_s)
|
|
31
52
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
32
53
|
http.use_ssl = uri.scheme == "https"
|
|
54
|
+
# Hard timeouts at both ends: connect, read AND write. Without these
|
|
55
|
+
# a stalled peer could block the host app's web worker indefinitely.
|
|
33
56
|
http.open_timeout = 5
|
|
34
57
|
http.read_timeout = 10
|
|
35
|
-
|
|
36
|
-
|
|
58
|
+
http.write_timeout = 10 if http.respond_to?(:write_timeout=)
|
|
59
|
+
http.continue_timeout = 5 if http.respond_to?(:continue_timeout=)
|
|
60
|
+
klass = case method.to_s.downcase
|
|
61
|
+
when "get" then Net::HTTP::Get
|
|
62
|
+
when "put" then Net::HTTP::Put
|
|
63
|
+
when "patch" then Net::HTTP::Patch
|
|
64
|
+
when "delete" then Net::HTTP::Delete
|
|
65
|
+
else Net::HTTP::Post
|
|
66
|
+
end
|
|
67
|
+
request = klass.new(uri.request_uri, headers)
|
|
68
|
+
request.body = payload unless method.to_s.downcase == "get"
|
|
37
69
|
response = http.request(request)
|
|
38
70
|
unless response.is_a?(Net::HTTPSuccess)
|
|
39
|
-
raise Error, "#{service} delivery failed (HTTP #{response.code}): #{response.body}"
|
|
71
|
+
raise Error, "#{service} delivery failed (HTTP #{response.code}): #{truncate(response.body)}"
|
|
40
72
|
end
|
|
41
73
|
response
|
|
42
74
|
end
|
|
43
75
|
|
|
44
76
|
def faraday_post(url, payload, headers, service, original_error)
|
|
45
77
|
require "faraday"
|
|
46
|
-
|
|
78
|
+
conn = Faraday.new do |f|
|
|
79
|
+
f.options.timeout = 10
|
|
80
|
+
f.options.open_timeout = 5
|
|
81
|
+
end
|
|
82
|
+
response = conn.post(url.to_s, payload, headers)
|
|
47
83
|
unless response.success?
|
|
48
|
-
raise Error, "#{service} delivery failed (HTTP #{response.status}): #{response.body}"
|
|
84
|
+
raise Error, "#{service} delivery failed (HTTP #{response.status}): #{truncate(response.body)}"
|
|
49
85
|
end
|
|
50
86
|
response
|
|
51
87
|
rescue LoadError
|
|
52
88
|
raise original_error
|
|
53
89
|
end
|
|
90
|
+
|
|
91
|
+
# Keep error messages bounded — a huge HTML error page must not blow
|
|
92
|
+
# up memory or logs.
|
|
93
|
+
def truncate(str, limit = 500)
|
|
94
|
+
s = str.to_s
|
|
95
|
+
s.length > limit ? "#{s[0, limit]}…" : s
|
|
96
|
+
rescue StandardError
|
|
97
|
+
""
|
|
98
|
+
end
|
|
54
99
|
end
|
|
55
100
|
end
|
|
56
101
|
end
|
|
@@ -5,41 +5,86 @@ require "uri"
|
|
|
5
5
|
require "json"
|
|
6
6
|
require "time"
|
|
7
7
|
require_relative "../provider"
|
|
8
|
+
require_relative "http_poster"
|
|
8
9
|
|
|
9
10
|
module EventTrigger
|
|
10
11
|
module Providers
|
|
11
|
-
# Sends event notifications to Slack
|
|
12
|
-
# Discord-compatible incoming-webhook URL).
|
|
12
|
+
# Sends event notifications to Slack.
|
|
13
13
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
14
|
+
# Two modes (existing webhook mode is untouched):
|
|
15
|
+
#
|
|
16
|
+
# 1. Legacy incoming-webhook mode (unchanged):
|
|
17
|
+
# config.slack.webhook_url = ENV["SLACK_WEBHOOK_URL"]
|
|
18
|
+
# - hooks.slack.com URLs receive { text: ... }.
|
|
19
|
+
# - any other URL receives the Discord-style embed payload.
|
|
20
|
+
#
|
|
21
|
+
# 2. NEW Slack Web API mode (chat.postMessage), used only when BOTH
|
|
22
|
+
# token + channel are set:
|
|
23
|
+
# config.slack.token = ENV["SLACK_BOT_TOKEN"]
|
|
24
|
+
# config.slack.channel = ENV["SLACK_CHANNEL"] # e.g. "#alerts" or "C123"
|
|
25
|
+
# API: POST https://slack.com/api/chat.postMessage
|
|
26
|
+
# Auth: Authorization: Bearer <token>
|
|
18
27
|
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
# payload with error/backtrace/context fields, truncated to stay
|
|
23
|
-
# within message limits.
|
|
28
|
+
# Common:
|
|
29
|
+
# config.slack.enabled = true
|
|
30
|
+
# config.slack.events = ["loan.activated", "payment.received"]
|
|
24
31
|
class SlackProvider < Provider
|
|
25
32
|
MAX_TEXT = 1500
|
|
26
33
|
|
|
34
|
+
WEB_API_URL = "https://slack.com/api/chat.postMessage"
|
|
35
|
+
|
|
27
36
|
def self.provider_name
|
|
28
37
|
:slack
|
|
29
38
|
end
|
|
30
39
|
|
|
40
|
+
include HttpPoster
|
|
41
|
+
|
|
31
42
|
def deliver(event)
|
|
43
|
+
# --- NEW: Web API mode (additive; webhook path below untouched) ---
|
|
44
|
+
token = config.token
|
|
45
|
+
channel = config.channel
|
|
46
|
+
if present?(token) && present?(channel)
|
|
47
|
+
return deliver_via_web_api(event, token.to_s, channel.to_s)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# --- Existing webhook mode (byte-for-byte behaviour preserved) ---
|
|
32
51
|
url = config.webhook_url || config.url
|
|
33
52
|
raise Error, "Slack webhook_url is not configured" if url.nil? || url.to_s.strip.empty?
|
|
34
53
|
|
|
35
54
|
body = build_payload(url.to_s, event)
|
|
36
|
-
post_json(url.to_s, body)
|
|
55
|
+
post_json(url.to_s, body, { "Content-Type" => "application/json" }, service: "Slack")
|
|
37
56
|
log("delivered event '#{event.name}'")
|
|
38
57
|
true
|
|
39
58
|
end
|
|
40
59
|
|
|
60
|
+
|
|
41
61
|
private
|
|
42
62
|
|
|
63
|
+
def deliver_via_web_api(event, token, channel)
|
|
64
|
+
body = { channel: channel, text: slack_text(event, event.payload) }
|
|
65
|
+
response = post_json(
|
|
66
|
+
WEB_API_URL, body,
|
|
67
|
+
{ "Content-Type" => "application/json", "Authorization" => "Bearer #{token}" },
|
|
68
|
+
service: "Slack"
|
|
69
|
+
)
|
|
70
|
+
parsed = parse_json(response.body)
|
|
71
|
+
unless parsed.is_a?(Hash) && parsed["ok"] == true
|
|
72
|
+
raise Error, "Slack Web API error: #{response.body}"
|
|
73
|
+
end
|
|
74
|
+
log("delivered event '#{event.name}' via chat.postMessage")
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parse_json(str)
|
|
79
|
+
JSON.parse(str.to_s)
|
|
80
|
+
rescue StandardError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def present?(value)
|
|
85
|
+
!value.nil? && !value.to_s.strip.empty?
|
|
86
|
+
end
|
|
87
|
+
|
|
43
88
|
def build_payload(url, event)
|
|
44
89
|
payload = event.payload
|
|
45
90
|
if url.include?("hooks.slack.com")
|
|
@@ -103,21 +148,6 @@ module EventTrigger
|
|
|
103
148
|
def truncate_for_discord(str, limit = 1000)
|
|
104
149
|
truncate(str, limit)
|
|
105
150
|
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
151
|
end
|
|
122
152
|
end
|
|
123
153
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../provider"
|
|
4
|
+
|
|
5
|
+
module EventTrigger
|
|
6
|
+
module Providers
|
|
7
|
+
# Sends SMS notifications. Pluggable interface — today backed by
|
|
8
|
+
# Twilio (lazy-loaded, only when this provider is enabled/used).
|
|
9
|
+
#
|
|
10
|
+
# Twilio Messaging API base: https://api.twilio.com/2010-04-01
|
|
11
|
+
#
|
|
12
|
+
# Config:
|
|
13
|
+
# config.sms.enabled = true
|
|
14
|
+
# config.sms.provider = :twilio # only :twilio supported today
|
|
15
|
+
# config.sms.account_sid = ENV["TWILIO_ACCOUNT_SID"]
|
|
16
|
+
# config.sms.auth_token = ENV["TWILIO_AUTH_TOKEN"]
|
|
17
|
+
# config.sms.from = ENV["TWILIO_FROM"] # E.164 number, e.g. +15551234567
|
|
18
|
+
# config.sms.events = ["payment.received"]
|
|
19
|
+
#
|
|
20
|
+
# Per-trigger overrides: data: { sms_to:, sms_body: }
|
|
21
|
+
# Crash-safe: failures raise EventTrigger::Error (Dispatcher logs them).
|
|
22
|
+
class SmsProvider < Provider
|
|
23
|
+
MAX_BODY = 1000
|
|
24
|
+
|
|
25
|
+
def self.provider_name
|
|
26
|
+
:sms
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def deliver(event)
|
|
30
|
+
name = (config.provider || :twilio).to_sym
|
|
31
|
+
raise Error, "SMS provider #{name.inspect} is not supported (only :twilio)" unless name == :twilio
|
|
32
|
+
|
|
33
|
+
client_class = twilio_client_class
|
|
34
|
+
unless client_class
|
|
35
|
+
raise Error, TwilioClient::GEM_MISSING_MESSAGE
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
sid = config.account_sid || config.twilio_sid
|
|
39
|
+
token = config.auth_token || config.twilio_token
|
|
40
|
+
from = config.from || config.twilio_from
|
|
41
|
+
to = event.payload[:sms_to] || event.payload["sms_to"] || config.to
|
|
42
|
+
raise Error, "SMS account_sid is not configured" if blank?(sid)
|
|
43
|
+
raise Error, "SMS auth_token is not configured" if blank?(token)
|
|
44
|
+
raise Error, "SMS sender (from) is not configured" if blank?(from)
|
|
45
|
+
raise Error, "SMS recipient (to) is not configured" if blank?(to)
|
|
46
|
+
|
|
47
|
+
body = event.payload[:sms_body] || event.payload["sms_body"] || build_body(event)
|
|
48
|
+
Twilio::REST::Client.new(sid.to_s, token.to_s).messages.create(
|
|
49
|
+
from: from.to_s, to: to.to_s, body: truncate(body.to_s)
|
|
50
|
+
)
|
|
51
|
+
log("delivered event '#{event.name}' to #{to}")
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Thin wrapper around the lazy resolver — kept as an instance method
|
|
59
|
+
# so tests can stub it (e.g. simulate "gem missing" deterministically).
|
|
60
|
+
def twilio_client_class
|
|
61
|
+
TwilioClient.resolve
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_body(event)
|
|
65
|
+
err = event.payload[:error] || event.payload["error"]
|
|
66
|
+
return err.to_s if err
|
|
67
|
+
|
|
68
|
+
summary = event.payload.map { |k, v| "#{k}: #{v}" }.join(", ")
|
|
69
|
+
summary = event.payload.inspect if summary.empty?
|
|
70
|
+
"[#{event.name}] #{summary}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def blank?(value)
|
|
74
|
+
value.nil? || value.to_s.strip.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def truncate(str, limit = MAX_BODY)
|
|
78
|
+
s = str.to_s
|
|
79
|
+
s.length > limit ? "#{s[0, limit]}…" : s
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../provider"
|
|
4
|
+
require_relative "http_poster"
|
|
5
|
+
|
|
6
|
+
module EventTrigger
|
|
7
|
+
module Providers
|
|
8
|
+
# Sends event notifications to a Microsoft Teams channel via the
|
|
9
|
+
# Microsoft Graph API.
|
|
10
|
+
#
|
|
11
|
+
# API: POST https://graph.microsoft.com/v1.0/teams/{team-id}/channels/{channel-id}/messages
|
|
12
|
+
# Auth: Authorization: Bearer <ACCESS_TOKEN>
|
|
13
|
+
#
|
|
14
|
+
# Config:
|
|
15
|
+
# config.teams.enabled = true
|
|
16
|
+
# config.teams.access_token = ENV["TEAMS_ACCESS_TOKEN"]
|
|
17
|
+
# config.teams.team_id = ENV["TEAMS_TEAM_ID"]
|
|
18
|
+
# config.teams.channel_id = ENV["TEAMS_CHANNEL_ID"]
|
|
19
|
+
# config.teams.events = ["loan.activated"]
|
|
20
|
+
#
|
|
21
|
+
# Crash-safe: failures raise EventTrigger::Error (Dispatcher logs them).
|
|
22
|
+
class TeamsProvider < Provider
|
|
23
|
+
include HttpPoster
|
|
24
|
+
|
|
25
|
+
GRAPH_BASE = "https://graph.microsoft.com/v1.0"
|
|
26
|
+
|
|
27
|
+
def self.provider_name
|
|
28
|
+
:teams
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def deliver(event)
|
|
32
|
+
token = config.access_token
|
|
33
|
+
team_id = config.team_id
|
|
34
|
+
channel_id = config.channel_id
|
|
35
|
+
raise Error, "Teams access_token is not configured" if blank?(token)
|
|
36
|
+
raise Error, "Teams team_id is not configured" if blank?(team_id)
|
|
37
|
+
raise Error, "Teams channel_id is not configured" if blank?(channel_id)
|
|
38
|
+
|
|
39
|
+
url = "#{GRAPH_BASE}/teams/#{team_id}/channels/#{channel_id}/messages"
|
|
40
|
+
post_json(
|
|
41
|
+
url, { body: { contentType: "text", content: message_text(event) } },
|
|
42
|
+
{ "Content-Type" => "application/json", "Authorization" => "Bearer #{token}" },
|
|
43
|
+
service: "Teams"
|
|
44
|
+
)
|
|
45
|
+
log("delivered event '#{event.name}'")
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def message_text(event)
|
|
53
|
+
payload = event.payload
|
|
54
|
+
err = payload[:error] || payload["error"]
|
|
55
|
+
return err.to_s if err
|
|
56
|
+
|
|
57
|
+
summary = payload.map { |k, v| "#{k}: #{v}" }.join(", ")
|
|
58
|
+
summary = payload.inspect if summary.empty?
|
|
59
|
+
"[#{event.name}] #{summary}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def blank?(value)
|
|
63
|
+
value.nil? || value.to_s.strip.empty?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../provider"
|
|
4
|
+
require_relative "http_poster"
|
|
5
|
+
|
|
6
|
+
module EventTrigger
|
|
7
|
+
module Providers
|
|
8
|
+
# Sends event notifications to Telegram via the Bot API.
|
|
9
|
+
#
|
|
10
|
+
# API: POST https://api.telegram.org/bot<BOT_TOKEN>/sendMessage
|
|
11
|
+
#
|
|
12
|
+
# Config:
|
|
13
|
+
# config.telegram.enabled = true
|
|
14
|
+
# config.telegram.bot_token = ENV["TELEGRAM_BOT_TOKEN"]
|
|
15
|
+
# config.telegram.chat_id = ENV["TELEGRAM_CHAT_ID"]
|
|
16
|
+
# config.telegram.events = ["loan.overdue"]
|
|
17
|
+
#
|
|
18
|
+
# Per-trigger override: data: { telegram_chat_id: "..." }
|
|
19
|
+
# Crash-safe: failures raise EventTrigger::Error (Dispatcher logs them).
|
|
20
|
+
class TelegramProvider < Provider
|
|
21
|
+
include HttpPoster
|
|
22
|
+
|
|
23
|
+
MAX_TEXT = 4000
|
|
24
|
+
|
|
25
|
+
def self.provider_name
|
|
26
|
+
:telegram
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def deliver(event)
|
|
30
|
+
token = config.bot_token
|
|
31
|
+
chat_id = event.payload[:telegram_chat_id] || event.payload["telegram_chat_id"] || config.chat_id
|
|
32
|
+
raise Error, "Telegram bot_token is not configured" if blank?(token)
|
|
33
|
+
raise Error, "Telegram chat_id is not configured" if blank?(chat_id)
|
|
34
|
+
|
|
35
|
+
url = "https://api.telegram.org/bot#{token}/sendMessage"
|
|
36
|
+
response = post_json(url, { chat_id: chat_id.to_s, text: message_text(event) }, service: "Telegram")
|
|
37
|
+
parsed = parse_json(response_body(response))
|
|
38
|
+
# Telegram returns { "ok": true, ... } on success.
|
|
39
|
+
if parsed.is_a?(Hash) && parsed.key?("ok") && parsed["ok"] != true
|
|
40
|
+
raise Error, "Telegram delivery failed: #{response_body(response)}"
|
|
41
|
+
end
|
|
42
|
+
log("delivered event '#{event.name}'")
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def message_text(event)
|
|
50
|
+
payload = event.payload
|
|
51
|
+
err = payload[:error] || payload["error"]
|
|
52
|
+
text =
|
|
53
|
+
if err
|
|
54
|
+
bt = payload[:backtrace] || payload["backtrace"]
|
|
55
|
+
lines = ["🚨 #{err}"]
|
|
56
|
+
lines << Array(bt).first.to_s if bt
|
|
57
|
+
lines.join("\n")
|
|
58
|
+
else
|
|
59
|
+
summary = payload.map { |k, v| "#{k}: #{v}" }.join(", ")
|
|
60
|
+
summary = payload.inspect if summary.empty?
|
|
61
|
+
"[#{event.name}] #{summary}"
|
|
62
|
+
end
|
|
63
|
+
truncate(text)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def response_body(response)
|
|
67
|
+
response.respond_to?(:body) ? response.body : response.to_s
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse_json(str)
|
|
71
|
+
require "json"
|
|
72
|
+
JSON.parse(str.to_s)
|
|
73
|
+
rescue StandardError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def blank?(value)
|
|
78
|
+
value.nil? || value.to_s.strip.empty?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def truncate(str, limit = MAX_TEXT)
|
|
82
|
+
s = str.to_s
|
|
83
|
+
s.length > limit ? "#{s[0, limit]}…" : s
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EventTrigger
|
|
4
|
+
module Providers
|
|
5
|
+
# Lazy, crash-safe resolver for the Twilio client class.
|
|
6
|
+
#
|
|
7
|
+
# - Returns the already-loaded (or test-stubbed) client when present,
|
|
8
|
+
# so `twilio-ruby` is never loaded until a Twilio-backed provider
|
|
9
|
+
# (SMS / WhatsApp) is actually used.
|
|
10
|
+
# - Requires the gem on demand on first use.
|
|
11
|
+
# - Raises EventTrigger::Error (never LoadError/NameError) when the
|
|
12
|
+
# gem is missing or broken, so the Dispatcher can log it and the
|
|
13
|
+
# host app keeps running.
|
|
14
|
+
module TwilioClient
|
|
15
|
+
GEM_MISSING_MESSAGE =
|
|
16
|
+
'twilio-ruby gem is not installed (add `gem "twilio-ruby"` to your Gemfile)'.freeze
|
|
17
|
+
|
|
18
|
+
def self.resolve
|
|
19
|
+
klass = loaded_client
|
|
20
|
+
return klass if klass
|
|
21
|
+
|
|
22
|
+
begin
|
|
23
|
+
require "twilio-ruby"
|
|
24
|
+
rescue LoadError, StandardError => e
|
|
25
|
+
raise Error, "#{GEM_MISSING_MESSAGE} (#{e.class}: #{e.message})"
|
|
26
|
+
end
|
|
27
|
+
loaded_client || raise(Error, GEM_MISSING_MESSAGE)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Explicit Object-space lookup (instead of lexical `defined?`) so
|
|
31
|
+
# test doubles (stub_const / hide_const) behave predictably.
|
|
32
|
+
def self.loaded_client
|
|
33
|
+
return nil unless ::Object.const_defined?(:Twilio)
|
|
34
|
+
|
|
35
|
+
rest = ::Twilio.const_defined?(:REST, false) ? ::Twilio::REST : nil
|
|
36
|
+
return nil unless rest
|
|
37
|
+
return nil unless rest.const_defined?(:Client, false)
|
|
38
|
+
|
|
39
|
+
rest.const_get(:Client, false)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -4,17 +4,28 @@ require "net/http"
|
|
|
4
4
|
require "uri"
|
|
5
5
|
require "json"
|
|
6
6
|
require_relative "../provider"
|
|
7
|
+
require_relative "http_poster"
|
|
7
8
|
|
|
8
9
|
module EventTrigger
|
|
9
10
|
module Providers
|
|
10
|
-
#
|
|
11
|
+
# Generic HTTP webhook provider for any custom system.
|
|
11
12
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
13
|
+
# Existing behaviour (unchanged): POSTs { event:, payload:,
|
|
14
|
+
# timestamp: } as JSON to config.webhook.url with optional
|
|
15
|
+
# config.webhook.headers.
|
|
16
|
+
#
|
|
17
|
+
# NEW additive options (only used when set):
|
|
18
|
+
# config.webhook.method = :post # :post (default), :put, :patch, :delete, :get
|
|
19
|
+
# NOTE: ProviderConfig defines Object#method, so write the verb via
|
|
20
|
+
# `config.webhook[:method] = :post` (reader also via config[:method]).
|
|
21
|
+
# config.webhook.url = ENV["EVENT_WEBHOOK_URL"]
|
|
15
22
|
# config.webhook.headers = { "Authorization" => "Bearer ..." }
|
|
16
|
-
#
|
|
23
|
+
#
|
|
24
|
+
# method is normalized (:POST/"post" all work); unknown verbs fall back
|
|
25
|
+
# to POST so old configs never break.
|
|
17
26
|
class WebhookProvider < Provider
|
|
27
|
+
include HttpPoster
|
|
28
|
+
|
|
18
29
|
def self.provider_name
|
|
19
30
|
:webhook
|
|
20
31
|
end
|
|
@@ -23,13 +34,19 @@ module EventTrigger
|
|
|
23
34
|
url = config.url
|
|
24
35
|
raise Error, "Webhook url is not configured" if url.nil? || url.to_s.strip.empty?
|
|
25
36
|
|
|
26
|
-
|
|
37
|
+
request_json(http_method, url.to_s, event_to_json(event), headers, service: "Webhook")
|
|
27
38
|
log("delivered event '#{event.name}' to #{url}")
|
|
28
39
|
true
|
|
29
40
|
end
|
|
30
41
|
|
|
42
|
+
|
|
31
43
|
private
|
|
32
44
|
|
|
45
|
+
def http_method
|
|
46
|
+
m = ((config[:method] rescue nil) || :post).to_s.downcase
|
|
47
|
+
%w[get post put patch delete].include?(m) ? m.to_sym : :post
|
|
48
|
+
end
|
|
49
|
+
|
|
33
50
|
def headers
|
|
34
51
|
{ "Content-Type" => "application/json" }.merge(config.headers || {})
|
|
35
52
|
end
|
|
@@ -45,21 +62,6 @@ module EventTrigger
|
|
|
45
62
|
def stringify(hash)
|
|
46
63
|
hash.each_with_object({}) { |(k, v), m| m[k.to_s] = v }
|
|
47
64
|
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
65
|
end
|
|
64
66
|
end
|
|
65
67
|
end
|