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,117 @@
1
+ require "yaml"
2
+ require "fileutils"
3
+ require "securerandom"
4
+
5
+ module Messengerr
6
+ # Encrypted YAML store for integration credentials (Rails-credentials-style).
7
+ #
8
+ # Threat model: the encrypted file protects credentials only when the master
9
+ # key is kept separate from it. Set MESSENGERR_MASTER_KEY (hex, 64 chars) to
10
+ # keep the key out of the filesystem entirely. Otherwise a master.key file
11
+ # (mode 600, dir 700) is generated next to the credentials file — equivalent
12
+ # in practice to a mode-600 plaintext file, protecting mainly against the
13
+ # .enc file leaking on its own (backups, VCS, shared systems).
14
+ class Credentials
15
+ def initialize(credentials_path: Paths.credentials_file, key_path: Paths.master_key_file)
16
+ @credentials_path = credentials_path
17
+ @key_path = key_path
18
+ end
19
+
20
+ def read(integration_key)
21
+ data.fetch("integrations", {}).fetch(integration_key.to_s, nil)
22
+ end
23
+
24
+ def write(integration_key, values)
25
+ merged = data
26
+ merged["integrations"] ||= {}
27
+ merged["integrations"][integration_key.to_s] = values.transform_keys(&:to_s)
28
+ persist(merged)
29
+ end
30
+
31
+ def configured?(integration_key)
32
+ !read(integration_key).nil?
33
+ end
34
+
35
+ def keys
36
+ data.fetch("integrations", {}).keys
37
+ end
38
+
39
+ def exists?
40
+ File.exist?(@credentials_path)
41
+ end
42
+
43
+ def delete_file!
44
+ FileUtils.rm_f(@credentials_path)
45
+ end
46
+
47
+ def purge!
48
+ delete_file!
49
+ FileUtils.rm_f(@key_path)
50
+ end
51
+
52
+ private
53
+
54
+ def data
55
+ return {} unless exists?
56
+
57
+ YAML.safe_load(Crypto.decrypt(File.read(@credentials_path), master_key(read_only: true))) || {}
58
+ rescue Psych::SyntaxError
59
+ raise CredentialsError, "credentials file decrypted to invalid YAML — it may be corrupted"
60
+ end
61
+
62
+ def persist(hash)
63
+ dir = File.dirname(@credentials_path)
64
+ # mkdir_p creates missing directories 0700; directories that already
65
+ # exist (e.g. a shared MESSENGERR_CONFIG_DIR) are never chmod-ed.
66
+ FileUtils.mkdir_p(dir, mode: 0o700)
67
+ write_secret(@credentials_path, Crypto.encrypt(YAML.dump(hash), master_key))
68
+ rescue Errno::EACCES, Errno::EROFS
69
+ raise CredentialsError,
70
+ "cannot write #{@credentials_path} — check permissions on #{dir}"
71
+ end
72
+
73
+ def master_key(read_only: false)
74
+ env_key = ENV["MESSENGERR_MASTER_KEY"]
75
+ return env_master_key(env_key) if env_key
76
+
77
+ if File.exist?(@key_path)
78
+ hex = File.read(@key_path).strip
79
+ unless hex.match?(/\A[0-9a-fA-F]{64}\z/)
80
+ raise CredentialsError, "#{@key_path} is malformed — run `messengerr credentials delete --purge` to reset"
81
+ end
82
+ [ hex ].pack("H*")
83
+ elsif read_only
84
+ raise CredentialsError,
85
+ "credentials exist but no master key is available — set MESSENGERR_MASTER_KEY " \
86
+ "or run `messengerr credentials delete` to reset"
87
+ else
88
+ key = SecureRandom.random_bytes(32)
89
+ write_secret(@key_path, key.unpack1("H*"))
90
+ key
91
+ end
92
+ end
93
+
94
+ # An explicitly set key must be valid: an empty or malformed value is a
95
+ # misconfiguration, never a reason to fall back to a key file silently.
96
+ def env_master_key(value)
97
+ hex = value.strip
98
+ unless hex.match?(/\A\h{64}\z/)
99
+ raise CredentialsError,
100
+ "MESSENGERR_MASTER_KEY must be 64 hex characters (32 bytes)" \
101
+ "#{' — it is set but empty' if hex.empty?}; unset it to use the key file instead"
102
+ end
103
+ [ hex ].pack("H*")
104
+ end
105
+
106
+ # Create files 0600 from birth and write atomically (temp + rename) so a
107
+ # crash never leaves a truncated credentials file.
108
+ def write_secret(path, contents)
109
+ dir = File.dirname(path)
110
+ FileUtils.mkdir_p(dir, mode: 0o700)
111
+
112
+ tmp = File.join(dir, ".#{File.basename(path)}.tmp")
113
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |f| f.write(contents) }
114
+ File.rename(tmp, path)
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,39 @@
1
+ require "openssl"
2
+ require "base64"
3
+
4
+ module Messengerr
5
+ # AES-256-GCM encryption. Encrypted payload is base64(iv + ciphertext + tag).
6
+ module Crypto
7
+ CIPHER = "aes-256-gcm"
8
+ IV_LENGTH = 12
9
+ TAG_LENGTH = 16
10
+
11
+ def self.encrypt(plaintext, key)
12
+ cipher = OpenSSL::Cipher.new(CIPHER).encrypt
13
+ cipher.key = key
14
+ iv = cipher.random_iv
15
+ ciphertext = cipher.update(plaintext) + cipher.final
16
+ Base64.strict_encode64(iv + ciphertext + cipher.auth_tag)
17
+ end
18
+
19
+ def self.decrypt(payload, key)
20
+ data = Base64.strict_decode64(payload)
21
+ if data.bytesize < IV_LENGTH + TAG_LENGTH + 1
22
+ raise CredentialsError, "credentials file is truncated or malformed"
23
+ end
24
+ iv, rest = data[0, IV_LENGTH], data[IV_LENGTH..]
25
+ ciphertext, tag = rest[0...-TAG_LENGTH], rest[-TAG_LENGTH..]
26
+
27
+ cipher = OpenSSL::Cipher.new(CIPHER).decrypt
28
+ cipher.key = key
29
+ cipher.iv = iv
30
+ cipher.auth_tag = tag
31
+ cipher.auth_data = ""
32
+ cipher.update(ciphertext) + cipher.final
33
+ rescue CredentialsError
34
+ raise
35
+ rescue StandardError
36
+ raise CredentialsError, "credentials could not be decrypted — master key is missing or incorrect"
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,31 @@
1
+ require "csv"
2
+
3
+ module Messengerr
4
+ # Local CSV file → headers + string-keyed row hashes.
5
+ class CsvSource
6
+ attr_reader :headers, :rows
7
+
8
+ def self.from(path)
9
+ expanded = File.expand_path(path)
10
+ raise Error, "CSV file not found: #{path}" unless File.file?(expanded)
11
+
12
+ table = CSV.read(expanded, headers: true, encoding: "bom|utf-8")
13
+ headers = table.headers.compact
14
+ raise Error, "CSV has no header row: #{path}" if headers.empty?
15
+
16
+ duplicates = headers.tally.select { |_, count| count > 1 }.keys
17
+ unless duplicates.empty?
18
+ raise Error, "CSV has duplicate headers: #{duplicates.join(', ')}"
19
+ end
20
+
21
+ new(headers: table.headers, rows: table.map(&:to_h))
22
+ rescue CSV::MalformedCSVError, Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => e
23
+ raise Error, "could not parse CSV #{path}: #{e.message}"
24
+ end
25
+
26
+ def initialize(headers:, rows:)
27
+ @headers = headers
28
+ @rows = rows
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,15 @@
1
+ module Messengerr
2
+ # One fully-rendered outbound message: who it goes to, the body, and an
3
+ # optional per-recipient subject (email). `format` says how to read `body`:
4
+ # :text (the default) or :html. Only integrations that declare :html in
5
+ # `.formats` are ever handed an :html delivery.
6
+ Delivery = Data.define(:recipient, :body, :subject, :format) do
7
+ def initialize(recipient:, body:, subject:, format: :text)
8
+ raise ArgumentError, "unknown delivery format: #{format.inspect}" unless %i[text html].include?(format)
9
+
10
+ super
11
+ end
12
+
13
+ def html? = format == :html
14
+ end
15
+ end
@@ -0,0 +1,10 @@
1
+ module Messengerr
2
+ class Error < StandardError; end
3
+ class CredentialsError < Error; end
4
+ class IntegrationError < Error; end
5
+ # The provider may have accepted the request (timeout after sending, 5xx,
6
+ # unreadable response). Retrying can duplicate the message.
7
+ class UncertainError < IntegrationError; end
8
+ class ValidationError < Error; end
9
+ class RenderError < Error; end
10
+ end
@@ -0,0 +1,59 @@
1
+ module Messengerr
2
+ module Integrations
3
+ # Contract every delivery integration implements. Instances are built with
4
+ # the integration's decrypted credentials hash.
5
+ class Base
6
+ # :email or :sms — declared by the integration, not encoded in the namespace.
7
+ def self.delivery_method
8
+ raise NotImplementedError, "#{name} must declare .delivery_method"
9
+ end
10
+
11
+ # Body formats this integration can send: :text, and :html if it
12
+ # delivers HTML. The CLI refuses --html for an integration that doesn't
13
+ # list :html, so markup is never sent as plain text by mistake.
14
+ def self.formats
15
+ [ :text ]
16
+ end
17
+
18
+ def self.integration_key
19
+ name.split("::").last.downcase.to_sym
20
+ end
21
+
22
+ # Parallel namespaces resolved by convention; override to wire explicitly.
23
+ def self.setup_class
24
+ Messengerr::Setups.const_get(class_name, false)
25
+ end
26
+
27
+ def self.options_class
28
+ Messengerr::SendingOptions.const_get(class_name, false)
29
+ end
30
+
31
+ def self.class_name
32
+ name.split("::").last
33
+ end
34
+
35
+ def self.inherited(subclass)
36
+ super
37
+ Registry.register(subclass) if subclass.name
38
+ end
39
+
40
+ def initialize(credentials)
41
+ @credentials = credentials
42
+ end
43
+
44
+ # Send each Delivery (recipient + rendered body + optional subject).
45
+ # → Array of Result, one per delivery — exactly one per recipient.
46
+ # Integrations that send one by one should `yield` each Result as it lands
47
+ # (optional block) so the ledger survives a crash mid-batch. Mark a failure
48
+ # `uncertain: true` unless the provider definitely rejected it.
49
+ def send_batch(deliveries, options)
50
+ raise NotImplementedError, "#{self.class.name} must implement #send_batch"
51
+ end
52
+
53
+ # Cheap live call proving the credentials work; raise IntegrationError on failure.
54
+ def verify!
55
+ raise NotImplementedError, "#{self.class.name} must implement #verify!"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,92 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module Messengerr
6
+ module Integrations
7
+ # ClickSend — SMS delivery via the v3 REST API using basic auth
8
+ # (username + api_key). No SDK dependency.
9
+ class ClickSend < Base
10
+ API_URL = "https://rest.clicksend.com/v3"
11
+
12
+ def self.delivery_method
13
+ :sms
14
+ end
15
+
16
+ def send_batch(deliveries, options)
17
+ body = {
18
+ messages: deliveries.map { |delivery|
19
+ { to: delivery.recipient.contact, body: delivery.body }.tap do |m|
20
+ m[:from] = options.from || @credentials["from"] if options.from || @credentials["from"]
21
+ m[:schedule] = options.schedule if options.schedule
22
+ end
23
+ }
24
+ }
25
+ response = post("/sms/send", body)
26
+ results = response.dig("data", "messages") || []
27
+ deliveries.each_with_index.map do |delivery, index|
28
+ entry = results[index]
29
+ if entry && %w[SUCCESS QUEUED].include?(entry["status"])
30
+ Result.new(recipient: delivery.recipient, status: :sent, provider_id: entry["message_id"])
31
+ else
32
+ Result.new(recipient: delivery.recipient, status: :failed,
33
+ reason: entry ? entry["status"] : "no status returned",
34
+ uncertain: entry.nil?)
35
+ end
36
+ end
37
+ rescue IntegrationError => e
38
+ uncertain = e.is_a?(UncertainError)
39
+ deliveries.map { |d| Result.new(recipient: d.recipient, status: :failed, reason: e.message, uncertain:) }
40
+ end
41
+
42
+ def verify!
43
+ response = request(:get, "/account")
44
+ raise IntegrationError, "ClickSend verification failed (#{response.code})" unless response.is_a?(Net::HTTPSuccess)
45
+
46
+ true
47
+ rescue IntegrationError
48
+ raise
49
+ rescue StandardError => e
50
+ raise IntegrationError, "ClickSend unreachable: #{e.message}"
51
+ end
52
+
53
+ private
54
+
55
+ def post(path, payload)
56
+ response = request(:post, path, payload)
57
+ unless response.is_a?(Net::HTTPSuccess)
58
+ # 4xx = definitively rejected. 5xx (incl. gateway 502/504) can happen
59
+ # after the batch was queued, so the outcome is unknown.
60
+ error = response.code.to_i >= 500 ? UncertainError : IntegrationError
61
+ raise error, "ClickSend rejected the request (#{response.code}): #{response.body.to_s[0, 200]}"
62
+ end
63
+
64
+ JSON.parse(response.body)
65
+ rescue JSON::ParserError
66
+ raise UncertainError, "ClickSend returned an unreadable response (#{response.code}) — status unknown"
67
+ end
68
+
69
+ def request(verb, path, payload = nil)
70
+ uri = URI("#{API_URL}#{path}")
71
+ http = Net::HTTP.new(uri.host, uri.port)
72
+ http.use_ssl = true
73
+ http.open_timeout = 5
74
+ http.read_timeout = 10
75
+
76
+ request = verb == :get ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
77
+ request.basic_auth(@credentials["username"], @credentials["api_key"])
78
+ if payload
79
+ request["Content-Type"] = "application/json"
80
+ request.body = JSON.dump(payload)
81
+ end
82
+ http.request(request)
83
+ rescue Net::OpenTimeout, Errno::ECONNREFUSED, SocketError, OpenSSL::SSL::SSLError => e
84
+ # Failed before the request reached ClickSend: definitely not sent.
85
+ raise IntegrationError, "ClickSend unreachable: #{e.message}"
86
+ rescue Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNRESET, Errno::EPIPE, EOFError => e
87
+ # The request may have been processed before the connection died.
88
+ raise UncertainError, "ClickSend connection lost mid-request (status unknown): #{e.message}"
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,77 @@
1
+ module Messengerr
2
+ module Integrations
3
+ # Postmark — email delivery via the postmark gem (lazy-loaded).
4
+ class Postmark < Base
5
+ def self.delivery_method
6
+ :email
7
+ end
8
+
9
+ def self.formats
10
+ [ :text, :html ]
11
+ end
12
+
13
+ def send_batch(deliveries, options)
14
+ deliveries.map do |delivery|
15
+ deliver_one(delivery, options).tap { |result| yield result if block_given? }
16
+ end
17
+ end
18
+
19
+ def verify!
20
+ client.server_info
21
+ true
22
+ rescue LoadError
23
+ raise
24
+ rescue StandardError => e
25
+ raise IntegrationError, "Postmark verification failed: #{e.message}"
26
+ end
27
+
28
+ private
29
+
30
+ def deliver_one(delivery, options)
31
+ subject = delivery.subject || options.subject
32
+ if subject.to_s.strip.empty?
33
+ return Result.new(recipient: delivery.recipient, status: :failed, reason: "subject is required for email sends")
34
+ end
35
+
36
+ response = client.deliver(
37
+ {
38
+ from: options.from || @credentials["from"],
39
+ to: delivery.recipient.contact,
40
+ subject:,
41
+ **body_params(delivery),
42
+ message_stream: options.message_stream,
43
+ tag: options.tag,
44
+ track_opens: options.track_opens
45
+ }.compact
46
+ )
47
+ Result.new(recipient: delivery.recipient, status: :sent, provider_id: response[:message_id])
48
+ rescue StandardError => e
49
+ Result.new(recipient: delivery.recipient, status: :failed, reason: e.message,
50
+ uncertain: !definitely_rejected?(e))
51
+ end
52
+
53
+ # Exactly one body: HTML mail is HTML only, text mail is text only.
54
+ def body_params(delivery)
55
+ delivery.html? ? { html_body: delivery.body } : { text_body: delivery.body }
56
+ end
57
+
58
+ # Only a 4xx from the API proves the message was not accepted. Timeouts,
59
+ # connection errors, 5xx and anything unrecognised may have gone through.
60
+ def definitely_rejected?(error)
61
+ defined?(::Postmark::HttpServerError) &&
62
+ error.is_a?(::Postmark::HttpServerError) &&
63
+ (400..499).cover?(error.status_code)
64
+ end
65
+
66
+ def client
67
+ @client ||= begin
68
+ require "postmark"
69
+ ::Postmark::ApiClient.new(@credentials["server_token"])
70
+ rescue LoadError
71
+ raise IntegrationError,
72
+ "the postmark integration requires the 'postmark' gem — add it to your Gemfile or `gem install postmark`"
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,5 @@
1
+ module Messengerr
2
+ # Namespace for delivery integrations (Postmark, ClickSend, community gems).
3
+ module Integrations
4
+ end
5
+ end
@@ -0,0 +1,78 @@
1
+ require "json"
2
+ require "fileutils"
3
+ require "time"
4
+
5
+ module Messengerr
6
+ # Durable record of send outcomes, one JSONL line per Result, appended as
7
+ # each result lands so a crash mid-run loses nothing already delivered —
8
+ # and `--resume` can skip contacts already marked sent. It holds every
9
+ # contact's address/number, so it is created owner-only (0600).
10
+ class Ledger
11
+ def initialize(path)
12
+ @path = path
13
+ end
14
+
15
+ # Fail before anything is sent if the ledger can't be written.
16
+ def verify_writable!
17
+ FileUtils.mkdir_p(File.dirname(@path))
18
+ File.open(@path, "a", 0o600) { }
19
+ rescue SystemCallError, IOError => e
20
+ raise Error, "cannot write ledger #{@path}: #{e.message}"
21
+ end
22
+
23
+ def record(result)
24
+ FileUtils.mkdir_p(File.dirname(@path))
25
+ File.open(@path, "a", 0o600) do |file|
26
+ file.puts(JSON.dump(
27
+ row_number: result.recipient.row_number,
28
+ contact: result.recipient.contact,
29
+ status: result.status,
30
+ reason: result.reason,
31
+ provider_id: result.provider_id,
32
+ uncertain: result.uncertain,
33
+ sent_at: Time.now.utc.iso8601
34
+ ))
35
+ file.flush
36
+ end
37
+ end
38
+
39
+ # Recipients already sent per this ledger — used by --resume. A recipient
40
+ # is [row_number, contact]: repeated contacts are separate messages, so
41
+ # sending row 2 to a@x.com says nothing about row 5 to the same address.
42
+ def self.sent_recipients(path)
43
+ outcomes(path).select { |_, state| state == :sent }.keys
44
+ end
45
+
46
+ # Recipients whose last attempt failed *uncertainly* and never later
47
+ # succeeded: they may already have received the message.
48
+ def self.uncertain_recipients(path)
49
+ outcomes(path).select { |_, state| state == :uncertain }.keys
50
+ end
51
+
52
+ def self.default_path
53
+ File.join(Dir.pwd, "messengerr-ledger-#{Time.now.utc.strftime('%Y%m%d-%H%M%S')}.jsonl")
54
+ end
55
+
56
+ # [row_number, contact] → :sent | :uncertain | :failed. :sent is terminal.
57
+ def self.outcomes(path)
58
+ return {} unless File.exist?(path)
59
+
60
+ File.readlines(path).each_with_object({}) do |line, states|
61
+ entry = JSON.parse(line)
62
+ next if entry["contact"].nil?
63
+
64
+ recipient = [ entry["row_number"], entry["contact"] ]
65
+ next if states[recipient] == :sent
66
+
67
+ states[recipient] =
68
+ if entry["status"] == "sent" then :sent
69
+ elsif entry["uncertain"] then :uncertain
70
+ else :failed
71
+ end
72
+ rescue JSON::ParserError
73
+ next # a torn final line from a crash
74
+ end
75
+ end
76
+ private_class_method :outcomes
77
+ end
78
+ end
@@ -0,0 +1,19 @@
1
+ module Messengerr
2
+ # Locations for messengerr-owned files. Credentials live in the user's
3
+ # config directory (~/.config/messengerr by default, overridable with
4
+ # MESSENGERR_CONFIG_DIR) so they survive gem upgrades.
5
+ module Paths
6
+ def self.config_dir
7
+ override = ENV["MESSENGERR_CONFIG_DIR"].to_s.strip
8
+ override.empty? ? File.join(Dir.home, ".config", "messengerr") : override
9
+ end
10
+
11
+ def self.credentials_file
12
+ File.join(config_dir, "credentials.yml.enc")
13
+ end
14
+
15
+ def self.master_key_file
16
+ File.join(config_dir, "master.key")
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,10 @@
1
+ module Messengerr
2
+ # Extracts {{placeholder}} names from message text.
3
+ module Placeholder
4
+ PATTERN = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/
5
+
6
+ def self.extract(text)
7
+ text.to_s.scan(PATTERN).flatten.uniq
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,59 @@
1
+ require "io/console"
2
+
3
+ module Messengerr
4
+ # Injectable stdin/stdout interaction so CLI flows are scriptable and testable.
5
+ class Prompter
6
+ def initialize(input: $stdin, output: $stdout)
7
+ @input = input
8
+ @output = output
9
+ end
10
+
11
+ def say(message)
12
+ @output.puts(message)
13
+ end
14
+
15
+ def ask(question)
16
+ @output.print("#{question} ")
17
+ @output.flush
18
+ read_line
19
+ end
20
+
21
+ def secret(question)
22
+ @output.print("#{question} ")
23
+ @output.flush
24
+ if @input.respond_to?(:noecho) && @input.tty?
25
+ value = @input.noecho(&:gets)&.chomp
26
+ @output.puts
27
+ value
28
+ else
29
+ read_line
30
+ end
31
+ end
32
+
33
+ def choose(question, options)
34
+ @output.puts(question)
35
+ options.each_with_index { |o, i| @output.puts(" #{i + 1}) #{o}") }
36
+ loop do
37
+ answer = ask("Choose 1-#{options.length}:")
38
+ index = Integer(answer, exception: false)
39
+ return options[index - 1] if index&.between?(1, options.length)
40
+
41
+ @output.puts("Please enter a number between 1 and #{options.length}.")
42
+ end
43
+ end
44
+
45
+ def confirm(question)
46
+ answer = ask("#{question} [y/n]")
47
+ answer.to_s.strip.downcase.start_with?("y")
48
+ end
49
+
50
+ private
51
+
52
+ def read_line
53
+ line = @input.gets
54
+ raise Error, "unexpected end of input" if line.nil?
55
+
56
+ line.chomp
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,4 @@
1
+ module Messengerr
2
+ # One sendable contact: normalized contact value plus its source row data.
3
+ Recipient = Data.define(:contact, :row, :row_number)
4
+ end
@@ -0,0 +1,49 @@
1
+ module Messengerr
2
+ # Lookup table of registered integration classes. Built-in integrations
3
+ # register themselves when Zeitwerk loads them; community gems call
4
+ # Messengerr.register_integration on load.
5
+ module Registry
6
+ @integrations = []
7
+
8
+ class << self
9
+ def register(integration_class)
10
+ raise Error, "anonymous classes can't be registered as integrations" if integration_class.name.nil?
11
+
12
+ existing = @integrations.find { |i| i.integration_key == integration_class.integration_key }
13
+ if existing && existing != integration_class
14
+ raise Error,
15
+ "integration key :#{integration_class.integration_key} is already taken by #{existing.name}"
16
+ end
17
+ @integrations << integration_class unless @integrations.include?(integration_class)
18
+ end
19
+
20
+ def all
21
+ ensure_loaded
22
+ @integrations.dup
23
+ end
24
+
25
+ def by_method(delivery_method)
26
+ all.select { |i| i.delivery_method == delivery_method.to_sym }
27
+ end
28
+
29
+ def find(integration_key)
30
+ all.find { |i| i.integration_key == integration_key.to_sym }
31
+ end
32
+
33
+ def configured(credentials = Credentials.new)
34
+ all.select { |i| credentials.configured?(i.integration_key) }
35
+ end
36
+
37
+ def configured_by_method(delivery_method, credentials = Credentials.new)
38
+ by_method(delivery_method).select { |i| credentials.configured?(i.integration_key) }
39
+ end
40
+
41
+ private
42
+
43
+ # Touch the built-in integration constants so Zeitwerk loads (and registers) them.
44
+ def ensure_loaded
45
+ Integrations.constants.each { |c| Integrations.const_get(c) }
46
+ end
47
+ end
48
+ end
49
+ end