messengerr 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c1c93598f3e402b4a4096677f451d9b4116c2378d5fb1466405a0ba0d436802a
4
- data.tar.gz: 58064c3aa1e92ec5812a301d1cf4be8191fac258abb2d1bfad65c9644ef5f9c3
3
+ metadata.gz: 8f907dbc0370e1a30035ba22269b2ff1adb90facd0ea9836b360cc539827ac61
4
+ data.tar.gz: 9d08c5eff77009d71e3137f4bb4ae221e4c2292d029d6057166c2c7ce1635ecf
5
5
  SHA512:
6
- metadata.gz: c11c74416f64968164c0de51700f0e52906affcf36cd05f21965b116ed90c83db89835a5c2c0223d9d81240ebadbc235dd343fbfae0624561b3660e0396a48e8
7
- data.tar.gz: 4b32107a5ffe04d44101b29818af58e5ab5cf0a637c71c355ee7c73229941cde1a08d83ab19d2b19a026d99eb4f3e422916ef9720646bead74142b17701ab5ca
6
+ metadata.gz: 4fca38b64037c0014f5ae69e1f00acdaa0191e00d0fe7934dfa0987f7579748f5484595da025cb0ff149dedc35dd7d5924eba4bb6ea9123ba0172538273a3fc2
7
+ data.tar.gz: 7b09fdf9a874fefdeef28d5415ee50a314fc949d5d964cc4d5bbf73e5d6c2154e6c384bac39218286cc5b6438409c44821e28c5f506a45f84e8651a981579f71
data/README.md CHANGED
@@ -27,6 +27,15 @@ messengerr send --dry-run # walk the wizard and preview, sending n
27
27
  messengerr send # preview, confirm, send
28
28
  ```
29
29
 
30
+ Every wizard question also has a flag, so a send can be one command. Preview it anywhere with `--dry-run`, then run the same command without it in a terminal:
31
+
32
+ ```sh
33
+ messengerr send --method email --csv contacts.csv --contact email --map name=name \
34
+ --message "Hi {{name}}, your invoice is ready." --subject "Invoice for {{name}}" --dry-run
35
+ ```
36
+
37
+ A real send is refused unless a person is at a terminal (so an automated shell can prepare and preview, but not send).
38
+
30
39
  Given a CSV like
31
40
 
32
41
  ```text
@@ -28,17 +28,19 @@ module Messengerr
28
28
  @prompter.say(@parser.help)
29
29
  return 0
30
30
  end
31
+ guard_interactive!
31
32
  check_resume_ledger!
32
33
  @file_message = load_message_file
33
34
 
34
35
  method = pick_delivery_method
35
36
  check_html_method!(method)
37
+ check_subject_method!(method)
36
38
  integration_class = pick_integration(method) or return 1
37
39
  check_html_supported!(integration_class)
38
40
  message, subject = collect_message(method)
39
41
  source = load_csv or return 1
40
- mapping = map_placeholders(message, subject, source)
41
- contact_column = pick_contact_column(method, source)
42
+ mapping = map_placeholders(message, subject, source) or return 1
43
+ contact_column = pick_contact_column(method, source) or return 1
42
44
  outcome = validate_recipients(source, contact_column, method) or return 1
43
45
  deliveries = preview_and_confirm(outcome[:valid], message, subject, mapping, method) or return 1
44
46
 
@@ -54,6 +56,7 @@ module Messengerr
54
56
  extra = @parser.parse(args)
55
57
  raise Error, "unexpected argument: #{extra.join(' ')} (see `messengerr send --help`)" unless extra.empty?
56
58
  raise Error, "use either --html or --message-file, not both (see `messengerr send --help`)" if @flags[:html] && @flags[:message_file]
59
+ raise Error, "use either --message or --message-file/--html, not both" if @flags[:message] && (@flags[:message_file] || @flags[:html])
57
60
  rescue OptionParser::ParseError => e
58
61
  raise Error, "#{e.message} (see `messengerr send --help`)"
59
62
  end
@@ -61,7 +64,19 @@ module Messengerr
61
64
  def build_parser
62
65
  OptionParser.new do |opts|
63
66
  opts.banner = "Usage: messengerr send [flags]"
