messengerr 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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +77 -0
  4. data/exe/messengerr +5 -0
  5. data/lib/messengerr/cli/credentials_command.rb +73 -0
  6. data/lib/messengerr/cli/integrations_command.rb +27 -0
  7. data/lib/messengerr/cli/send_command.rb +373 -0
  8. data/lib/messengerr/cli.rb +50 -0
  9. data/lib/messengerr/contact_validator.rb +48 -0
  10. data/lib/messengerr/credentials.rb +117 -0
  11. data/lib/messengerr/crypto.rb +39 -0
  12. data/lib/messengerr/csv_source.rb +31 -0
  13. data/lib/messengerr/delivery.rb +15 -0
  14. data/lib/messengerr/errors.rb +10 -0
  15. data/lib/messengerr/integrations/base.rb +59 -0
  16. data/lib/messengerr/integrations/clicksend.rb +92 -0
  17. data/lib/messengerr/integrations/postmark.rb +77 -0
  18. data/lib/messengerr/integrations.rb +5 -0
  19. data/lib/messengerr/ledger.rb +78 -0
  20. data/lib/messengerr/paths.rb +19 -0
  21. data/lib/messengerr/placeholder.rb +10 -0
  22. data/lib/messengerr/prompter.rb +59 -0
  23. data/lib/messengerr/recipient.rb +4 -0
  24. data/lib/messengerr/registry.rb +49 -0
  25. data/lib/messengerr/renderer.rb +30 -0
  26. data/lib/messengerr/report.rb +24 -0
  27. data/lib/messengerr/result.rb +12 -0
  28. data/lib/messengerr/sender.rb +104 -0
  29. data/lib/messengerr/sending_options/base.rb +45 -0
  30. data/lib/messengerr/sending_options/clicksend.rb +18 -0
  31. data/lib/messengerr/sending_options/postmark.rb +12 -0
  32. data/lib/messengerr/sending_options.rb +5 -0
  33. data/lib/messengerr/setups/base.rb +80 -0
  34. data/lib/messengerr/setups/clicksend.rb +13 -0
  35. data/lib/messengerr/setups/postmark.rb +9 -0
  36. data/lib/messengerr/setups.rb +5 -0
  37. data/lib/messengerr/version.rb +3 -0
  38. data/lib/messengerr.rb +22 -0
  39. metadata +201 -0
@@ -0,0 +1,30 @@
1
+ require "cgi"
2
+
3
+ module Messengerr
4
+ # Substitutes {{placeholder}} tokens with values from a CSV row, using a
5
+ # user-supplied mapping of placeholder name → column name. Any leftover
6
+ # {{ or }} after substitution is a render failure — never sent literally.
7
+ #
8
+ # With `escape: true` (HTML messages) every substituted value is HTML-escaped,
9
+ # so CSV data can never inject markup. The template itself is trusted.
10
+ module Renderer
11
+ def self.render(template, row, mapping, escape: false)
12
+ rendered = template.gsub(Placeholder::PATTERN) do
13
+ key = Regexp.last_match(1)
14
+ column = mapping[key]
15
+ raise RenderError, "no column mapped for placeholder {{#{key}}}" if column.nil?
16
+
17
+ value = row[column]
18
+ raise RenderError, "column \"#{column}\" is empty for {{#{key}}}" if value.nil? || value.strip.empty?
19
+
20
+ escape ? CGI.escapeHTML(value.strip) : value.strip
21
+ end
22
+
23
+ if rendered.match?(/\{\{|\}\}/)
24
+ raise RenderError, "unrenderable placeholder left in message: #{rendered[/\{\{[^}]*\}?\}?/]}"
25
+ end
26
+
27
+ rendered
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,24 @@
1
+ module Messengerr
2
+ # Aggregate outcome of a send run: Results plus dropped validation defects.
3
+ class Report
4
+ attr_reader :results, :dropped
5
+
6
+ def initialize(results: [], dropped: [])
7
+ @results = results
8
+ @dropped = dropped # validation defects the user chose to drop
9
+ end
10
+
11
+ def successes = @results.select(&:sent?)
12
+ def failures = @results.reject(&:sent?)
13
+
14
+ def sent_count = successes.length
15
+ def failed_count = failures.length
16
+ def dropped_count = @dropped.length
17
+
18
+ def ok? = failures.empty?
19
+
20
+ def summary
21
+ "sent: #{sent_count}, failed: #{failed_count}, dropped: #{dropped_count}"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,12 @@
1
+ module Messengerr
2
+ # Per-recipient send outcome. `uncertain` marks failures where the provider
3
+ # may still have delivered (timeouts, unreadable responses) — retrying these
4
+ # can duplicate the message.
5
+ Result = Data.define(:recipient, :status, :reason, :provider_id, :uncertain) do
6
+ def initialize(recipient:, status:, reason: nil, provider_id: nil, uncertain: false)
7
+ super
8
+ end
9
+
10
+ def sent? = status == :sent
11
+ end
12
+ end
@@ -0,0 +1,104 @@
1
+ module Messengerr
2
+ # Dispatches Deliveries through an integration in batch_size slices,
3
+ # throttled by rate_limit. Every delivery produces exactly one Result:
4
+ # exceptions become failed Results, and integrations that return too few
5
+ # results get the gaps filled rather than silently losing contacts.
6
+ #
7
+ # Integrations that send one message at a time may yield each Result from
8
+ # send_batch as it lands; the Sender records those to the ledger immediately,
9
+ # so a crash or Ctrl-C mid-batch never loses what was already delivered.
10
+ class Sender
11
+ # Why the run stopped early, if it did (currently: the ledger became
12
+ # unwritable, so no further batch is sent without a record).
13
+ attr_reader :halted_reason
14
+
15
+ def initialize(integration, options: integration.class.options_class.new, ledger: nil)
16
+ @integration = integration
17
+ @options = options
18
+ @options.validate!
19
+ @ledger = ledger
20
+ @recorded = {}
21
+ @halted_reason = nil
22
+ end
23
+
24
+ # deliveries → [Result], one per delivery, in slice order.
25
+ def deliver(deliveries)
26
+ deliveries.each_slice(@options.batch_size).with_index.flat_map do |slice, index|
27
+ throttle if index.positive? && !@halted_reason
28
+ @halted_reason ? halted_results(slice) : dispatch(slice)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def dispatch(slice)
35
+ @recorded = {} # dedupe is per batch: a retry must be recorded again
36
+ seen = {}
37
+ results =
38
+ begin
39
+ returned = @integration.send_batch(slice, @options) do |result|
40
+ seen[key(result.recipient)] = result
41
+ record(result)
42
+ end
43
+ reconcile(slice, returned, seen)
44
+ rescue Interrupt
45
+ # Whatever hadn't reported back may or may not have gone out.
46
+ slice.each { |d| record(seen[key(d.recipient)] || failure(d, "interrupted", uncertain: true)) }
47
+ raise
48
+ rescue StandardError => e
49
+ failure_results(slice, seen, e)
50
+ end
51
+
52
+ results.each { |r| record(r) }
53
+ results
54
+ end
55
+
56
+ # One Result per delivery. Results the integration yielded survive an
57
+ # exception raised later in the same batch.
58
+ def failure_results(slice, seen, error)
59
+ # A plain IntegrationError means the provider definitely rejected it;
60
+ # anything else (timeouts, UncertainError, bugs) may have gone out.
61
+ uncertain = !(error.is_a?(IntegrationError) && !error.is_a?(UncertainError))
62
+ slice.map { |d| seen[key(d.recipient)] || failure(d, error.message, uncertain:) }
63
+ end
64
+
65
+ # The contract: one Result per delivery. Gaps are filled with uncertain
66
+ # failures rather than silently dropping contacts.
67
+ def reconcile(slice, returned, seen)
68
+ by_recipient = Array(returned).group_by { |r| key(r.recipient) }
69
+ slice.map do |d|
70
+ k = key(d.recipient)
71
+ by_recipient[k]&.first || seen[k] || failure(d, "integration returned no result", uncertain: true)
72
+ end
73
+ end
74
+
75
+ def failure(delivery, reason, uncertain: false)
76
+ Result.new(recipient: delivery.recipient, status: :failed, reason:, uncertain:)
77
+ end
78
+
79
+ def halted_results(slice)
80
+ slice.map { |d| failure(d, "not sent — #{@halted_reason}") }
81
+ end
82
+
83
+ def record(result)
84
+ return unless @ledger
85
+
86
+ k = key(result.recipient)
87
+ return if @recorded[k]
88
+
89
+ @ledger.record(result)
90
+ @recorded[k] = true
91
+ rescue SystemCallError, IOError => e
92
+ # Never crash after a send: keep the in-memory result, stop sending more.
93
+ @halted_reason ||= "ledger write failed (#{e.message}); stopped so no message goes unrecorded"
94
+ end
95
+
96
+ def key(recipient)
97
+ [ recipient.row_number, recipient.contact ]
98
+ end
99
+
100
+ def throttle
101
+ sleep(1.0 / @options.rate_limit)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,45 @@
1
+ module Messengerr
2
+ module SendingOptions
3
+ # Shared sending options every service supports. Subclasses call `option`
4
+ # to add their own; each class validates its values in `validate!`.
5
+ class Base
6
+ class << self
7
+ def option(name)
8
+ options << name
9
+ attr_accessor name
10
+ end
11
+
12
+ def options
13
+ @options ||= superclass.respond_to?(:options) ? superclass.options.dup : []
14
+ end
15
+ end
16
+
17
+ option :from
18
+ option :batch_size
19
+ option :rate_limit
20
+ option :metadata
21
+
22
+ def initialize(attributes = {})
23
+ attributes.each do |key, value|
24
+ public_send("#{key}=", value)
25
+ end
26
+ @batch_size ||= 50
27
+ @rate_limit ||= 10
28
+ @metadata ||= {}
29
+ end
30
+
31
+ def validate!
32
+ errors = []
33
+ errors << "batch_size must be a positive integer" unless batch_size.is_a?(Integer) && batch_size.positive?
34
+ errors << "rate_limit must be positive" unless rate_limit.is_a?(Numeric) && rate_limit.positive?
35
+ raise ValidationError, errors.join(", ") unless errors.empty?
36
+
37
+ true
38
+ end
39
+
40
+ def to_h
41
+ self.class.options.to_h { |name| [ name, public_send(name) ] }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,18 @@
1
+ module Messengerr
2
+ module SendingOptions
3
+ class ClickSend < Base
4
+ MAX_BATCH = 1_000 # ClickSend caps /sms/send at 1000 messages per request
5
+
6
+ option :schedule
7
+
8
+ def validate!
9
+ super
10
+ if batch_size > MAX_BATCH
11
+ raise ValidationError, "batch_size can't exceed #{MAX_BATCH} for ClickSend"
12
+ end
13
+
14
+ true
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ module Messengerr
2
+ module SendingOptions
3
+ class Postmark < Base
4
+ # `subject` is a fallback only; the wizard supplies a (rendered) subject
5
+ # per Delivery, and the integration fails any delivery that has neither.
6
+ option :subject
7
+ option :message_stream
8
+ option :tag
9
+ option :track_opens
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ module Messengerr
2
+ # Namespace for per-integration sending option classes.
3
+ module SendingOptions
4
+ end
5
+ end
@@ -0,0 +1,80 @@
1
+ module Messengerr
2
+ module Setups
3
+ # Interactive credential setup for an integration. The subclass declares
4
+ # which keys it needs; this framework prompts for them, runs the
5
+ # integration's live verify!, and writes the values into the encrypted
6
+ # credentials store.
7
+ class Base
8
+ # Keys the user must supply, e.g. [:server_token]. Optional keys go in
9
+ # .optional_keys and may be left blank.
10
+ def self.required_keys
11
+ []
12
+ end
13
+
14
+ def self.optional_keys
15
+ []
16
+ end
17
+
18
+ # The integration class this setup belongs to — inferred by convention.
19
+ def self.integration_class
20
+ Messengerr::Integrations.const_get(name.split("::").last, false)
21
+ end
22
+
23
+ def initialize(integration_class: self.class.integration_class,
24
+ prompter: Prompter.new,
25
+ credentials: Credentials.new)
26
+ @integration_class = integration_class
27
+ @prompter = prompter
28
+ @credentials = credentials
29
+ end
30
+
31
+ # Returns true when credentials were verified and stored.
32
+ def run
33
+ @prompter.say("Setting up #{@integration_class.integration_key} (#{@integration_class.delivery_method}):")
34
+ values = collect_values
35
+ return false if values.nil?
36
+
37
+ if verified?(values)
38
+ @credentials.write(@integration_class.integration_key, values)
39
+ @prompter.say("#{integration_label} is configured and verified.")
40
+ true
41
+ else
42
+ @prompter.confirm("Try again?") ? run : false
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def collect_values
49
+ values = {}
50
+
51
+ self.class.required_keys.each do |key|
52
+ values[key] = @prompter.secret("#{key.to_s.tr('_', ' ')}:").to_s.strip
53
+ end
54
+ if values.values.any?(&:empty?)
55
+ @prompter.say("All required fields must have a value — aborting.")
56
+ return nil
57
+ end
58
+
59
+ self.class.optional_keys.each do |key|
60
+ value = @prompter.ask("#{key.to_s.tr('_', ' ')} (optional, enter to skip):").to_s.strip
61
+ values[key] = value unless value.empty?
62
+ end
63
+
64
+ values.transform_keys(&:to_s)
65
+ end
66
+
67
+ def verified?(values)
68
+ @integration_class.new(values).verify!
69
+ true
70
+ rescue IntegrationError => e
71
+ @prompter.say("Verification failed: #{e.message}")
72
+ false
73
+ end
74
+
75
+ def integration_label
76
+ @integration_class.integration_key.to_s.capitalize
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,13 @@
1
+ module Messengerr
2
+ module Setups
3
+ class ClickSend < Base
4
+ def self.required_keys
5
+ [ :username, :api_key ]
6
+ end
7
+
8
+ def self.optional_keys
9
+ [ :from ]
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ module Messengerr
2
+ module Setups
3
+ class Postmark < Base
4
+ def self.required_keys
5
+ [ :server_token, :from ]
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ module Messengerr
2
+ # Namespace for per-integration interactive setup wizards.
3
+ module Setups
4
+ end
5
+ end
@@ -0,0 +1,3 @@
1
+ module Messengerr
2
+ VERSION = "0.1.0"
3
+ end
data/lib/messengerr.rb ADDED
@@ -0,0 +1,22 @@
1
+ require "zeitwerk"
2
+ require_relative "messengerr/version"
3
+ require_relative "messengerr/errors"
4
+
5
+ module Messengerr
6
+ class << self
7
+ def loader
8
+ @loader ||= Zeitwerk::Loader.for_gem.tap do |loader|
9
+ loader.inflector.inflect("cli" => "CLI", "clicksend" => "ClickSend")
10
+ loader.ignore("#{__dir__}/messengerr/version.rb", "#{__dir__}/messengerr/errors.rb")
11
+ loader.setup
12
+ end
13
+ end
14
+
15
+ # Third-party gems call this on load to register an Integrations::Base subclass.
16
+ def register_integration(integration_class)
17
+ Registry.register(integration_class)
18
+ end
19
+ end
20
+ end
21
+
22
+ Messengerr.loader
metadata ADDED
@@ -0,0 +1,201 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: messengerr
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Daiwick
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-10-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: csv
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.3'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: phonelib
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.8'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.8'
55
+ - !ruby/object:Gem::Dependency
56
+ name: postmark
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.25'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.25'
69
+ - !ruby/object:Gem::Dependency
70
+ name: zeitwerk
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.6'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.6'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '13.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '13.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.13'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.13'
111
+ - !ruby/object:Gem::Dependency
112
+ name: webmock
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.23'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.23'
125
+ description: Interactive CLI that renders {{placeholder}} messages against a CSV contact
126
+ list and dispatches through pluggable services scoped by delivery method (email,
127
+ sms).
128
+ email:
129
+ - developers@daiwick.com
130
+ executables:
131
+ - messengerr
132
+ extensions: []
133
+ extra_rdoc_files: []
134
+ files:
135
+ - LICENSE
136
+ - README.md
137
+ - exe/messengerr
138
+ - lib/messengerr.rb
139
+ - lib/messengerr/cli.rb
140
+ - lib/messengerr/cli/credentials_command.rb
141
+ - lib/messengerr/cli/integrations_command.rb
142
+ - lib/messengerr/cli/send_command.rb
143
+ - lib/messengerr/contact_validator.rb
144
+ - lib/messengerr/credentials.rb
145
+ - lib/messengerr/crypto.rb
146
+ - lib/messengerr/csv_source.rb
147
+ - lib/messengerr/delivery.rb
148
+ - lib/messengerr/errors.rb
149
+ - lib/messengerr/integrations.rb
150
+ - lib/messengerr/integrations/base.rb
151
+ - lib/messengerr/integrations/clicksend.rb
152
+ - lib/messengerr/integrations/postmark.rb
153
+ - lib/messengerr/ledger.rb
154
+ - lib/messengerr/paths.rb
155
+ - lib/messengerr/placeholder.rb
156
+ - lib/messengerr/prompter.rb
157
+ - lib/messengerr/recipient.rb
158
+ - lib/messengerr/registry.rb
159
+ - lib/messengerr/renderer.rb
160
+ - lib/messengerr/report.rb
161
+ - lib/messengerr/result.rb
162
+ - lib/messengerr/sender.rb
163
+ - lib/messengerr/sending_options.rb
164
+ - lib/messengerr/sending_options/base.rb
165
+ - lib/messengerr/sending_options/clicksend.rb
166
+ - lib/messengerr/sending_options/postmark.rb
167
+ - lib/messengerr/setups.rb
168
+ - lib/messengerr/setups/base.rb
169
+ - lib/messengerr/setups/clicksend.rb
170
+ - lib/messengerr/setups/postmark.rb
171
+ - lib/messengerr/version.rb
172
+ homepage: https://github.com/jalappeno-apps/messengerr
173
+ licenses:
174
+ - MIT
175
+ metadata:
176
+ documentation_uri: https://jalappeno-apps.github.io/messengerr/
177
+ source_code_uri: https://github.com/jalappeno-apps/messengerr
178
+ bug_tracker_uri: https://github.com/jalappeno-apps/messengerr/issues
179
+ changelog_uri: https://github.com/jalappeno-apps/messengerr/blob/main/CHANGELOG.md
180
+ rubygems_mfa_required: 'true'
181
+ allowed_push_host: https://rubygems.org
182
+ post_install_message:
183
+ rdoc_options: []
184
+ require_paths:
185
+ - lib
186
+ required_ruby_version: !ruby/object:Gem::Requirement
187
+ requirements:
188
+ - - ">="
189
+ - !ruby/object:Gem::Version
190
+ version: '3.2'
191
+ required_rubygems_version: !ruby/object:Gem::Requirement
192
+ requirements:
193
+ - - ">="
194
+ - !ruby/object:Gem::Version
195
+ version: '0'
196
+ requirements: []
197
+ rubygems_version: 3.5.22
198
+ signing_key:
199
+ specification_version: 4
200
+ summary: Send templated messages to CSV contact lists over email and SMS.
201
+ test_files: []