67
+ opts.on("--method METHOD", %w[email sms], "Delivery method (skips the chooser)") { |v| @flags[:method] = v.to_sym }
68
+ opts.on("--integration NAME", "Integration to use (e.g. postmark, clicksend)") { |v| @flags[:integration] = present("--integration", v) }
64
69
  opts.on("--dry-run", "Preview the rendered messages; send nothing") { @flags[:dry_run] = true }
70
+ opts.on("--csv PATH", "Contact CSV (skips the path prompt)") { |v| @flags[:csv] = present("--csv", v) }
71
+ opts.on("--contact COLUMN", "Column holding the email/phone contact value") { |v| @flags[:contact] = present("--contact", v) }
72
+ opts.on("--map PLACEHOLDER=COLUMN", "Map a {{placeholder}} to a CSV column (repeatable)") do |v|
73
+ key, column = v.split("=", 2)
74
+ raise OptionParser::InvalidArgument, "#{v} (expected PLACEHOLDER=COLUMN)" if key.nil? || column.nil? || key.empty? || column.empty?
75
+ (@flags[:map] ||= {})[key] = column
76
+ end
77
+ opts.on("--message TEXT", "Message body as a literal argument") { |v| @flags[:message] = present("--message", v) }
78
+ opts.on("--subject TEXT", "Email subject line ({{placeholders}} allowed)") { |v| @flags[:subject] = present("--subject", v) }
79
+ opts.on("--allow-non-interactive", "Send without an interactive terminal (human scripts only)") { @flags[:allow_non_interactive] = true }
65
80
  opts.on("--resume LEDGER", "Skip recipients already sent in this ledger (row + contact)") do |v|
66
81
  @flags[:resume] = v
67
82
  @flags[:ledger] = v
@@ -76,12 +91,30 @@ module Messengerr
76
91
  end
77
92
  end
78
93
 
94
+ # A blank value is almost always a mistake (an empty shell variable), and
95
+ # for --message it would mean sending an empty message.
96
+ def present(flag, value)
97
+ raise Error, "#{flag} can't be blank (see `messengerr send --help`)" if value.strip.empty?
98
+
99
+ value
100
+ end
101
+
79
102
  def positive(value)
80
103
  raise OptionParser::InvalidArgument, "#{value} (must be a positive integer)" unless value.positive?
81
104
 
82
105
  value
83
106
  end
84
107
 
108
+ # Real sends are for humans at terminals. An agent shell or CI hitting
109
+ # this path gets a clear refusal; the documented escape hatch exists for
110
+ # the user's own scripts only.
111
+ def guard_interactive!
112
+ return if @flags[:dry_run] || @flags[:allow_non_interactive] || @prompter.interactive?
113
+
114
+ raise Error, "refusing to send without an interactive terminal: run `messengerr send` yourself in a terminal " \
115
+ "(or pass --allow-non-interactive in your own scripts). --dry-run works anywhere."
116
+ end
117
+
85
118
  # A mistyped --resume path must not silently mean "nothing sent yet".
86
119
  def check_resume_ledger!
87
120
  return unless @flags[:resume]
@@ -114,6 +147,10 @@ module Messengerr
114
147
  raise Error, "--html is only for email; SMS messages are plain text (use --message-file)" if @flags[:html] && method != :email
115
148
  end
116
149
 
150
+ def check_subject_method!(method)
151
+ raise Error, "--subject is only for email; SMS messages have no subject" if @flags[:subject] && method != :email
152
+ end
153
+
117
154
  def check_html_supported!(integration_class)
118
155
  return unless @flags[:html]
119
156
  return if integration_class.formats.include?(:html)
@@ -124,7 +161,7 @@ module Messengerr
124
161
  def message_format = @flags[:html] ? :html : :text
125
162
 
126
163
  def pick_delivery_method
127
- @prompter.choose("Delivery method:", %w[email sms]).to_sym
164
+ @flags[:method] || @prompter.choose("Delivery method:", %w[email sms]).to_sym
128
165
  end
129
166
 
130
167
  def pick_integration(method)
@@ -135,6 +172,17 @@ module Messengerr
135
172
  return nil
136
173
  end
137
174
 
175
+ if @flags[:integration]
176
+ wanted = @flags[:integration].to_sym
177
+ match = integrations.find { |i| i.integration_key == wanted }
178
+ unless match
179
+ @prompter.say("Integration '#{wanted}' is not configured for #{method}. " \
180
+ "Configured: #{integrations.map(&:integration_key).join(', ')}")
181
+ return nil
182
+ end
183
+ return match
184
+ end
185
+
138
186
  return integrations.first if integrations.length == 1
139
187
 
140
188
  keys = integrations.map(&:integration_key)
@@ -143,13 +191,13 @@ module Messengerr
143
191
  end
144
192
 
145
193
  def collect_message(method)
146
- message = @file_message
147
- if message
194
+ message = @file_message || @flags[:message]
195
+ if @file_message
148
196
  @prompter.say("Message: #{message.lines.length} line(s) from #{@flags[:html] || @flags[:message_file]}")
149
- else
197
+ elsif message.nil?
150
198
  message = @prompter.ask("Message (use {{column}} placeholders):")
151
199
  end
152
- subject = ask_subject if method == :email
200
+ subject = @flags[:subject] || (ask_subject if method == :email)
153
201
  [ message, subject ]
154
202
  end
155
203
 
@@ -164,7 +212,7 @@ module Messengerr
164
212
  end
165
213
 
166
214
  def load_csv
167
- path = @prompter.ask("Path to CSV file:")
215
+ path = @flags[:csv] || @prompter.ask("Path to CSV file:")
168
216
  source = CsvSource.from(path)
169
217
  @prompter.say("Columns: #{source.headers.join(', ')}")
170
218
  source
@@ -176,15 +224,31 @@ module Messengerr
176
224
  # Placeholders in the subject are mapped just like body placeholders.
177
225
  def map_placeholders(message, subject, source)
178
226
  placeholders = Placeholder.extract(message) + Placeholder.extract(subject)
227
+ flag_map = @flags[:map] || {}
228
+ flag_map.each_key do |placeholder|
229
+ unless source.headers.include?(flag_map[placeholder])
230
+ @prompter.say("--map #{placeholder}=#{flag_map[placeholder]}: no such column in the CSV.")
231
+ return nil
232
+ end
233
+ end
234
+ unused = flag_map.keys - placeholders
235
+ unless unused.empty?
236
+ @prompter.say("Note: --map #{unused.join(', ')} not used: no such {{placeholder}} in the message or subject.")
237
+ end
179
238
  placeholders.uniq.to_h do |placeholder|
180
- column = @prompter.choose("Map {{#{placeholder}}} to column:", source.headers)
239
+ column = flag_map[placeholder] || @prompter.choose("Map {{#{placeholder}}} to column:", source.headers)
181
240
  [ placeholder, column ]
182
241
  end
183
242
  end
184
243
 
185
244
  def pick_contact_column(method, source)
186
245
  label = { email: "email address", sms: "phone number" }[method]
187
- @prompter.choose("Which column holds the #{label}?", source.headers)
246
+ column = @flags[:contact] || @prompter.choose("Which column holds the #{label}?", source.headers)
247
+ unless source.headers.include?(column)
248
+ @prompter.say("--contact #{column}: no such column in the CSV.")
249
+ return nil
250
+ end
251
+ column
188
252
  end
189
253
 
190
254
  def validate_recipients(source, contact_column, method)
@@ -3,11 +3,16 @@ require "io/console"
3
3
  module Messengerr
4
4
  # Injectable stdin/stdout interaction so CLI flows are scriptable and testable.
5
5
  class Prompter
6
- def initialize(input: $stdin, output: $stdout)
6
+ def initialize(input: $stdin, output: $stdout, interactive: nil)
7
7
  @input = input
8
8
  @output = output
9
+ @interactive = interactive.nil? ? (@input.respond_to?(:tty?) && @input.tty?) : interactive
9
10
  end
10
11
 
12
+ # Real sends require a human at a terminal; anything else (pipes, agent
13
+ # shells) is non-interactive. Specs inject the value.
14
+ def interactive? = @interactive
15
+
11
16
  def say(message)
12
17
  @output.puts(message)
13
18
  end
@@ -1,3 +1,3 @@
1
1
  module Messengerr
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: messengerr
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daiwick